Skip to content

Commit 958e4ff

Browse files
committed
Add a GeoGebra parser for worksheets, Notes/Slides and tools
Parse geogebra.xml/geogebra_macro.xml with the pooled, hardened SAX path: construction title/author/date and the app name/version/format/id become metadata, and the user-visible text (string-literal expressions, rich-text content runs, captions, macro names and help texts) is emitted as XHTML paragraphs. Notes/Slides files emit one div per slide in structure.json order and set xmpTPg:NPages. The representative rendering - geogebra_thumbnail.png at the root, or the first slide's thumbnail - is emitted as an embedded document marked embeddedResourceType=THUMBNAIL so unpack sidecars identify the preview image; other slides' thumbnails are redundant renderings and are skipped. Any other embedded file (e.g. inserted pictures) is emitted as an embedded document.
1 parent 1226f26 commit 958e4ff

7 files changed

Lines changed: 590 additions & 0 deletions

File tree

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@
8989
<artifactId>tika-parser-xmp-commons</artifactId>
9090
<version>${project.version}</version>
9191
</dependency>
92+
<!-- for the GeoGebra structure.json and inline text content -->
93+
<dependency>
94+
<groupId>com.fasterxml.jackson.core</groupId>
95+
<artifactId>jackson-databind</artifactId>
96+
</dependency>
9297
</dependencies>
9398

