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 34b9d279434..f1978f8e898 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 @@ -1578,10 +1578,24 @@ + <_comment>GeoGebra Worksheet + + + + <_comment>GeoGebra Pinboard + + + + + <_comment>GeoGebra Notes/Slides + + + <_comment>GeoGebra Tool + 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 f52482c8d78..8dead957bf5 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 void testHttpServerFileExtensions() { 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-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 2bd54be8b65..6bd663dbbec 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 @@ tika-parser-xmp-commons ${project.version} + + + com.fasterxml.jackson.core + jackson-databind + 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 00000000000..2a1839ea830 --- /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,329 @@ +/* + * 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.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +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.config.TikaComponent; +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentUtil; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +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; + +/** + * Parser for the zip-based GeoGebra formats: worksheets (*.ggb), Notes/Slides + * (*.ggs) and tools (*.ggt). + *

+ * 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