+ * The construction metadata (title, author, date) and the application
+ * name/version are read from {@code geogebra.xml} (or, for a tool, from
+ * {@code geogebra_macro.xml}), and the user-visible text (text objects, ink
+ * notes, captions) is emitted as XHTML paragraphs. For Notes/Slides, the slide
+ * order is taken from {@code structure.json} and each slide becomes a
+ * {@code
}.
+ *
+ * The representative rendering of the document — {@code geogebra_thumbnail.png}
+ * at the root of a worksheet or tool, or the first slide's thumbnail of a
+ * Notes/Slides file — is emitted as an embedded document marked with
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, so that clients
+ * (e.g. the unpacker's sidecar metadata) can pick it as the preview image.
+ * Thumbnails of the remaining slides are renderings of content that is already
+ * extracted, so they are skipped. Any other embedded file (e.g. inserted
+ * pictures) is emitted as an embedded document.
+ */
+@TikaComponent
+public class GeoGebraParser implements Parser {
+
+ /**
+ * Serial version UID
+ */
+ private static final long serialVersionUID = 2114923339149498692L;
+
+ public static final String GEOGEBRA_PREFIX = "geogebra:";
+
+ /**
+ * The GeoGebra application flavor the file was written with,
+ * e.g. "classic", "notes", "graphing".
+ */
+ public static final Property APP_NAME =
+ Property.internalText(GEOGEBRA_PREFIX + "appName");
+
+ /**
+ * The GeoGebra application version the file was written with.
+ */
+ public static final Property APP_VERSION =
+ Property.internalText(GEOGEBRA_PREFIX + "appVersion");
+
+ /**
+ * The GeoGebra XML format version.
+ */
+ public static final Property FORMAT_VERSION =
+ Property.internalText(GEOGEBRA_PREFIX + "formatVersion");
+
+ /**
+ * The unique id GeoGebra assigns to the document.
+ */
+ public static final Property ID = Property.internalText(GEOGEBRA_PREFIX + "id");
+
+ /**
+ * The free-form date string of the construction. This is user-entered
+ * text, not necessarily a parseable date.
+ */
+ public static final Property DATE = Property.internalText(GEOGEBRA_PREFIX + "date");
+
+ /**
+ * The tool names of the macros in a tool file (or in a worksheet with
+ * embedded macros).
+ */
+ public static final Property TOOL_NAME =
+ Property.internalTextBag(GEOGEBRA_PREFIX + "toolName");
+
+ private static final Set SUPPORTED_TYPES = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList(MediaType.application("vnd.geogebra.file"),
+ MediaType.application("vnd.geogebra.slides"),
+ MediaType.application("vnd.geogebra.tool"))));
+
+ private static final String GEOGEBRA_XML = "geogebra.xml";
+ private static final String MACRO_XML = "geogebra_macro.xml";
+ private static final String STRUCTURE_JSON = "structure.json";
+ private static final String THUMBNAIL_PNG = "geogebra_thumbnail.png";
+
+ /**
+ * Housekeeping entries every ggb-like container may carry; everything
+ * else is user content and worth emitting as an embedded document.
+ */
+ private static final Set KNOWN_ENTRY_NAMES = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList(GEOGEBRA_XML, MACRO_XML, THUMBNAIL_PNG,
+ "geogebra_defaults2d.xml", "geogebra_defaults3d.xml",
+ "geogebra_javascript.js")));
+
+ private static final Pattern SLIDE_XML_PATTERN =
+ Pattern.compile("^(_slide\\d+)/" + Pattern.quote(GEOGEBRA_XML) + "$");
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Override
+ public Set getSupportedTypes(ParseContext context) {
+ return SUPPORTED_TYPES;
+ }
+
+ @Override
+ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
+ ParseContext context) throws IOException, SAXException, TikaException {
+ EmbeddedDocumentUtil embeddedDocumentUtil = new EmbeddedDocumentUtil(context);
+
+ ZipFile zipFile;
+ Object container = tis.getOpenContainer();
+ if (container instanceof ZipFile) {
+ zipFile = (ZipFile) container;
+ } else {
+ zipFile = ZipFile.builder().setFile(tis.getFile()).get();
+ tis.setOpenContainer(zipFile);
+ }
+
+ XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
+ xhtml.startDocument();
+ List slideIds = getSlideIds(zipFile);
+ if (!slideIds.isEmpty()) {
+ parseSlides(zipFile, slideIds, xhtml, metadata, context, embeddedDocumentUtil);
+ } else {
+ parseWorksheet(zipFile, xhtml, metadata, context, embeddedDocumentUtil);
+ }
+ xhtml.endDocument();
+ }
+
+ /**
+ * Returns the ordered slide directory names of a Notes/Slides file, or an
+ * empty list if this is not a Notes/Slides file. The order comes from
+ * {@code structure.json}; slides present in the zip but missing from
+ * {@code structure.json} are appended in numeric order.
+ */
+ private List getSlideIds(ZipFile zipFile) {
+ Set inZip = new LinkedHashSet<>();
+ List numericallySorted = new ArrayList<>();
+ Enumeration entries = zipFile.getEntries();
+ while (entries.hasMoreElements()) {
+ Matcher m = SLIDE_XML_PATTERN.matcher(entries.nextElement().getName());
+ if (m.matches()) {
+ numericallySorted.add(m.group(1));
+ }
+ }
+ numericallySorted.sort((a, b) -> Integer.compare(
+ Integer.parseInt(a.substring("_slide".length())),
+ Integer.parseInt(b.substring("_slide".length()))));
+
+ ZipArchiveEntry structure = zipFile.getEntry(STRUCTURE_JSON);
+ if (structure == null || numericallySorted.isEmpty()) {
+ return Collections.emptyList();
+ }
+ try (InputStream is = zipFile.getInputStream(structure)) {
+ JsonNode root = OBJECT_MAPPER.readTree(is);
+ for (JsonNode chapter : root.path("chapters")) {
+ for (JsonNode page : chapter.path("pages")) {
+ for (JsonNode element : page.path("elements")) {
+ String id = element.path("id").asText("");
+ if (numericallySorted.contains(id)) {
+ inZip.add(id);
+ }
+ }
+ }
+ }
+ } catch (IOException e) {
+ //fall through to the numeric order
+ }
+ for (String id : numericallySorted) {
+ inZip.add(id);
+ }
+ return new ArrayList<>(inZip);
+ }
+
+ private void parseWorksheet(ZipFile zipFile, XHTMLContentHandler xhtml, Metadata metadata,
+ ParseContext context, EmbeddedDocumentUtil embeddedDocumentUtil)
+ throws IOException, SAXException, TikaException {
+ ZipArchiveEntry contentXml = zipFile.getEntry(GEOGEBRA_XML);
+ if (contentXml == null) {
+ contentXml = zipFile.getEntry(MACRO_XML);
+ }
+ if (contentXml != null) {
+ parseGeoGebraXml(zipFile, contentXml, xhtml, metadata, context);
+ }
+ handleThumbnail(zipFile, zipFile.getEntry(THUMBNAIL_PNG), xhtml, context,
+ embeddedDocumentUtil);
+ handleOtherEntries(zipFile, xhtml, context, embeddedDocumentUtil);
+ }
+
+ private void parseSlides(ZipFile zipFile, List slideIds, XHTMLContentHandler xhtml,
+ Metadata metadata, ParseContext context,
+ EmbeddedDocumentUtil embeddedDocumentUtil)
+ throws IOException, SAXException, TikaException {
+ metadata.set(PagedText.N_PAGES, slideIds.size());
+ boolean first = true;
+ for (String slideId : slideIds) {
+ xhtml.startElement("div", "class", "slide");
+ ZipArchiveEntry contentXml = zipFile.getEntry(slideId + "/" + GEOGEBRA_XML);
+ if (contentXml != null) {
+ //document-level metadata comes from the first slide
+ parseGeoGebraXml(zipFile, contentXml, xhtml, first ? metadata : null, context);
+ }
+ xhtml.endElement("div");
+ if (first) {
+ handleThumbnail(zipFile, zipFile.getEntry(slideId + "/" + THUMBNAIL_PNG), xhtml,
+ context, embeddedDocumentUtil);
+ }
+ first = false;
+ }
+ handleOtherEntries(zipFile, xhtml, context, embeddedDocumentUtil);
+ }
+
+ private void parseGeoGebraXml(ZipFile zipFile, ZipArchiveEntry entry,
+ XHTMLContentHandler xhtml, Metadata metadata,
+ ParseContext context)
+ throws IOException, SAXException, TikaException {
+ try (InputStream is = zipFile.getInputStream(entry)) {
+ XMLReaderUtils.parseSAX(is,
+ new EmbeddedContentHandler(new GeoGebraXMLHandler(xhtml, metadata)), context);
+ }
+ }
+
+ /**
+ * Emits the representative thumbnail as an embedded document marked
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}.
+ */
+ private void handleThumbnail(ZipFile zipFile, ZipArchiveEntry entry, XHTMLContentHandler xhtml,
+ ParseContext context, EmbeddedDocumentUtil embeddedDocumentUtil)
+ throws IOException, SAXException {
+ if (entry == null) {
+ return;
+ }
+ Metadata embeddedMetadata = Metadata.newInstance(context);
+ embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, entry.getName());
+ embeddedMetadata.set(TikaCoreProperties.INTERNAL_PATH, entry.getName());
+ embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+ embeddedMetadata.set(Metadata.CONTENT_TYPE, "image/png");
+ if (embeddedDocumentUtil.shouldParseEmbedded(embeddedMetadata)) {
+ try (TikaInputStream tisZip = TikaInputStream.get(zipFile.getInputStream(entry))) {
+ embeddedDocumentUtil.parseEmbedded(tisZip, new EmbeddedContentHandler(xhtml),
+ embeddedMetadata, false);
+ }
+ }
+ }
+
+ /**
+ * Emits everything that is not GeoGebra housekeeping (e.g. inserted
+ * pictures) as an embedded document. Housekeeping entries are matched by
+ * their basename so the rule covers slide subdirectories, too.
+ */
+ private void handleOtherEntries(ZipFile zipFile, XHTMLContentHandler xhtml,
+ ParseContext context,
+ EmbeddedDocumentUtil embeddedDocumentUtil)
+ throws IOException, SAXException {
+ Enumeration entries = zipFile.getEntries();
+ while (entries.hasMoreElements()) {
+ ZipArchiveEntry entry = entries.nextElement();
+ if (entry.isDirectory()) {
+ continue;
+ }
+ String name = entry.getName();
+ if (STRUCTURE_JSON.equals(name)) {
+ continue;
+ }
+ String basename = name.substring(name.lastIndexOf('/') + 1);
+ if (KNOWN_ENTRY_NAMES.contains(basename)) {
+ continue;
+ }
+ Metadata embeddedMetadata = Metadata.newInstance(context);
+ embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, basename);
+ embeddedMetadata.set(TikaCoreProperties.INTERNAL_PATH, name);
+ embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+ TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
+ if (embeddedDocumentUtil.shouldParseEmbedded(embeddedMetadata)) {
+ try (TikaInputStream tisZip =
+ TikaInputStream.get(zipFile.getInputStream(entry))) {
+ embeddedDocumentUtil.parseEmbedded(tisZip, new EmbeddedContentHandler(xhtml),
+ embeddedMetadata, false);
+ }
+ }
+ }
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraXMLHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraXMLHandler.java
new file mode 100644
index 00000000000..c2574b29c41
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraXMLHandler.java
@@ -0,0 +1,155 @@
+/*
+ * 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 java.io.IOException;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * SAX handler for {@code geogebra.xml} and {@code geogebra_macro.xml}.
+ *
+ * Extracts the document metadata from the {@code <geogebra>} root and
+ * the {@code <construction>} element (when a {@link Metadata} object is
+ * given), and emits the user-visible text as XHTML paragraphs: string-literal
+ * {@code <expression>}s of text objects, the rich text of
+ * {@code <content>} elements (ink notes, inline text), element
+ * {@code <caption>}s and macro names/help texts.
+ */
+class GeoGebraXMLHandler extends DefaultHandler {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final XHTMLContentHandler xhtml;
+ private final Metadata metadata;
+ private int depth = 0;
+
+ /**
+ * @param xhtml the handler paragraphs are written to
+ * @param metadata the metadata to fill from the root and construction
+ * elements, or {@code null} to extract text only
+ */
+ GeoGebraXMLHandler(XHTMLContentHandler xhtml, Metadata metadata) {
+ this.xhtml = xhtml;
+ this.metadata = metadata;
+ }
+
+ @Override
+ public void startElement(String uri, String localName, String qName, Attributes attributes)
+ throws SAXException {
+ if (depth == 0 && "geogebra".equals(localName)) {
+ if (metadata != null) {
+ setIfNotBlank(GeoGebraParser.APP_NAME, attributes.getValue("app"));
+ setIfNotBlank(GeoGebraParser.APP_VERSION, attributes.getValue("version"));
+ setIfNotBlank(GeoGebraParser.FORMAT_VERSION, attributes.getValue("format"));
+ setIfNotBlank(GeoGebraParser.ID, attributes.getValue("id"));
+ }
+ } else if ("construction".equals(localName)) {
+ if (metadata != null) {
+ setIfNotBlank(TikaCoreProperties.TITLE, attributes.getValue("title"));
+ setIfNotBlank(TikaCoreProperties.CREATOR, attributes.getValue("author"));
+ setIfNotBlank(GeoGebraParser.DATE, attributes.getValue("date"));
+ }
+ } else if ("expression".equals(localName)) {
+ handleExpression(attributes.getValue("exp"));
+ } else if ("content".equals(localName)) {
+ handleContent(attributes.getValue("val"));
+ } else if ("caption".equals(localName)) {
+ paragraph(attributes.getValue("val"));
+ } else if ("macro".equals(localName)) {
+ String toolName = attributes.getValue("toolName");
+ if (isBlank(toolName)) {
+ toolName = attributes.getValue("cmdName");
+ }
+ if (metadata != null && !isBlank(toolName)) {
+ metadata.add(GeoGebraParser.TOOL_NAME, toolName);
+ }
+ paragraph(toolName);
+ paragraph(attributes.getValue("toolHelp"));
+ }
+ depth++;
+ }
+
+ @Override
+ public void endElement(String uri, String localName, String qName) {
+ depth--;
+ }
+
+ /**
+ * Emits a text object's expression, which is a GeoGebra string literal
+ * like {@code "some text"}. Computational expressions (anything not
+ * quoted) are geometry, not text, and are skipped.
+ */
+ private void handleExpression(String exp) throws SAXException {
+ if (exp == null || exp.length() < 2 || exp.charAt(0) != '"' ||
+ exp.charAt(exp.length() - 1) != '"') {
+ return;
+ }
+ paragraph(exp.substring(1, exp.length() - 1).replace("\\\"", "\""));
+ }
+
+ /**
+ * Emits the text runs of a rich-text {@code content} value, a JSON array
+ * of text runs like {@code [{"text":"Hello\n"}]}. All {@code text} fields
+ * are collected recursively (tables and mind maps nest them), joined, and
+ * emitted one paragraph per line.
+ */
+ private void handleContent(String val) throws SAXException {
+ if (val == null || val.isEmpty()) {
+ return;
+ }
+ JsonNode root;
+ try {
+ root = OBJECT_MAPPER.readTree(val);
+ } catch (IOException e) {
+ //not JSON; ignore
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String text : root.findValuesAsText("text")) {
+ sb.append(text);
+ }
+ for (String line : sb.toString().split("\n")) {
+ paragraph(line);
+ }
+ }
+
+ private void paragraph(String text) throws SAXException {
+ if (!isBlank(text)) {
+ xhtml.element("p", text.trim());
+ }
+ }
+
+ private void setIfNotBlank(Property property, String value) {
+ if (!isBlank(value)) {
+ metadata.set(property, value.trim());
+ }
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.trim().isEmpty();
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/geogebra/GeoGebraParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/geogebra/GeoGebraParserTest.java
new file mode 100644
index 00000000000..c30cbd5f697
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/geogebra/GeoGebraParserTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class GeoGebraParserTest extends TikaTest {
+
+ @Test
+ public void testGGB() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebra.ggb");
+ Metadata metadata = metadataList.get(0);
+ assertEquals("application/vnd.geogebra.file", metadata.get(Metadata.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 thumbnail is emitted as an embedded document marked THUMBNAIL
+ assertEquals(2, metadataList.size());
+ Metadata thumbnail = metadataList.get(1);
+ assertEquals("image/png", thumbnail.get(Metadata.CONTENT_TYPE));
+ assertEquals("geogebra_thumbnail.png", thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ @Test
+ public void testGGS() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebraSlides.ggs");
+ Metadata metadata = metadataList.get(0);
+ assertEquals("application/vnd.geogebra.slides", metadata.get(Metadata.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");
+
+ //only the first slide's thumbnail is emitted, marked THUMBNAIL
+ assertEquals(2, metadataList.size());
+ Metadata thumbnail = metadataList.get(1);
+ assertEquals("image/png", thumbnail.get(Metadata.CONTENT_TYPE));
+ assertEquals("_slide1/geogebra_thumbnail.png",
+ thumbnail.get(TikaCoreProperties.INTERNAL_PATH));
+ assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ @Test
+ public void testGGT() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebraTool.ggt");
+ Metadata metadata = metadataList.get(0);
+ assertEquals("application/vnd.geogebra.tool", metadata.get(Metadata.CONTENT_TYPE));
+ assertEquals("Midpoint tool", metadata.get(GeoGebraParser.TOOL_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
+ assertEquals(1, metadataList.size());
+ assertNull(metadata.get(TikaCoreProperties.TITLE));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra.ggb b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra.ggb
new file mode 100644
index 00000000000..4d01c3500eb
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra.ggb differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraSlides.ggs b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraSlides.ggs
new file mode 100644
index 00000000000..50bf0334366
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraSlides.ggs differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraTool.ggt b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraTool.ggt
new file mode 100644
index 00000000000..24479f7fb22
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebraTool.ggt differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
index fe9b5236d61..b1cd4a5e5d3 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
@@ -126,6 +126,8 @@ private static Set loadZipSpecializations() {
"application/vnd.apple.keynote", "application/vnd.apple.numbers",
"application/vnd.apple.pages", "application/vnd.apple.unknown.13",
"application/vnd.etsi.asic-e+zip", "application/vnd.etsi.asic-s+zip",
+ "application/vnd.geogebra.file", "application/vnd.geogebra.slides",
+ "application/vnd.geogebra.tool",
"application/vnd.google-earth.kmz", "application/vnd.mindjet.mindmanager",
"application/vnd.ms-excel.addin.macroenabled.12",
"application/vnd.ms-excel.sheet.binary.macroenabled.12",
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/GeoGebraDetector.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/GeoGebraDetector.java
new file mode 100644
index 00000000000..7cb7b55939d
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/java/org/apache/tika/detect/zip/GeoGebraDetector.java
@@ -0,0 +1,107 @@
+/*
+ * 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.detect.zip;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Enumeration;
+
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.mime.MediaType;
+
+/**
+ * Detects the zip-based GeoGebra formats by their well-known entry names:
+ *
+ * - {@code geogebra.xml} at the root → a worksheet (*.ggb)
+ * - {@code structure.json} at the root plus at least one
+ * {@code _slide<N>/geogebra.xml} → GeoGebra Notes/Slides (*.ggs)
+ * - {@code geogebra_macro.xml} at the root → a tool (*.ggt)
+ *
+ * A worksheet with macros contains both {@code geogebra.xml} and
+ * {@code geogebra_macro.xml}, so the worksheet check takes precedence over
+ * the tool check, and the decision is only made once all entry names have
+ * been seen.
+ */
+public class GeoGebraDetector implements ZipContainerDetector {
+
+ private static final MediaType GGB = MediaType.application("vnd.geogebra.file");
+ private static final MediaType GGS = MediaType.application("vnd.geogebra.slides");
+ private static final MediaType GGT = MediaType.application("vnd.geogebra.tool");
+
+ @Override
+ public MediaType detect(ZipFile zip, TikaInputStream tis) throws IOException {
+ Names names = new Names();
+ Enumeration entries = zip.getEntries();
+ while (entries.hasMoreElements()) {
+ names.update(entries.nextElement().getName());
+ }
+ return names.decide();
+ }
+
+ @Override
+ public MediaType streamingDetectUpdate(ZipArchiveEntry zae, InputStream zis,
+ StreamingDetectContext detectContext) {
+ Names names = detectContext.get(Names.class);
+ if (names == null) {
+ names = new Names();
+ detectContext.set(Names.class, names);
+ }
+ names.update(zae.getName());
+ return null;
+ }
+
+ @Override
+ public MediaType streamingDetectFinal(StreamingDetectContext detectContext) {
+ Names names = detectContext.get(Names.class);
+ return names == null ? null : names.decide();
+ }
+
+ private static class Names {
+ private boolean geogebraXml;
+ private boolean macroXml;
+ private boolean structureJson;
+ private boolean slideXml;
+
+ void update(String name) {
+ if ("geogebra.xml".equals(name)) {
+ geogebraXml = true;
+ } else if ("geogebra_macro.xml".equals(name)) {
+ macroXml = true;
+ } else if ("structure.json".equals(name)) {
+ structureJson = true;
+ } else if (name.startsWith("_slide") && name.endsWith("/geogebra.xml")) {
+ slideXml = true;
+ }
+ }
+
+ MediaType decide() {
+ if (structureJson && slideXml) {
+ return GGS;
+ }
+ if (geogebraXml) {
+ return GGB;
+ }
+ if (macroXml) {
+ return GGT;
+ }
+ return null;
+ }
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/resources/META-INF/services/org.apache.tika.detect.zip.ZipContainerDetector b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/resources/META-INF/services/org.apache.tika.detect.zip.ZipContainerDetector
index eb2bfe27742..cc0c6706d95 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/resources/META-INF/services/org.apache.tika.detect.zip.ZipContainerDetector
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/main/resources/META-INF/services/org.apache.tika.detect.zip.ZipContainerDetector
@@ -14,6 +14,7 @@
# limitations under the License.
#These are a special type of detector, and we're currently relying on this
#manual file.
+org.apache.tika.detect.zip.GeoGebraDetector
org.apache.tika.detect.zip.IPADetector
org.apache.tika.detect.zip.JarDetector
org.apache.tika.detect.zip.KMZDetector
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/java/org/apache/tika/detect/zip/GeoGebraDetectionTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/java/org/apache/tika/detect/zip/GeoGebraDetectionTest.java
new file mode 100644
index 00000000000..7b61961e9b0
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/java/org/apache/tika/detect/zip/GeoGebraDetectionTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.detect.zip;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * Test case for detecting the zip-based GeoGebra formats by their contents.
+ */
+public class GeoGebraDetectionTest extends TikaTest {
+
+ @Test
+ public void testGGBDetection() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebra.ggb");
+ assertEquals("application/vnd.geogebra.file",
+ metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+ }
+
+ @Test
+ public void testGGSDetection() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebraSlides.ggs");
+ assertEquals("application/vnd.geogebra.slides",
+ metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+ }
+
+ @Test
+ public void testGGTDetection() throws Exception {
+ List metadataList = getRecursiveMetadata("testGeoGebraTool.ggt");
+ assertEquals("application/vnd.geogebra.tool",
+ metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+ }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebra.ggb b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebra.ggb
new file mode 100644
index 00000000000..4d01c3500eb
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebra.ggb differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraSlides.ggs b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraSlides.ggs
new file mode 100644
index 00000000000..50bf0334366
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraSlides.ggs differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraTool.ggt b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraTool.ggt
new file mode 100644
index 00000000000..24479f7fb22
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-zip-commons/src/test/resources/test-documents/testGeoGebraTool.ggt differ