9499
<build>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.geogebra;
18+
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.util.ArrayList;
22+
import java.util.Arrays;
23+
import java.util.Collections;
24+
import java.util.Enumeration;
25+
import java.util.HashSet;
26+
import java.util.LinkedHashSet;
27+
import java.util.List;
28+
import java.util.Set;
29+
import java.util.regex.Matcher;
30+
import java.util.regex.Pattern;
31+
32+
import com.fasterxml.jackson.databind.JsonNode;
33+
import com.fasterxml.jackson.databind.ObjectMapper;
34+
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
35+
import org.apache.commons.compress.archivers.zip.ZipFile;
36+
import org.xml.sax.ContentHandler;
37+
import org.xml.sax.SAXException;
38+
39+
import org.apache.tika.config.TikaComponent;
40+
import org.apache.tika.exception.TikaException;
41+
import org.apache.tika.extractor.EmbeddedDocumentUtil;
42+
import org.apache.tika.io.TikaInputStream;
43+
import org.apache.tika.metadata.Metadata;
44+
import org.apache.tika.metadata.PagedText;
45+
import org.apache.tika.metadata.Property;
46+
import org.apache.tika.metadata.TikaCoreProperties;
47+
import org.apache.tika.mime.MediaType;
48+
import org.apache.tika.parser.ParseContext;
49+
import org.apache.tika.parser.Parser;
50+
import org.apache.tika.sax.EmbeddedContentHandler;
51+
import org.apache.tika.sax.XHTMLContentHandler;
52+
import org.apache.tika.utils.XMLReaderUtils;
53+
54+
/**
55+
* Parser for the zip-based GeoGebra formats: worksheets (*.ggb), Notes/Slides
56+
* (*.ggs) and tools (*.ggt).
57+
* <p>
58+
* The construction metadata (title, author, date) and the application
59+
* name/version are read from {@code geogebra.xml} (or, for a tool, from
60+
* {@code geogebra_macro.xml}), and the user-visible text (text objects, ink
61+
* notes, captions) is emitted as XHTML paragraphs. For Notes/Slides, the slide
62+
* order is taken from {@code structure.json} and each slide becomes a
63+
* {@code <div class="slide">}.
64+
* <p>
65+
* The representative rendering of the document — {@code geogebra_thumbnail.png}
66+
* at the root of a worksheet or tool, or the first slide's thumbnail of a
67+
* Notes/Slides file — is emitted as an embedded document marked with
68+
* {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, so that clients
69+
* (e.g. the unpacker's sidecar metadata) can pick it as the preview image.
70+
* Thumbnails of the remaining slides are renderings of content that is already
71+
* extracted, so they are skipped. Any other embedded file (e.g. inserted
72+
* pictures) is emitted as an embedded document.
73+
*/
74+
@TikaComponent
75+
public class GeoGebraParser implements Parser {
76+
77+
/**
78+
* Serial version UID
79+
*/
80+
private static final long serialVersionUID = 2114923339149498692L;
81+
82+
public static final String GEOGEBRA_PREFIX = "geogebra:";
83+
84+
/**
85+
* The GeoGebra application flavor the file was written with,
86+
* e.g. "classic", "notes", "graphing".
87+
*/
88+
public static final Property APP_NAME =
89+
Property.internalText(GEOGEBRA_PREFIX + "appName");
90+
91+
/**
92+
* The GeoGebra application version the file was written with.
93+
*/
94+
public static final Property APP_VERSION =
95+
Property.internalText(GEOGEBRA_PREFIX + "appVersion");
96+
97+
/**
98+
* The GeoGebra XML format version.
99+
*/
100+
public static final Property FORMAT_VERSION =
101+
Property.internalText(GEOGEBRA_PREFIX + "formatVersion");
102+
103+
/**
104+
* The unique id GeoGebra assigns to the document.
105+
*/
106+
public static final Property ID = Property.internalText(GEOGEBRA_PREFIX + "id");
107+
108+
/**
109+
* The free-form date string of the construction. This is user-entered
110+
* text, not necessarily a parseable date.
111+
*/
112+
public static final Property DATE = Property.internalText(GEOGEBRA_PREFIX + "date");
113+
114+
/**
115+
* The tool names of the macros in a tool file (or in a worksheet with
116+
* embedded macros).
117+
*/
118+
public static final Property TOOL_NAME =
119+
Property.internalTextBag(GEOGEBRA_PREFIX + "toolName");
120+
121+
private static final Set<MediaType> SUPPORTED_TYPES = Collections.unmodifiableSet(
122+
new HashSet<>(Arrays.asList(MediaType.application("vnd.geogebra.file"),
123+
MediaType.application("vnd.geogebra.slides"),
124+
MediaType.application("vnd.geogebra.tool"))));
125+
126+
private static final String GEOGEBRA_XML = "geogebra.xml";
127+
private static final String MACRO_XML = "geogebra_macro.xml";
128+
private static final String STRUCTURE_JSON = "structure.json";
129+
private static final String THUMBNAIL_PNG = "geogebra_thumbnail.png";
130+
131+
/**
132+
* Housekeeping entries every ggb-like container may carry; everything
133+
* else is user content and worth emitting as an embedded document.
134+
*/
135+
private static final Set<String> KNOWN_ENTRY_NAMES = Collections.unmodifiableSet(
136+
new HashSet<>(Arrays.asList(GEOGEBRA_XML, MACRO_XML, THUMBNAIL_PNG,
137+
"geogebra_defaults2d.xml", "geogebra_defaults3d.xml",
138+
"geogebra_javascript.js")));
139+
140+
private static final Pattern SLIDE_XML_PATTERN =
141+
Pattern.compile("^(_slide\\d+)/" + Pattern.quote(GEOGEBRA_XML) + "$");
142+
143+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
144+
145+
@Override
146+
public Set<MediaType> getSupportedTypes(ParseContext context) {
147+
return SUPPORTED_TYPES;
148+
}
149+
150+
@Override
151+
public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
152+
ParseContext context) throws IOException, SAXException, TikaException {
153+
EmbeddedDocumentUtil embeddedDocumentUtil = new EmbeddedDocumentUtil(context);
154+
155+
ZipFile zipFile;
156+
Object container = tis.getOpenContainer();
157+
if (container instanceof ZipFile) {
158+
zipFile = (ZipFile) container;
159+
} else {
160+
zipFile = ZipFile.builder().setFile(tis.getFile()).get();
161+
tis.setOpenContainer(zipFile);
162+
}
163+
164+
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
165+
xhtml.startDocument();
166+
List<String> slideIds = getSlideIds(zipFile);
167+
if (!slideIds.isEmpty()) {
168+
parseSlides(zipFile, slideIds, xhtml, metadata, context, embeddedDocumentUtil);
169+
} else {
170+
parseWorksheet(zipFile, xhtml, metadata, context, embeddedDocumentUtil);
171+
}
172+
xhtml.endDocument();
173+
}
174+
175+
/**
176+
* Returns the ordered slide directory names of a Notes/Slides file, or an
177+
* empty list if this is not a Notes/Slides file. The order comes from
178+
* {@code structure.json}; slides present in the zip but missing from
179+
* {@code structure.json} are appended in numeric order.
180+
*/
181+
private List<String> getSlideIds(ZipFile zipFile) {
182+
Set<String> inZip = new LinkedHashSet<>();
183+
List<String> numericallySorted = new ArrayList<>();
184+
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
185+
while (entries.hasMoreElements()) {
186+
Matcher m = SLIDE_XML_PATTERN.matcher(entries.nextElement().getName());
187+
if (m.matches()) {
188+
numericallySorted.add(m.group(1));
189+
}
190+
}
191+
numericallySorted.sort((a, b) -> Integer.compare(
192+
Integer.parseInt(a.substring("_slide".length())),
193+
Integer.parseInt(b.substring("_slide".length()))));
194+
195+
ZipArchiveEntry structure = zipFile.getEntry(STRUCTURE_JSON);
196+
if (structure == null || numericallySorted.isEmpty()) {
197+
return Collections.emptyList();
198+
}
199+
try (InputStream is = zipFile.getInputStream(structure)) {
200+
JsonNode root = OBJECT_MAPPER.readTree(is);
201+
for (JsonNode chapter : root.path("chapters")) {
202+
for (JsonNode page : chapter.path("pages")) {
203+
for (JsonNode element : page.path("elements")) {
204+
String id = element.path("id").asText("");
205+
if (numericallySorted.contains(id)) {
206+
inZip.add(id);
207+
}
208+
}
209+
}
210+
}
211+
} catch (IOException e) {
212+
//fall through to the numeric order
213+
}
214+
for (String id : numericallySorted) {
215+
inZip.add(id);
216+
}
217+
return new ArrayList<>(inZip);
218+
}
219+
220+
private void parseWorksheet(ZipFile zipFile, XHTMLContentHandler xhtml, Metadata metadata,
221+
ParseContext context, EmbeddedDocumentUtil embeddedDocumentUtil)
222+
throws IOException, SAXException, TikaException {
223+
ZipArchiveEntry contentXml = zipFile.getEntry(GEOGEBRA_XML);
224+
if (contentXml == null) {
225+
contentXml = zipFile.getEntry(MACRO_XML);
226+
}
227+
if (contentXml != null) {
228+
parseGeoGebraXml(zipFile, contentXml, xhtml, metadata, context);
229+
}
230+
handleThumbnail(zipFile, zipFile.getEntry(THUMBNAIL_PNG), xhtml, context,
231+
embeddedDocumentUtil);
232+
handleOtherEntries(zipFile, xhtml, context, embeddedDocumentUtil);
233+
}
234+
235+
private void parseSlides(ZipFile zipFile, List<String> slideIds, XHTMLContentHandler xhtml,
236+
Metadata metadata, ParseContext context,
237+
EmbeddedDocumentUtil embeddedDocumentUtil)
238+
throws IOException, SAXException, TikaException {
239+
metadata.set(PagedText.N_PAGES, slideIds.size());
240+
boolean first = true;
241+
for (String slideId : slideIds) {
242+
xhtml.startElement("div", "class", "slide");
243+
ZipArchiveEntry contentXml = zipFile.getEntry(slideId + "/" + GEOGEBRA_XML);
244+
if (contentXml != null) {
245+
//document-level metadata comes from the first slide
246+
parseGeoGebraXml(zipFile, contentXml, xhtml, first ? metadata : null, context);
247+
}
248+
xhtml.endElement("div");
249+
if (first) {
250+
handleThumbnail(zipFile, zipFile.getEntry(slideId + "/" + THUMBNAIL_PNG), xhtml,
251+
context, embeddedDocumentUtil);
252+
}
253+
first = false;
254+
}
255+
handleOtherEntries(zipFile, xhtml, context, embeddedDocumentUtil);
256+
}
257+
258+
private void parseGeoGebraXml(ZipFile zipFile, ZipArchiveEntry entry,
259+
XHTMLContentHandler xhtml, Metadata metadata,
260+
ParseContext context)
261+
throws IOException, SAXException, TikaException {
262+
try (InputStream is = zipFile.getInputStream(entry)) {
263+
XMLReaderUtils.parseSAX(is,
264+
new EmbeddedContentHandler(new GeoGebraXMLHandler(xhtml, metadata)), context);
265+
}
266+
}
267+
268+
/**
269+
* Emits the representative thumbnail as an embedded document marked
270+
* {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}.
271+
*/
272+
private void handleThumbnail(ZipFile zipFile, ZipArchiveEntry entry, XHTMLContentHandler xhtml,
273+
ParseContext context, EmbeddedDocumentUtil embeddedDocumentUtil)
274+
throws IOException, SAXException {
275+
if (entry == null) {
276+
return;
277+
}
278+
Metadata embeddedMetadata = Metadata.newInstance(context);
279+
embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, entry.getName());
280+
embeddedMetadata.set(TikaCoreProperties.INTERNAL_PATH, entry.getName());
281+
embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
282+
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
283+
embeddedMetadata.set(Metadata.CONTENT_TYPE, "image/png");
284+
if (embeddedDocumentUtil.shouldParseEmbedded(embeddedMetadata)) {
285+
try (TikaInputStream tisZip = TikaInputStream.get(zipFile.getInputStream(entry))) {
286+
embeddedDocumentUtil.parseEmbedded(tisZip, new EmbeddedContentHandler(xhtml),
287+
embeddedMetadata, false);
288+
}
289+
}
290+
}
291+
292+
/**
293+
* Emits everything that is not GeoGebra housekeeping (e.g. inserted
294+
* pictures) as an embedded document. Housekeeping entries are matched by
295+
* their basename so the rule covers slide subdirectories, too.
296+
*/
297+
private void handleOtherEntries(ZipFile zipFile, XHTMLContentHandler xhtml,
298+
ParseContext context,
299+
EmbeddedDocumentUtil embeddedDocumentUtil)
300+
throws IOException, SAXException {
301+
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
302+
while (entries.hasMoreElements()) {
303+
ZipArchiveEntry entry = entries.nextElement();
304+
if (entry.isDirectory()) {
305+
continue;
306+
}
307+
String name = entry.getName();
308+
if (STRUCTURE_JSON.equals(name)) {
309+
continue;
310+
}
311+
String basename = name.substring(name.lastIndexOf('/') + 1);
312+
if (KNOWN_ENTRY_NAMES.contains(basename)) {
313+
continue;
314+
}
315+
Metadata embeddedMetadata = Metadata.newInstance(context);
316+
embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, basename);
317+
embeddedMetadata.set(TikaCoreProperties.INTERNAL_PATH, name);
318+
embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
319+
TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
320+
if (embeddedDocumentUtil.shouldParseEmbedded(embeddedMetadata)) {
321+
try (TikaInputStream tisZip =
322+
TikaInputStream.get(zipFile.getInputStream(entry))) {
323+
embeddedDocumentUtil.parseEmbedded(tisZip, new EmbeddedContentHandler(xhtml),
324+
embeddedMetadata, false);
325+
}
326+
}
327+
}
328+
}
329+
}

0 commit comments

Comments
 (0)