This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new ccec84eb03 TIKA-4831: Add content-based detection and a parser for 
GeoGebra files (ggb, ggs, ggt) (#3044)
ccec84eb03 is described below

commit ccec84eb030fbfddcceffe063621e74ca81b13a3
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Aug 27 20:41:05 2026 +0200

    TIKA-4831: Add content-based detection and a parser for GeoGebra files 
(ggb, ggs, ggt) (#3044)
---
 CHANGES.txt                                        |  15 +
 .../org/apache/tika/mime/tika-mimetypes.xml        |  14 +
 .../java/org/apache/tika/TikaDetectionTest.java    |   2 +
 .../apache/tika/metadata/metadata-key-fields.json  |   6 +
 .../org/apache/tika/metadata/metadata-keys.json    |   6 +
 .../tika/detect/TestContainerAwareDetector.java    |   6 +
 .../test-documents/testGeoGebra_classic.ggb        | Bin 0 -> 4761 bytes
 .../test-documents/testGeoGebra_notes.ggs          | Bin 0 -> 45929 bytes
 .../tika-parser-miscoffice-module/pom.xml          |   5 +
 .../tika/parser/geogebra/GeoGebraParser.java       | 433 +++++++++++++++++++++
 .../tika/parser/geogebra/GeoGebraXMLHandler.java   | 178 +++++++++
 .../tika/parser/geogebra/GeoGebraParserTest.java   | 409 +++++++++++++++++++
 .../test/resources/test-documents/testGeoGebra.ggb | Bin 0 -> 2010 bytes
 .../test-documents/testGeoGebraSlides.ggs          | Bin 0 -> 2466 bytes
 .../resources/test-documents/testGeoGebraTool.ggt  | Bin 0 -> 517 bytes
 .../test-documents/testGeoGebra_classic.ggb        | Bin 0 -> 4761 bytes
 .../test-documents/testGeoGebra_notes.ggs          | Bin 0 -> 45929 bytes
 .../java/org/apache/tika/parser/pkg/ZipParser.java |   2 +
 .../apache/tika/detect/zip/GeoGebraDetector.java   | 126 ++++++
 ...org.apache.tika.detect.zip.ZipContainerDetector |   4 +-
 .../tika/detect/zip/GeoGebraDetectionTest.java     |  69 ++++
 .../test/resources/test-documents/testGeoGebra.ggb | Bin 0 -> 2010 bytes
 .../test-documents/testGeoGebraSlides.ggs          | Bin 0 -> 2466 bytes
 .../resources/test-documents/testGeoGebraTool.ggt  | Bin 0 -> 517 bytes
 24 files changed, 1274 insertions(+), 1 deletion(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 1ca08ba230..3b336d2e81 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -227,6 +227,21 @@ Release 4.1.0 - unreleased
      recursion/allocation; malformed files fall back to the legacy string dump
      (TIKA-4814).
 
+   * New GeoGebraParser for *.ggb/*.ggs/*.ggt: geogebra:* metadata, text and
+     the thumbnail as a THUMBNAIL embedded document, with content-based
+     detection. Previously typed application/zip with every entry as an
+     attachment. *.ggs and *.ggp are new mime types; *.ggp is glob-only
+     (TIKA-4831).
+
+   * RawTiffParser extracts the camera-generated JPEG previews embedded in
+     TIFF-based raw images (Nikon NEF/NRW, Sony ARW/SRF/SR2, Pentax PEF/PTX,
+     Adobe DNG and Canon CR2, including BigTIFF DNG containers) as thumbnail
+     embedded documents. image/x-raw-{nikon,sony,pentax,adobe} are now
+     sub-classes of image/tiff, so a named NEF/ARW/PEF/DNG that used to detect
+     as image/tiff (TiffParser, metadata only) now detects as image/x-raw-* and
+     emits thumbnail-N.jpg attachments in /rmeta and /unpack; CR2 keeps its
+     detection but also gains the attachments. Disable via
+     "raw-tiff-parser": {"extractPreviews": false} (TIKA-4824).
    * RawTiffParser extracts embedded JPEG previews from NEF/NRW, ARW/SRF/SR2,
      PEF/PTX, DNG and CR2 as thumbnail embedded documents. image/x-raw-* are
      now subtypes of image/tiff, so named raw files detect as image/x-raw-*.
diff --git 
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml 
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index 608edcbd72..7750d5ae90 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -1591,10 +1591,24 @@
     <glob pattern="*.txd"/>
   </mime-type>
   <mime-type type="application/vnd.geogebra.file">
+    <_comment>GeoGebra Worksheet</_comment>
     <glob pattern="*.ggb"/>
+    <sub-class-of type="application/zip"/>
+  </mime-type>
+  <mime-type type="application/vnd.geogebra.pinboard">
+    <_comment>GeoGebra Pinboard</_comment>
+    <glob pattern="*.ggp"/>
+    <sub-class-of type="application/json"/>
+  </mime-type>
+  <mime-type type="application/vnd.geogebra.slides">
+    <_comment>GeoGebra Notes/Slides</_comment>
+    <glob pattern="*.ggs"/>
+    <sub-class-of type="application/zip"/>
   </mime-type>
   <mime-type type="application/vnd.geogebra.tool">
+    <_comment>GeoGebra Tool</_comment>
     <glob pattern="*.ggt"/>
+    <sub-class-of type="application/zip"/>
   </mime-type>
   <mime-type type="application/vnd.geometry-explorer">
     <glob pattern="*.gex"/>
diff --git a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java 
b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
index f52482c8d7..8dead957bf 100644
--- a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
+++ b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
@@ -239,6 +239,8 @@ public class TikaDetectionTest {
         assertEquals("application/vnd.fuzzysheet", tika.detect("x.fzs"));
         assertEquals("application/vnd.genomatix.tuxedo", tika.detect("x.txd"));
         assertEquals("application/vnd.geogebra.file", tika.detect("x.ggb"));
+        assertEquals("application/vnd.geogebra.pinboard", 
tika.detect("x.ggp"));
+        assertEquals("application/vnd.geogebra.slides", tika.detect("x.ggs"));
         assertEquals("application/vnd.geogebra.tool", tika.detect("x.ggt"));
         assertEquals("application/vnd.geometry-explorer", 
tika.detect("x.gex"));
         assertEquals("application/vnd.geometry-explorer", 
tika.detect("x.gre"));
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index 68cf8d2828..99646bb828 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -735,6 +735,12 @@
   
{"class":"org.apache.tika.parser.gdal.GDALParser","field":"SIZE","key":"gdal:size"},
   
{"class":"org.apache.tika.parser.gdal.GDALParser","field":"UPPER_LEFT","key":"gdal:upper-left"},
   
{"class":"org.apache.tika.parser.gdal.GDALParser","field":"UPPER_RIGHT","key":"gdal:upper-right"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"APP_NAME","key":"geogebra:app-name"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"APP_VERSION","key":"geogebra:app-version"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"DATE","key":"geogebra:date"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"FORMAT_VERSION","key":"geogebra:format-version"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"ID","key":"geogebra:id"},
+  
{"class":"org.apache.tika.parser.geogebra.GeoGebraParser","field":"TOOL_NAME","key":"geogebra:toolName"},
   
{"class":"org.apache.tika.parser.hdf.HDFParser","field":"FILE_TYPE_DESCRIPTION","key":"hdf:file-type-description"},
   
{"class":"org.apache.tika.parser.iwork.KeynoteContentHandler","field":"PRESENTATION_HEIGHT","key":"keynote:slides-height"},
   
{"class":"org.apache.tika.parser.iwork.KeynoteContentHandler","field":"PRESENTATION_WIDTH","key":"keynote:slides-width"},
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index c2a916af37..f75945ed23 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -192,6 +192,12 @@
   
{"key":"geo:lat","namespace":"geo","valueType":"REAL","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"geo:long","namespace":"geo","valueType":"REAL","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"geo:timestamp","namespace":"geo","valueType":"DATE","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"geogebra:app-name","namespace":"geogebra","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-miscoffice-module"},
+  
{"key":"geogebra:app-version","namespace":"geogebra","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-miscoffice-module"},
+  
{"key":"geogebra:date","namespace":"geogebra","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-miscoffice-module"},
+  
{"key":"geogebra:format-version","namespace":"geogebra","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-miscoffice-module"},
+  
{"key":"geogebra:id","namespace":"geogebra","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-miscoffice-module"},
+  
{"key":"geogebra:toolName","namespace":"geogebra","valueType":"TEXT","cardinality":"BAG","module":"tika-parser-miscoffice-module"},
   
{"key":"geotopic:latitude","namespace":"geotopic","valueType":"TEXT","cardinality":"SIMPLE","module":""},
   
{"key":"geotopic:longitude","namespace":"geotopic","valueType":"TEXT","cardinality":"SIMPLE","module":""},
   
{"key":"geotopic:name","namespace":"geotopic","valueType":"TEXT","cardinality":"SIMPLE","module":""},
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/TestContainerAwareDetector.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/TestContainerAwareDetector.java
index 013fceab4d..aa663e5bbf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/TestContainerAwareDetector.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/detect/TestContainerAwareDetector.java
@@ -454,6 +454,12 @@ public class TestContainerAwareDetector extends 
MultiThreadedTikaTest {
         assertTypeByData("testKMZ.kmz", "application/vnd.google-earth.kmz");
     }
 
+    @Test
+    public void testDetectGeoGebra() throws Exception {
+        assertTypeByData("testGeoGebra_classic.ggb", 
"application/vnd.geogebra.file");
+        assertTypeByData("testGeoGebra_notes.ggs", 
"application/vnd.geogebra.slides");
+    }
+
     @Test
     public void testDetectIPA() throws Exception {
         assertTypeByNameAndData("testIPA.ipa", "application/x-itunes-ipa");
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_classic.ggb
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_classic.ggb
new file mode 100644
index 0000000000..f50e3e92e5
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_classic.ggb
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_notes.ggs
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_notes.ggs
new file mode 100644
index 0000000000..f83ae6e42a
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testGeoGebra_notes.ggs
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/pom.xml
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/pom.xml
index deb3f8fcbd..d69b2a536e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/pom.xml
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/pom.xml
@@ -89,6 +89,11 @@
       <artifactId>tika-parser-xmp-commons</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <!-- for the GeoGebra structure.json and inline text content -->
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+    </dependency>
   </dependencies>
 
   <build>
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
new file mode 100644
index 0000000000..087ef04cd8
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/geogebra/GeoGebraParser.java
@@ -0,0 +1,433 @@
+/*
+ * 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 java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.exception.WriteLimitReachedException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.BoundedInputStream;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.PageAnchoring;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.EmbeddedContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+import org.apache.tika.utils.XMLReaderUtils;
+import org.apache.tika.zip.utils.ZipFileHelper;
+
+/**
+ * Parser for the zip-based GeoGebra formats: worksheets (*.ggb), Notes/Slides
+ * (*.ggs) and tools (*.ggt).
+ * <p>
+ * 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, inline
+ * text, captions, tool names and help) is emitted as XHTML paragraphs. For
+ * Notes/Slides, each {@code _slideN/geogebra.xml} becomes a
+ * {@code <div class="slide">}, in the order given by {@code structure.json}.
+ * <p>
+ * The representative rendering of the document, {@code geogebra_thumbnail.png}
+ * at the root of a worksheet or tool, or the first available slide 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. The document script
+ * {@code geogebra_javascript.js} is emitted as a
+ * {@link TikaCoreProperties.EmbeddedResourceType#MACRO}, and any other
+ * embedded file (e.g. inserted pictures) as an embedded document.
+ * <p>
+ * A part that cannot be read (an unsupported zip entry, malformed XML) is
+ * recorded in the metadata and skipped; the remaining parts are still parsed.
+ */
+@TikaComponent(name = "geogebra-parser")
+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 + "app-name");
+
+    /**
+     * The GeoGebra application version the file was written with.
+     */
+    public static final Property APP_VERSION =
+            Property.internalText(GEOGEBRA_PREFIX + "app-version");
+
+    /**
+     * The GeoGebra XML format version.
+     */
+    public static final Property FORMAT_VERSION =
+            Property.internalText(GEOGEBRA_PREFIX + "format-version");
+
+    /**
+     * 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). The name is the {@code toolName} attribute of the
+     * macro element.
+     */
+    public static final Property TOOL_NAME =
+            Property.internalTextBag(GEOGEBRA_PREFIX + "toolName");
+
+    private static final Set<MediaType> 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";
+    private static final String JAVASCRIPT_JS = "geogebra_javascript.js";
+
+    /**
+     * Housekeeping entries at the root or in a slide directory that carry no
+     * user content of their own. The XML files are parsed for text and the
+     * thumbnails handled separately.
+     */
+    private static final Set<String> HOUSEKEEPING_NAMES = 
Collections.unmodifiableSet(
+            new HashSet<>(Arrays.asList(GEOGEBRA_XML, MACRO_XML, THUMBNAIL_PNG,
+                    "geogebra_defaults2d.xml", "geogebra_defaults3d.xml")));
+
+    private static final String SLIDE_DIR_PREFIX = "_slide";
+
+    private static final Pattern SLIDE_XML_PATTERN =
+            Pattern.compile("^(" + SLIDE_DIR_PREFIX + "\\d+)/" + 
Pattern.quote(GEOGEBRA_XML) + "$");
+
+    /**
+     * structure.json only lists chapters, pages and element ids; a real one is
+     * a few kilobytes.
+     */
+    private static final long MAX_STRUCTURE_JSON_LENGTH = 1024 * 1024;
+
+    static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        EmbeddedDocumentExtractor embeddedDocumentExtractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+
+        ZipFile zipFile;
+        Object container = tis.getOpenContainer();
+        if (container instanceof ZipFile) {
+            zipFile = (ZipFile) container;
+        } else {
+            zipFile = ZipFileHelper.open(tis, null);
+            tis.setOpenContainer(zipFile);
+        }
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
+        xhtml.startDocument();
+        List<String> slideIds = getSlideIds(zipFile);
+        ZipArchiveEntry rootXml = zipFile.getEntry(GEOGEBRA_XML);
+        ZipArchiveEntry macroXml = zipFile.getEntry(MACRO_XML);
+        //document metadata comes from the first XML parsed: a worksheet's
+        //geogebra.xml, a tool's geogebra_macro.xml, or the first slide
+        boolean documentMetadataPending = true;
+        if (rootXml != null) {
+            documentMetadataPending = false;
+            parseGeoGebraXml(zipFile, rootXml, xhtml, metadata, true, context);
+        }
+        if (macroXml != null) {
+            //a worksheet with macros carries both XMLs; the macro one only
+            //contributes the tool names then, not the document metadata
+            parseGeoGebraXml(zipFile, macroXml, xhtml, metadata, 
documentMetadataPending, context);
+            documentMetadataPending = false;
+        }
+        Map<String, Integer> pageNumbers = new HashMap<>();
+        if (!slideIds.isEmpty()) {
+            metadata.set(PagedText.N_PAGES, slideIds.size());
+            int page = 1;
+            for (String slideId : slideIds) {
+                pageNumbers.put(slideId, page++);
+                xhtml.startElement("div", "class", "slide");
+                try {
+                    ZipArchiveEntry slideXml = zipFile.getEntry(slideId + "/" 
+ GEOGEBRA_XML);
+                    parseGeoGebraXml(zipFile, slideXml, xhtml, metadata, 
documentMetadataPending,
+                            context);
+                    documentMetadataPending = false;
+                } finally {
+                    xhtml.endElement("div");
+                }
+            }
+        }
+        handleThumbnail(zipFile, slideIds, xhtml, metadata, context, 
embeddedDocumentExtractor);
+        handleOtherEntries(zipFile, pageNumbers, xhtml, metadata, context,
+                embeddedDocumentExtractor);
+        xhtml.endDocument();
+    }
+
+    /**
+     * Returns the ordered slide directory names of a Notes/Slides file, or an
+     * empty list if there are no slides. The slides are the
+     * {@code _slideN/geogebra.xml} entries; {@code structure.json} only
+     * supplies their order, slides it does not list (or all of them, if it is
+     * missing or unreadable) follow in numeric order.
+     */
+    private List<String> getSlideIds(ZipFile zipFile) {
+        List<String> numericallySorted = new ArrayList<>();
+        Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
+        while (entries.hasMoreElements()) {
+            Matcher m = 
SLIDE_XML_PATTERN.matcher(entries.nextElement().getName());
+            if (m.matches()) {
+                numericallySorted.add(m.group(1));
+            }
+        }
+        if (numericallySorted.isEmpty()) {
+            return Collections.emptyList();
+        }
+        numericallySorted.sort(GeoGebraParser::compareSlideIds);
+
+        Set<String> ordered = new LinkedHashSet<>();
+        ZipArchiveEntry structure = zipFile.getEntry(STRUCTURE_JSON);
+        if (structure != null && zipFile.canReadEntryData(structure)) {
+            Set<String> knownSlideIds = new HashSet<>(numericallySorted);
+            try (InputStream is = new 
BoundedInputStream(MAX_STRUCTURE_JSON_LENGTH,
+                    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 (knownSlideIds.contains(id)) {
+                                ordered.add(id);
+                            }
+                        }
+                    }
+                }
+            } catch (IOException e) {
+                //fall through to the numeric order
+            }
+        }
+        ordered.addAll(numericallySorted);
+        return new ArrayList<>(ordered);
+    }
+
+    /**
+     * Compares the digit suffixes of two slide ids numerically without
+     * parsing them (a crafted id may carry more digits than a long holds):
+     * leading zeros aside, a shorter digit string is the smaller number and
+     * equal lengths compare lexicographically.
+     */
+    private static int compareSlideIds(String a, String b) {
+        String da = stripLeadingZeros(a.substring(SLIDE_DIR_PREFIX.length()));
+        String db = stripLeadingZeros(b.substring(SLIDE_DIR_PREFIX.length()));
+        if (da.length() != db.length()) {
+            return Integer.compare(da.length(), db.length());
+        }
+        int byValue = da.compareTo(db);
+        return byValue != 0 ? byValue : a.compareTo(b);
+    }
+
+    private static String stripLeadingZeros(String digits) {
+        int i = 0;
+        while (i < digits.length() - 1 && digits.charAt(i) == '0') {
+            i++;
+        }
+        return digits.substring(i);
+    }
+
+    /**
+     * Parses one GeoGebra XML for its text and, if {@code documentMetadata}
+     * is set, the document metadata. A part that cannot be read or is not
+     * well-formed is recorded in the metadata and skipped.
+     */
+    private void parseGeoGebraXml(ZipFile zipFile, ZipArchiveEntry entry,
+                                  XHTMLContentHandler xhtml, Metadata metadata,
+                                  boolean documentMetadata, ParseContext 
context)
+            throws SAXException {
+        if (entry == null) {
+            return;
+        }
+        if (!zipFile.canReadEntryData(entry)) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(
+                    new IOException("Unsupported zip entry: " + 
entry.getName()), metadata);
+            return;
+        }
+        try (InputStream is = zipFile.getInputStream(entry)) {
+            XMLReaderUtils.parseSAX(is, new EmbeddedContentHandler(
+                    new GeoGebraXMLHandler(xhtml, metadata, 
documentMetadata)), context);
+        } catch (SAXException e) {
+            if (WriteLimitReachedException.isWriteLimitReached(e)) {
+                throw e;
+            }
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata);
+        } catch (IOException | TikaException e) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata);
+        }
+    }
+
+    /**
+     * Emits the representative thumbnail: the root one of a worksheet or
+     * tool, or the first slide thumbnail (in slide order) of a Notes/Slides
+     * file.
+     */
+    private void handleThumbnail(ZipFile zipFile, List<String> slideIds, 
XHTMLContentHandler xhtml,
+                                 Metadata metadata, ParseContext context,
+                                 EmbeddedDocumentExtractor 
embeddedDocumentExtractor)
+            throws IOException, SAXException {
+        ZipArchiveEntry entry = zipFile.getEntry(THUMBNAIL_PNG);
+        for (int i = 0; entry == null && i < slideIds.size(); i++) {
+            entry = zipFile.getEntry(slideIds.get(i) + "/" + THUMBNAIL_PNG);
+        }
+        if (entry != null) {
+            handleEmbedded(zipFile, entry, 
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL,
+                    null, xhtml, metadata, context, embeddedDocumentExtractor);
+        }
+    }
+
+    /**
+     * Emits everything that is not GeoGebra housekeeping: the document script
+     * as a macro, and inserted pictures and other files as embedded documents.
+     * Housekeeping is matched at the root and in the slide directories only,
+     * so a file of the same name elsewhere is still emitted.
+     */
+    private void handleOtherEntries(ZipFile zipFile, Map<String, Integer> 
pageNumbers,
+                                    XHTMLContentHandler xhtml, Metadata 
metadata,
+                                    ParseContext context,
+                                    EmbeddedDocumentExtractor 
embeddedDocumentExtractor)
+            throws IOException, SAXException {
+        Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
+        while (entries.hasMoreElements()) {
+            ZipArchiveEntry entry = entries.nextElement();
+            if (entry.isDirectory()) {
+                continue;
+            }
+            String name = entry.getName();
+            String dir = "";
+            String basename = name;
+            int slash = name.indexOf('/');
+            if (slash >= 0) {
+                dir = name.substring(0, slash);
+                basename = name.substring(slash + 1);
+            }
+            boolean knownDir = dir.isEmpty() || pageNumbers.containsKey(dir);
+            if (knownDir && (HOUSEKEEPING_NAMES.contains(basename)
+                    || (dir.isEmpty() && STRUCTURE_JSON.equals(basename)))) {
+                continue;
+            }
+            TikaCoreProperties.EmbeddedResourceType type = null;
+            if (knownDir && JAVASCRIPT_JS.equals(basename)) {
+                type = TikaCoreProperties.EmbeddedResourceType.MACRO;
+            }
+            handleEmbedded(zipFile, entry, type, pageNumbers.get(dir), xhtml, 
metadata, context,
+                    embeddedDocumentExtractor);
+        }
+    }
+
+    /**
+     * Emits one zip entry as an embedded document. Without a given resource
+     * type, pictures are marked {@link 
TikaCoreProperties.EmbeddedResourceType#INLINE}
+     * and other files {@link 
TikaCoreProperties.EmbeddedResourceType#ATTACHMENT}.
+     * An entry in a slide directory is tagged with the slide's page number.
+     */
+    private void handleEmbedded(ZipFile zipFile, ZipArchiveEntry entry,
+                                TikaCoreProperties.EmbeddedResourceType type, 
Integer page,
+                                XHTMLContentHandler xhtml, Metadata 
parentMetadata,
+                                ParseContext context,
+                                EmbeddedDocumentExtractor 
embeddedDocumentExtractor)
+            throws IOException, SAXException {
+        if (!zipFile.canReadEntryData(entry)) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(
+                    new IOException("Unsupported zip entry: " + 
entry.getName()), parentMetadata);
+            return;
+        }
+        Metadata embeddedMetadata = Metadata.newInstance(context);
+        embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, 
entry.getName());
+        embeddedMetadata.set(TikaCoreProperties.INTERNAL_PATH, 
entry.getName());
+        if (page != null) {
+            PageAnchoring.applyPageMetadata(embeddedMetadata, 
Collections.singleton(page));
+        }
+        try (TikaInputStream tisZip = 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+            if (type == null) {
+                //spool so the stream can be rewound after detection
+                tisZip.getFile();
+                MediaType mediaType = EmbeddedDocumentUtil.getDetector(context)
+                        .detect(tisZip, embeddedMetadata, context);
+                tisZip.reset();
+                if (mediaType != null) {
+                    embeddedMetadata.set(HttpHeaders.CONTENT_TYPE, 
mediaType.toString());
+                }
+                type = mediaType != null && "image".equals(mediaType.getType())
+                        ? TikaCoreProperties.EmbeddedResourceType.INLINE
+                        : TikaCoreProperties.EmbeddedResourceType.ATTACHMENT;
+            }
+            embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, 
type.toString());
+            if 
(embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, context)) {
+                embeddedDocumentExtractor.parseEmbedded(tisZip, new 
EmbeddedContentHandler(xhtml),
+                        embeddedMetadata, context, false);
+            }
+        } catch (IOException e) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
parentMetadata);
+        }
+    }
+}
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 0000000000..b3d579fa05
--- /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,178 @@
+/*
+ * 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 java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+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;
+import org.apache.tika.utils.StringUtils;
+
+/**
+ * SAX handler for {@code geogebra.xml} and {@code geogebra_macro.xml}.
+ * <p>
+ * Extracts the document metadata from the {@code <geogebra>} root and its
+ * {@code <construction>} child (when asked to), and emits the user-visible
+ * text as XHTML paragraphs: the string literals of text object
+ * {@code <expression>}s, the text runs of {@code <content>} elements (inline
+ * text, tables, mind maps), element {@code <caption>}s and macro names and
+ * help texts.
+ */
+class GeoGebraXMLHandler extends DefaultHandler {
+
+    /**
+     * A GeoGebra string literal. GeoGebra writes strings between plain
+     * double quotes without any escaping, so a literal never contains one.
+     */
+    private static final Pattern STRING_LITERAL = 
Pattern.compile("\"([^\"]*)\"");
+
+    /**
+     * Longest content JSON that is parsed; a real inline text, table or mind
+     * map is a few kilobytes, anything far beyond that is not worth a tree.
+     */
+    private static final int MAX_CONTENT_LENGTH = 1024 * 1024;
+
+    private final XHTMLContentHandler xhtml;
+    private final Metadata metadata;
+    private final boolean documentMetadata;
+    private int depth = 0;
+
+    /**
+     * @param xhtml            the handler paragraphs are written to
+     * @param metadata         the metadata tool names are added to
+     * @param documentMetadata whether to also fill the document metadata from
+     *                         the root and construction elements
+     */
+    GeoGebraXMLHandler(XHTMLContentHandler xhtml, Metadata metadata, boolean 
documentMetadata) {
+        this.xhtml = xhtml;
+        this.metadata = metadata;
+        this.documentMetadata = documentMetadata;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName, 
Attributes attributes)
+            throws SAXException {
+        if (depth == 0 && "geogebra".equals(localName)) {
+            if (documentMetadata) {
+                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 (depth == 1 && "construction".equals(localName)) {
+            //only the document's own construction; a macro's construction is
+            //nested one level deeper inside its <macro> element
+            if (documentMetadata) {
+                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 (StringUtils.isBlank(toolName)) {
+                toolName = attributes.getValue("cmdName");
+            }
+            if (!StringUtils.isBlank(toolName)) {
+                metadata.add(GeoGebraParser.TOOL_NAME, toolName.trim());
+            }
+            paragraph(toolName);
+            paragraph(attributes.getValue("toolHelp"));
+        }
+        depth++;
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName) {
+        depth--;
+    }
+
+    /**
+     * Emits the string literals of an expression. A text object's expression
+     * is either a single literal like {@code "some text"} or, for a dynamic
+     * text, literals combined with values like {@code "Area = " + a}; the
+     * literals are the user's text, everything else is geometry and skipped.
+     */
+    private void handleExpression(String exp) throws SAXException {
+        if (exp == null || exp.indexOf('"') < 0) {
+            return;
+        }
+        StringBuilder sb = new StringBuilder();
+        Matcher m = STRING_LITERAL.matcher(exp);
+        while (m.find()) {
+            sb.append(m.group(1));
+        }
+        paragraph(sb.toString());
+    }
+
+    /**
+     * 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) {
+            return;
+        }
+        String trimmed = val.trim();
+        if (trimmed.isEmpty() || trimmed.length() > MAX_CONTENT_LENGTH
+                || (trimmed.charAt(0) != '[' && trimmed.charAt(0) != '{')) {
+            //not a JSON document; a plain string carries no text runs
+            return;
+        }
+        JsonNode root;
+        try {
+            root = GeoGebraParser.OBJECT_MAPPER.readTree(trimmed);
+        } catch (IOException e) {
+            return;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (String text : root.findValuesAsText("text")) {
+            sb.append(text);
+        }
+        for (String line : sb.toString().split("\r\n|[\r\n]")) {
+            paragraph(line);
+        }
+    }
+
+    private void paragraph(String text) throws SAXException {
+        if (!StringUtils.isBlank(text)) {
+            xhtml.element("p", text.trim());
+        }
+    }
+
+    private void setIfNotBlank(Property property, String value) {
+        if (!StringUtils.isBlank(value)) {
+            metadata.set(property, value.trim());
+        }
+    }
+}
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 0000000000..76856a7e32
--- /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,409 @@
+/*
+ * 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.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));
+    }
+
+    @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=\"&quot;Body&quot;\"/></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=\"&quot;plain text&quot;\"/>"
+                //a dynamic text: literals combined with a value
+                + "<expression label=\"t2\" exp=\"&quot;Area = &quot; + a\"/>"
+                + "<expression label=\"t3\" 
exp=\"&quot;a&quot;+&quot;b&quot;\"/>"
+                //geometry, not text
+                + "<expression label=\"f\" exp=\"x^2 + 1\"/>"
+                + "<expression label=\"empty\" exp=\"&quot;&quot;\"/>"
+                + "</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=\"&quot;Broken&quot;\"/><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=\"&quot;Root text&quot;\"/></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=\"[{&quot;text&quot;:&quot;"
+                + text + "\\r\\n&quot;}]\"/></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;
+    }
+}
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 0000000000..087532316a
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 0000000000..7b3ee22789
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 0000000000..6ed4e8fef6
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-miscoffice-module/src/test/resources/test-documents/testGeoGebra_classic.ggb
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra_classic.ggb
new file mode 100644
index 0000000000..f50e3e92e5
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_classic.ggb
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra_notes.ggs
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testGeoGebra_notes.ggs
new file mode 100644
index 0000000000..f83ae6e42a
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_notes.ggs
 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 c99264fdc0..70f6a72779 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
@@ -127,6 +127,8 @@ public class ZipParser extends AbstractArchiveParser {
                 "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 0000000000..cba9559cf4
--- /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,126 @@
+/*
+ * 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 java.util.regex.Pattern;
+
+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:
+ * <ul>
+ *   <li>{@code geogebra.xml} at the root &rarr; a worksheet (*.ggb)</li>
+ *   <li>{@code structure.json} at the root plus at least one
+ *       {@code _slide&lt;N&gt;/geogebra.xml} &rarr; GeoGebra Notes/Slides 
(*.ggs)</li>
+ *   <li>{@code geogebra_macro.xml} at the root &rarr; a tool (*.ggt)</li>
+ * </ul>
+ * 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");
+
+    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 Pattern SLIDE_XML_PATTERN =
+            Pattern.compile("^_slide\\d+/geogebra\\.xml$");
+
+    @Override
+    public MediaType detect(ZipFile zip, TikaInputStream tis) throws 
IOException {
+        //this runs for every zip Tika sees: look the root names up and only
+        //walk the entries for a slide when there is a structure.json
+        Names names = new Names();
+        names.geogebraXml = zip.getEntry(GEOGEBRA_XML) != null;
+        names.macroXml = zip.getEntry(MACRO_XML) != null;
+        names.structureJson = zip.getEntry(STRUCTURE_JSON) != null;
+        if (names.structureJson) {
+            Enumeration<ZipArchiveEntry> entries = zip.getEntries();
+            while (!names.slideXml && entries.hasMoreElements()) {
+                names.slideXml = isSlideXml(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 boolean isSlideXml(String name) {
+        return SLIDE_XML_PATTERN.matcher(name).matches();
+    }
+
+    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 (MACRO_XML.equals(name)) {
+                macroXml = true;
+            } else if (STRUCTURE_JSON.equals(name)) {
+                structureJson = true;
+            } else if (isSlideXml(name)) {
+                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 eb2bfe2774..3d59fd80c0 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
@@ -19,4 +19,6 @@ org.apache.tika.detect.zip.JarDetector
 org.apache.tika.detect.zip.KMZDetector
 org.apache.tika.detect.zip.OpenDocumentDetector
 org.apache.tika.detect.zip.StarOfficeDetector
-org.apache.tika.detect.zip.FrictionlessPackageDetector
\ No newline at end of file
+org.apache.tika.detect.zip.FrictionlessPackageDetector
+
+org.apache.tika.detect.zip.GeoGebraDetector
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 0000000000..13dd026680
--- /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,69 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.InputStream;
+import java.util.List;
+
+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.parser.ParseContext;
+
+/**
+ * Test case for detecting the zip-based GeoGebra formats by their contents.
+ * The streams are parsed without a resource name, so detection must rely on
+ * the zip entry names, not the *.ggb/*.ggs/*.ggt globs.
+ */
+public class GeoGebraDetectionTest extends TikaTest {
+
+    private List<Metadata> getRecursiveMetadataWithoutName(String fileName) 
throws Exception {
+        InputStream is = getClass().getResourceAsStream("/test-documents/" + 
fileName);
+        assertNotNull(is, "missing test resource " + fileName);
+        try (TikaInputStream tis = TikaInputStream.get(is, new Metadata())) {
+            return getRecursiveMetadata(tis, AUTO_DETECT_PARSER, new 
Metadata(),
+                    new ParseContext(), true);
+        }
+    }
+
+    @Test
+    public void testGGBDetection() throws Exception {
+        List<Metadata> metadataList = 
getRecursiveMetadataWithoutName("testGeoGebra.ggb");
+        assertEquals("application/vnd.geogebra.file",
+                metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testGGSDetection() throws Exception {
+        List<Metadata> metadataList = 
getRecursiveMetadataWithoutName("testGeoGebraSlides.ggs");
+        assertEquals("application/vnd.geogebra.slides",
+                metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testGGTDetection() throws Exception {
+        List<Metadata> metadataList = 
getRecursiveMetadataWithoutName("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 0000000000..087532316a
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 0000000000..7b3ee22789
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 0000000000..6ed4e8fef6
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

Reply via email to