GeoGebraParserTest.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.parser.geogebra;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.apache.tika.TikaTest;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.PagedText;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.TikaPagedText;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
public class GeoGebraParserTest extends TikaTest {
private static final String XML_HEAD = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'};
@Test
public void testGGB() throws Exception {
List<Metadata> metadataList = getRecursiveMetadata("testGeoGebra.ggb");
Metadata metadata = metadataList.get(0);
assertEquals("application/vnd.geogebra.file", metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Pythagorean theorem", metadata.get(TikaCoreProperties.TITLE));
assertEquals("Ada Lovelace", metadata.get(TikaCoreProperties.CREATOR));
assertEquals("15 January 2026", metadata.get(GeoGebraParser.DATE));
assertEquals("classic", metadata.get(GeoGebraParser.APP_NAME));
assertEquals("5.0.815.0", metadata.get(GeoGebraParser.APP_VERSION));
assertEquals("5.0", metadata.get(GeoGebraParser.FORMAT_VERSION));
assertEquals("0c34397e-e3e1-4d1c-9cb6-fe6e54b1e88f", metadata.get(GeoGebraParser.ID));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("In a right triangle a�� + b�� = c��", content);
assertContains("Theorem statement", content);
assertContains("Drag the vertices to explore.", content);
//the embedded macro is parsed alongside geogebra.xml
assertEquals("Midpoint tool", metadata.get(GeoGebraParser.TOOL_NAME));
assertContains("Select two points to construct their midpoint", content);
assertEquals(3, metadataList.size());
Metadata thumbnail = byName(metadataList, "geogebra_thumbnail.png");
assertEquals("image/png", thumbnail.get(HttpHeaders.CONTENT_TYPE));
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
//the document script is user code
Metadata script = byName(metadataList, "geogebra_javascript.js");
assertEquals(TikaCoreProperties.EmbeddedResourceType.MACRO.toString(),
script.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
@Test
public void testGGS() throws Exception {
List<Metadata> metadataList = getRecursiveMetadata("testGeoGebraSlides.ggs");
Metadata metadata = metadataList.get(0);
assertEquals("application/vnd.geogebra.slides", metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("notes", metadata.get(GeoGebraParser.APP_NAME));
assertEquals(2, (int) metadata.getInt(PagedText.N_PAGES));
//structure.json orders _slide1 before _slide0
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("First slide text", content);
assertContains("Second slide text", content);
assertTrue(content.indexOf("First slide text") < content.indexOf("Second slide text"),
"slide order should follow structure.json");
assertContains("<div class=\"slide\">", content);
//only the first slide's thumbnail is emitted, marked THUMBNAIL
assertEquals(5, metadataList.size());
Metadata thumbnail = byName(metadataList, "_slide1/geogebra_thumbnail.png");
assertEquals("image/png", thumbnail.get(HttpHeaders.CONTENT_TYPE));
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertNull(byName(metadataList, "_slide0/geogebra_thumbnail.png"));
//the inserted picture is emitted under its full zip entry name, as an
//inline image anchored to its slide (the second page)
Metadata picture = byName(metadataList, "_slide0/8c6976e5b541/photo.png");
assertEquals("_slide0/8c6976e5b541/photo.png",
picture.get(TikaCoreProperties.INTERNAL_PATH));
assertEquals("image/png", picture.get(HttpHeaders.CONTENT_TYPE));
assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
picture.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("2", picture.get(TikaPagedText.PAGE_NUMBERS));
Metadata script = byName(metadataList, "_slide0/geogebra_javascript.js");
assertEquals(TikaCoreProperties.EmbeddedResourceType.MACRO.toString(),
script.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
/**
* A worksheet written by GeoGebra Classic 5.4 itself: three text objects,
* one of them dynamic ("Hypotenuse c = " + c), no thumbnail.
*/
@Test
public void testClassicWorksheet() throws Exception {
List<Metadata> metadataList = getRecursiveMetadata("testGeoGebra_classic.ggb");
Metadata metadata = metadataList.get(0);
assertEquals("application/vnd.geogebra.file", metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("classic", metadata.get(GeoGebraParser.APP_NAME));
assertEquals("5.4.929.3", metadata.get(GeoGebraParser.APP_VERSION));
assertEquals("5.0", metadata.get(GeoGebraParser.FORMAT_VERSION));
assertEquals("6b9c92b5-ee50-4943-a8a0-388a33f639f8", metadata.get(GeoGebraParser.ID));
//blank construction attributes are not set
assertNull(metadata.get(TikaCoreProperties.TITLE));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("<p>In a right triangle a�� + b�� = c��</p>", content);
assertContains("<p>Hypotenuse c =</p>", content);
assertContains("<p>Drag the vertices to explore.</p>", content);
//the point and segment expressions are geometry, not text
assertNotContained("(0, 0)", content);
assertNotContained("Segment", content);
assertEquals(2, metadataList.size());
Metadata script = byName(metadataList, "geogebra_javascript.js");
assertEquals(TikaCoreProperties.EmbeddedResourceType.MACRO.toString(),
script.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
/**
* A two page Notes file written by GeoGebra Notes 5.4 itself, with a
* thumbnail per page and a picture inserted on the second page.
*/
@Test
public void testNotes() throws Exception {
List<Metadata> metadataList = getRecursiveMetadata("testGeoGebra_notes.ggs");
Metadata metadata = metadataList.get(0);
assertEquals("application/vnd.geogebra.slides", metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("notes", metadata.get(GeoGebraParser.APP_NAME));
assertEquals("5.4.929.3", metadata.get(GeoGebraParser.APP_VERSION));
assertEquals(2, (int) metadata.getInt(PagedText.N_PAGES));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("<p>First page text</p>", content);
assertContains("<p>Second page text</p>", content);
assertTrue(content.indexOf("First page") < content.indexOf("Second page"), content);
//main document, first thumbnail, two scripts and the picture
assertEquals(5, metadataList.size());
Metadata thumbnail = byName(metadataList, "_slide0/geogebra_thumbnail.png");
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("image/png", thumbnail.get(HttpHeaders.CONTENT_TYPE));
assertNull(byName(metadataList, "_slide1/geogebra_thumbnail.png"));
Metadata picture = byName(metadataList,
"_slide1/29812c8a66471e456649e2d2cfeee1c6/29812c8a66471e456649e2d2cfeee1c6.png");
assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
picture.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("image/png", picture.get(HttpHeaders.CONTENT_TYPE));
assertEquals("2", picture.get(TikaPagedText.PAGE_NUMBERS));
}
@Test
public void testGGT() throws Exception {
List<Metadata> metadataList = getRecursiveMetadata("testGeoGebraTool.ggt");
Metadata metadata = metadataList.get(0);
assertEquals("application/vnd.geogebra.tool", metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Midpoint tool", metadata.get(GeoGebraParser.TOOL_NAME));
assertEquals("classic", metadata.get(GeoGebraParser.APP_NAME));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("Midpoint tool", content);
assertContains("Select two points to construct their midpoint", content);
//no thumbnail in this tool file; the macro's own construction carries
//no document metadata
assertEquals(1, metadataList.size());
assertNull(metadata.get(TikaCoreProperties.TITLE));
}
/**
* A tool with an icon: GeoGebra stores it in a directory with a generated
* name and points to it with the macro's iconFile. It is the tool's
* thumbnail, and is not emitted a second time as a picture.
*/
@Test
public void testGGTIconIsTheThumbnail() throws Exception {
String icon = "5d41402abc4b2a76b9719d911017c592/Midpoint.png";
Map<String, String> entries = new HashMap<>();
entries.put("geogebra_macro.xml", geogebra("classic", "5.0.815.0", "tool-1",
"<macro cmdName=\"Mid\" toolName=\"Midpoint\" toolHelp=\"Two points\" iconFile=\""
+ icon + "\"><construction/></macro>"));
List<Metadata> metadataList = parse(entries, Collections.singletonMap(icon, PNG), null);
assertEquals("application/vnd.geogebra.tool",
metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
assertEquals(2, metadataList.size());
Metadata thumbnail = byName(metadataList, icon);
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals("image/png", thumbnail.get(HttpHeaders.CONTENT_TYPE));
//an iconFile that is not in the zip: no thumbnail, no error
metadataList = parse(entries);
assertEquals(1, metadataList.size());
assertNull(metadataList.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
}
/**
* A worksheet keeps its own thumbnail; the icon of an embedded tool is
* just a picture then.
*/
@Test
public void testWorksheetThumbnailBeatsToolIcon() throws Exception {
String icon = "5d41402abc4b2a76b9719d911017c592/Midpoint.png";
Map<String, String> entries = new HashMap<>();
entries.put("geogebra.xml", geogebra("classic", "5.0.815.0", "ws-1", "<construction/>"));
entries.put("geogebra_macro.xml", geogebra("classic", "5.0.815.0", "ws-1",
"<macro cmdName=\"Mid\" toolName=\"Midpoint\" iconFile=\"" + icon
+ "\"><construction/></macro>"));
Map<String, byte[]> binary = new HashMap<>();
binary.put("geogebra_thumbnail.png", PNG);
binary.put(icon, PNG);
List<Metadata> metadataList = parse(entries, binary, null);
assertEquals(3, metadataList.size());
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
byName(metadataList, "geogebra_thumbnail.png")
.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.toString(),
byName(metadataList, icon).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
@Test
public void testMacroDoesNotOverrideWorksheetMetadata() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("geogebra.xml", geogebra("classic", "5.0.1.0", "doc-id",
"<construction title=\"Worksheet\" author=\"Ada\" date=\"today\">"
+ "<expression label=\"t\" exp=\""Body"\"/></construction>"));
entries.put("geogebra_macro.xml", geogebra("other", "9.9.9.9", "macro-id",
"<macro cmdName=\"Mid\" toolName=\"Midpoint\" toolHelp=\"Two points\">"
+ "<construction title=\"Macro\" author=\"Bob\" date=\"never\"/></macro>"));
Metadata metadata = parse(entries).get(0);
assertEquals("Worksheet", metadata.get(TikaCoreProperties.TITLE));
assertEquals("Ada", metadata.get(TikaCoreProperties.CREATOR));
assertEquals("today", metadata.get(GeoGebraParser.DATE));
assertEquals("classic", metadata.get(GeoGebraParser.APP_NAME));
assertEquals("5.0.1.0", metadata.get(GeoGebraParser.APP_VERSION));
assertEquals("doc-id", metadata.get(GeoGebraParser.ID));
assertEquals("Midpoint", metadata.get(GeoGebraParser.TOOL_NAME));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("Body", content);
assertContains("Two points", content);
}
@Test
public void testTextExpressions() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("geogebra.xml", geogebra("classic", "5.0.1.0", "id", "<construction>"
+ "<expression label=\"t1\" exp=\""plain text"\"/>"
//a dynamic text: literals combined with a value
+ "<expression label=\"t2\" exp=\""Area = " + a\"/>"
+ "<expression label=\"t3\" exp=\""a"+"b"\"/>"
//geometry, not text
+ "<expression label=\"f\" exp=\"x^2 + 1\"/>"
+ "<expression label=\"empty\" exp=\"""\"/>"
+ "</construction>"));
String content = parse(entries).get(0).get(TikaCoreProperties.TIKA_CONTENT);
assertContains("<p>plain text</p>", content);
assertContains("<p>Area =</p>", content);
assertContains("<p>ab</p>", content);
assertNotContained("x^2", content);
}
@Test
public void testSlidesWithoutStructureJson() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("_slide10/geogebra.xml", slide("Tenth"));
entries.put("_slide007/geogebra.xml", slide("Seventh"));
entries.put("_slide2/geogebra.xml", slide("Second"));
Metadata metadata = parse(entries, new GeoGebraParser()).get(0);
assertEquals(3, (int) metadata.getInt(PagedText.N_PAGES));
//numeric order, leading zeros aside
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertTrue(content.indexOf("Second") < content.indexOf("Seventh"), content);
assertTrue(content.indexOf("Seventh") < content.indexOf("Tenth"), content);
}
@Test
public void testStructureJsonSuppliesOrderOnly() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
//a structure.json that is not JSON at all, and one slide it would not list
entries.put("structure.json", "not json");
entries.put("_slide0/geogebra.xml", slide("Zero"));
entries.put("_slide1/geogebra.xml", slide("One"));
Metadata metadata = parse(entries).get(0);
assertEquals(2, (int) metadata.getInt(PagedText.N_PAGES));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
//the CRLF line ending of the text run does not leak into the paragraph
assertContains("<p>Zero</p>", content);
assertContains("<p>One</p>", content);
}
@Test
public void testMalformedSlideDoesNotAbortTheRest() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("structure.json", "{\"chapters\":[{\"pages\":[{\"elements\":"
+ "[{\"id\":\"_slide0\"},{\"id\":\"_slide1\"}]}]}]}");
entries.put("_slide0/geogebra.xml", XML_HEAD + "<geogebra><construction>"
+ "<expression label=\"t\" exp=\""Broken"\"/><unclosed>");
entries.put("_slide1/geogebra.xml", slide("Fine"));
List<Metadata> metadataList = parse(entries,
Collections.singletonMap("_slide1/geogebra_thumbnail.png", PNG), null);
Metadata metadata = metadataList.get(0);
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("Fine", content);
//the slide div was closed and the failure recorded
assertContains("</div>", content);
assertNotNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
//the thumbnail that follows is still emitted
assertNotNull(byName(metadataList, "_slide1/geogebra_thumbnail.png"));
}
@Test
public void testThumbnailFallsBackToNextSlide() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("_slide0/geogebra.xml", slide("Zero"));
entries.put("_slide1/geogebra.xml", slide("One"));
entries.put("_slide2/geogebra.xml", slide("Two"));
Map<String, byte[]> thumbnails = new LinkedHashMap<>();
thumbnails.put("_slide1/geogebra_thumbnail.png", PNG);
thumbnails.put("_slide2/geogebra_thumbnail.png", PNG);
List<Metadata> metadataList = parse(entries, thumbnails, new GeoGebraParser());
assertEquals(2, metadataList.size());
Metadata thumbnail = byName(metadataList, "_slide1/geogebra_thumbnail.png");
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
@Test
public void testRootWorksheetAlongsideSlides() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("geogebra.xml", geogebra("notes", "5.2.0.0", "root-id",
"<construction title=\"Root\"><expression label=\"t\" "
+ "exp=\""Root text"\"/></construction>"));
entries.put("_slide0/geogebra.xml", slide("Slide text"));
Metadata metadata = parse(entries).get(0);
assertEquals("Root", metadata.get(TikaCoreProperties.TITLE));
String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
assertContains("Root text", content);
assertContains("Slide text", content);
}
@Test
public void testHousekeepingNamesOnlyMatchAtKnownPlaces() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("geogebra.xml", geogebra("classic", "5.0.1.0", "id", "<construction/>"));
//a script hidden in a subdirectory is not the document script
entries.put("dir/geogebra_javascript.js", "alert(1)");
entries.put("dir/geogebra.xml", "<geogebra/>");
List<Metadata> metadataList = parse(entries);
assertEquals(3, metadataList.size());
Metadata script = byName(metadataList, "dir/geogebra_javascript.js");
assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString(),
script.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
assertNotNull(byName(metadataList, "dir/geogebra.xml"));
}
/**
* A crafted slide id can carry more digits than an int holds; sorting the
* slide ids must not throw a NumberFormatException out of parse().
*/
@Test
public void testSlideNumberLargerThanIntParses() throws Exception {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("structure.json", "{\"chapters\":[{\"pages\":[{\"elements\":"
+ "[{\"id\":\"_slide0\"},{\"id\":\"_slide99999999999\"}]}]}]}");
//two slides so that sorting actually compares the ids
entries.put("_slide0/geogebra.xml", "<geogebra format=\"5.0\"></geogebra>");
entries.put("_slide99999999999/geogebra.xml", "<geogebra format=\"5.0\"></geogebra>");
assertEquals("application/vnd.geogebra.slides",
parse(entries).get(0).get(HttpHeaders.CONTENT_TYPE));
}
private static String geogebra(String app, String version, String id, String body) {
return XML_HEAD + "<geogebra format=\"5.0\" version=\"" + version + "\" app=\"" + app
+ "\" id=\"" + id + "\">" + body + "</geogebra>";
}
private static String slide(String text) {
return geogebra("notes", "5.2.0.0", "slide-" + text, "<construction>"
+ "<element type=\"inlinetext\" label=\"a\"><content val=\"[{"text":""
+ text + "\\r\\n"}]\"/></element></construction>");
}
private List<Metadata> parse(Map<String, String> entries) throws Exception {
return parse(entries, null);
}
private List<Metadata> parse(Map<String, String> entries, Parser parser) throws Exception {
return parse(entries, Collections.emptyMap(), parser);
}
/**
* Parses an in-memory zip, through detection or, for a container that
* detection would not attribute to GeoGebra (a slides file without
* structure.json), with the parser directly.
*/
private List<Metadata> parse(Map<String, String> textEntries,
Map<String, byte[]> binaryEntries, Parser parser)
throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
for (Map.Entry<String, String> e : textEntries.entrySet()) {
zos.putNextEntry(new ZipEntry(e.getKey()));
zos.write(e.getValue().getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
for (Map.Entry<String, byte[]> e : binaryEntries.entrySet()) {
zos.putNextEntry(new ZipEntry(e.getKey()));
zos.write(e.getValue());
zos.closeEntry();
}
}
try (TikaInputStream tis = TikaInputStream.get(bos.toByteArray())) {
if (parser == null) {
return getRecursiveMetadata(tis, new Metadata(), new ParseContext(), false);
}
return getRecursiveMetadata(tis, parser, new Metadata(), new ParseContext(), false);
}
}
private static Metadata byName(List<Metadata> metadataList, String name) {
for (Metadata m : metadataList) {
if (name.equals(m.get(TikaCoreProperties.RESOURCE_NAME_KEY))) {
return m;
}
}
return null;
}
}