AbstractPDF2XHTML.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.parser.pdf;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.AUTO;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.NO_OCR;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.OCR_AND_TEXT_EXTRACTION;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.OCR_ONLY;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import javax.xml.stream.XMLStreamException;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDJavascriptNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.common.COSObjectable;
import org.apache.pdfbox.pdmodel.common.PDDestinationOrAction;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.common.filespecification.PDFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDSimpleFileSpecification;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.interactive.action.PDAction;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionImportData;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionRemoteGoTo;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.action.PDAnnotationAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDDocumentCatalogAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDFormFieldAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDPageAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationMarkup;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDXFAResource;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.tools.imageio.ImageIOUtil;
import org.apache.pdfbox.util.Matrix;
import org.apache.pdfbox.util.Vector;
import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;
import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaTimeoutException;
import org.apache.tika.exception.WriteLimitReachedException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Font;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.PDF;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.TikaPagedText;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.parser.enricher.CompositeContentEnricher;
import org.apache.tika.parser.enricher.ContentEnrichers;
import org.apache.tika.parser.hook.ParseHooks;
import org.apache.tika.parser.pdf.updates.IncrementalUpdateRecord;
import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
import org.apache.tika.parser.pdf.updates.StartXRefOffset;
import org.apache.tika.renderer.CompositeRenderer;
import org.apache.tika.renderer.PageBasedRenderResults;
import org.apache.tika.renderer.PageRangeRequest;
import org.apache.tika.renderer.RenderResult;
import org.apache.tika.renderer.Renderer;
import org.apache.tika.renderer.RenderingTracker;
import org.apache.tika.renderer.pdf.pdfbox.NoTextPDFRenderer;
import org.apache.tika.renderer.pdf.pdfbox.PDDocumentRenderer;
import org.apache.tika.renderer.pdf.pdfbox.PDFRenderingState;
import org.apache.tika.renderer.pdf.pdfbox.TextOnlyPDFRenderer;
import org.apache.tika.renderer.pdf.pdfbox.VectorGraphicsOnlyPDFRenderer;
import org.apache.tika.sax.BodyContentHandler;
import org.apache.tika.sax.ContentHandlerDecorator;
import org.apache.tika.sax.EmbeddedContentHandler;
import org.apache.tika.sax.XHTMLContentHandler;
import org.apache.tika.utils.StringUtils;
class AbstractPDF2XHTML extends PDFTextStripper {
public static final String XMP_DOCUMENT_CATALOG_LOCATION = "documentCatalog";
public static final String XMP_PAGE_LOCATION_PREFIX = "page ";
/**
* Maximum recursive depth to prevent cycles/recursion bombs.
* This applies to AcroForm processing and processing
* the embedded document tree.
*/
private final static int MAX_RECURSION_DEPTH = 100;
private final static int MAX_BOOKMARK_ITEMS = 10000;
//This is used for both types and subtypes.
//These can be unbounded. We need to limit the number we store.
private final static int MAX_ANNOTATION_TYPES = 100;
private static final String THREE_D = "3D";
private static final COSName ON_INSTANTIATE = COSName.getPDFName("OnInstantiate");
private static final String NULL_STRING = "null";
private static final MediaType XFA_MEDIA_TYPE = MediaType.application("vnd.adobe.xdp+xml");
private static final MediaType XMP_MEDIA_TYPE = MediaType.application("rdf+xml");
final List<Exception> exceptions = new ArrayList<>();
final PDDocument pdDocument;
final XHTMLContentHandler xhtml;
// Non-null only for AUTO with a text recognizer: records each page's text for the verdict.
private final PageTextBuffer pageBuffer;
// Every enricher for the render type, resolved once per document; null skips the render.
final Parser ocrEngine;
// Non-null only when the image strategy renders every page: the enrichers that do not
// recognize text run on each page the OCR step leaves alone, so a page is enriched once
// whether or not it tripped the OCR verdict.
private final Parser pageAnnotators;
final MediaType ocrImageMediaType;
private PageText pageDecision = PageText.UNDECIDED;
/** What the OCR step did with the current page; SKIPPED until it runs. */
private PageOcr currentPageOcr = PageOcr.SKIPPED;
// Text held back from an OCR_WANTED page; replayed if OCR does not run.
private List<PageTextBuffer.SaxEvent> pendingText = Collections.emptyList();
final ParseContext context;
final Metadata metadata;
final EmbeddedDocumentExtractor embeddedDocumentExtractor;
final PDFParserConfig config;
final Renderer renderer;
final CompositeContentEnricher contentEnrichers;
private final ParseHooks hooks;
/** Pages are rendered for inference: the config says PAGES and a hook wants them. */
private final boolean pagesForInference;
/** The current page's render, made once for OCR, annotators and inference alike. */
private RenderResult pageRender;
private TemporaryResources pageRenderResources;
/**
* Format used for signature dates
* TODO Make this thread-safe
*/
private final SimpleDateFormat dateFormat =
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT);
private final Set<String> fontNames = new TreeSet<>();
private final Set<String> annotationTypes = new TreeSet<>();
private final Set<String> annotationSubtypes = new TreeSet<>();
private final Set<String> triggers = new TreeSet<>();
private final Set<String> actionTypes = new TreeSet<>();
//these are files that we extract as part of Annotations
//We don't want to extract them twice when we go through the
//full DOM looking for /Type = /EmbeddedFile
private final Set<COSBase> extractedFiles = new HashSet<>();
//zero-based pageIndex
int pageIndex = 0;
//private in PDFTextStripper...must have own copy because we override processpages
int unmappedUnicodeCharsPerPage = 0;
int totalCharsPerPage = 0;
int totalUnmappedUnicodeCharacters;
int totalCharacters;
//contains at least one font that is not embedded
boolean containsNonEmbeddedFont = false;
//contains at least one broken font
boolean containsDamagedFont = false;
int num3DAnnotations = 0;
AbstractPDF2XHTML(PDDocument pdDocument, ContentHandler handler, ParseContext context,
Metadata metadata, PDFParserConfig config, Renderer renderer,
CompositeContentEnricher contentEnrichers) throws IOException {
this.pdDocument = pdDocument;
this.ocrImageMediaType =
MediaType.image(config.getOcr().getImageFormat().getFormatName());
// resolved before any page is rendered, so a probe stands in for the render
Metadata renderTarget = new Metadata();
renderTarget.set(HttpHeaders.CONTENT_TYPE, ocrImageMediaType.toString());
renderTarget.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
this.ocrEngine =
ContentEnrichers.get(contentEnrichers, ocrImageMediaType, renderTarget, context);
PDFParserConfig.IMAGE_STRATEGY imageStrategy = config.getImageStrategy();
Parser annotators = null;
if (imageStrategy == PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_BEFORE_PARSE
|| imageStrategy == PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END) {
try (ContentEnrichers.Suspension recognizersOff =
ContentEnrichers.suspendRecognizers(context)) {
annotators = ContentEnrichers.get(contentEnrichers, ocrImageMediaType,
renderTarget, context);
}
}
this.pageAnnotators = annotators;
if (config.getOcr().getStrategy() == AUTO && ContentEnrichers.hasTextRecognizer(
contentEnrichers, ocrImageMediaType, renderTarget, context)) {
this.pageBuffer = new PageTextBuffer(handler);
this.xhtml = new XHTMLContentHandler(pageBuffer, metadata, context);
} else {
this.pageBuffer = null;
this.xhtml = new XHTMLContentHandler(handler, metadata, context);
}
this.context = context;
this.metadata = metadata;
this.config = config;
this.renderer = renderer;
this.contentEnrichers = contentEnrichers;
embeddedDocumentExtractor = EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
this.hooks = context.get(ParseHooks.class);
boolean wantsPages = false;
if (hooks != null && config.getInference().getInput().contains(InferenceConfig.Input.PAGES)) {
try {
wantsPages = hooks.wantsPages(ocrImageMediaType, context);
} catch (TikaException e) {
throw new IOException(e);
}
}
this.pagesForInference = wantsPages;
}
private static void addNonNullAttribute(String name, String value, AttributesImpl attributes) {
if (name == null || value == null) {
return;
}
attributes.addAttribute("", name, name, "CDATA", value);
}
private static void setOrReplaceAttribute(String name, String value,
AttributesImpl attributes) {
if (name == null || value == null) {
return;
}
int idx = attributes.getIndex("", name);
if (idx >= 0) {
attributes.setValue(idx, value);
} else {
attributes.addAttribute("", name, name, "CDATA", value);
}
}
private static PDActionURI getActionURI(PDAnnotation annot) {
//copied and pasted from PDFBox's PrintURLs
// use reflection to catch all annotation types that have getAction()
// If you can't use reflection, then check for classes
// PDAnnotationLink and PDAnnotationWidget, and call getAction() and check for a
// PDActionURI result type
try {
Method actionMethod = annot.getClass().getDeclaredMethod("getAction");
if (actionMethod.getReturnType().equals(PDAction.class)) {
PDAction action = (PDAction) actionMethod.invoke(annot);
if (action instanceof PDActionURI) {
return (PDActionURI) action;
}
}
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
//swallow
}
return null;
}
@Override
protected void startPage(PDPage page) throws IOException {
pageDecision = PageText.UNDECIDED;
pendingText = Collections.emptyList();
try {
xhtml.startElement("div", "class", "page");
} catch (SAXException e) {
throw new IOException("Unable to start a page", e);
}
if (pageBuffer != null) {
pageBuffer.start();
}
writeParagraphStart();
}
/**
* Ends the page's text phase: takes the AUTO verdict and either releases the recorded
* text in place or holds it until endPage knows whether OCR ran. Idempotent, so
* subclasses call it as soon as their text is written and endPage covers the rest.
*/
void endPageText() throws IOException {
if (pageDecision != PageText.UNDECIDED) {
return;
}
List<PageTextBuffer.SaxEvent> captured =
pageBuffer == null ? Collections.emptyList() : pageBuffer.stop();
boolean wanted = config.getOcr().getStrategy() == AUTO &&
ocrWanted(pageBuffer == null ? null : pageBuffer.text());
pageDecision = wanted ? PageText.OCR_WANTED : PageText.KEEP;
if (wanted) {
pendingText = captured;
} else {
replay(captured);
}
}
private void replay(List<PageTextBuffer.SaxEvent> events) throws IOException {
if (events.isEmpty()) {
return;
}
try {
pageBuffer.replay(events);
} catch (SAXException e) {
throw new IOException("Unable to write page text", e);
}
}
/**
* AUTO verdict for the page whose text was just extracted. Counters only for now;
* pageText (null when nothing was recorded) is the seam for a junk detector (TIKA-4883).
*/
boolean ocrWanted(String pageText) {
OcrConfig.StrategyAuto strategyAuto = config.getOcr().getStrategyAuto();
if (totalCharsPerPage <= strategyAuto.getTotalCharsPerPage()) {
return true;
}
float percentUnmapped = (float) unmappedUnicodeCharsPerPage / totalCharsPerPage;
float limit = strategyAuto.getUnmappedUnicodeCharsPerPage();
return (limit < 1) ? percentUnmapped > limit : unmappedUnicodeCharsPerPage > limit;
}
private void extractXMPXFA() throws IOException, SAXException {
Set<MediaType> supportedTypes = Collections.EMPTY_SET;
Parser embeddedParser = context.get(Parser.class);
if (embeddedParser != null) {
supportedTypes = embeddedParser.getSupportedTypes(context);
}
if (supportedTypes == null || supportedTypes.size() == 0) {
return;
}
if (supportedTypes.contains(XMP_MEDIA_TYPE)) {
//try the main metadata
if (pdDocument.getDocumentCatalog().getMetadata() != null) {
try (TikaInputStream tis = TikaInputStream.get(
() -> pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata(),
new TemporaryResources(), null)) {
extractXMPAsEmbeddedFile(tis, XMP_DOCUMENT_CATALOG_LOCATION);
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
}
//now iterate through the pages
int pageNumber = 1;
for (PDPage page : pdDocument.getPages()) {
if (page.getMetadata() != null) {
try (TikaInputStream tis = TikaInputStream.get(
() -> page.getMetadata().exportXMPMetadata(),
new TemporaryResources(), null)) {
extractXMPAsEmbeddedFile(tis, XMP_PAGE_LOCATION_PREFIX + pageNumber);
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
}
pageNumber++;
}
}
//now try the xfa
if (pdDocument.getDocumentCatalog().getAcroForm(null) != null &&
pdDocument.getDocumentCatalog().getAcroForm(null).getXFA() != null) {
Metadata xfaMetadata = Metadata.newInstance(context);
xfaMetadata.set(HttpHeaders.CONTENT_TYPE, XFA_MEDIA_TYPE.toString());
xfaMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.METADATA.toString());
if (embeddedDocumentExtractor.shouldParseEmbedded(xfaMetadata, context) &&
supportedTypes.contains(XFA_MEDIA_TYPE)) {
byte[] bytes = null;
try {
bytes = pdDocument.getDocumentCatalog().getAcroForm(null).getXFA().getBytes();
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
if (bytes != null) {
try (TikaInputStream tis = TikaInputStream.get(bytes)) {
parseMetadata(tis, xfaMetadata);
}
}
}
}
}
private void extractXMPAsEmbeddedFile(TikaInputStream tis, String location)
throws IOException, SAXException {
if (tis == null) {
return;
}
Metadata xmpMetadata = Metadata.newInstance(context);
xmpMetadata.set(HttpHeaders.CONTENT_TYPE, XMP_MEDIA_TYPE.toString());
xmpMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.METADATA.toString());
xmpMetadata.set(PDF.XMP_LOCATION, location);
if (embeddedDocumentExtractor.shouldParseEmbedded(xmpMetadata, context)) {
parseMetadata(tis, xmpMetadata);
}
}
private void parseMetadata(TikaInputStream tis, Metadata embeddedMetadata)
throws IOException, SAXException {
try {
embeddedDocumentExtractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml),
embeddedMetadata, context, true);
} catch (IOException e) {
handleCatchableIOE(e);
}
}
private void extractEmbeddedDocuments(PDDocument document)
throws IOException, SAXException, TikaException {
//See 14.13.10 for the 2.0 spec. Associated files can show up in lots of places...even
// streams.
// It would be great to get more context from the /AF info, but we risk missing files
//if we don't look everywhere. With the current method, we're at least getting all
//filespecs at the cost of losing context (to what was this file attached: doc, page,
// stream, etc?).
//find all Filespecs TIKA-4012
List<COSObject> objs = document.getDocument().getObjectsByType(COSName.FILESPEC);
Set<COSBase> seen = new HashSet<>();
for (COSObject obj : objs) {
processDoc("", "", createFileSpecification(obj.getObject()), new AttributesImpl());
seen.add(obj.getObject());
}
//now go through the embedded files names tree to get those rare cases where
//a file (instead of a filespec) is attached directly to the names tree
//or where the filespec is a direct object
if (document.getDocumentCatalog() == null) {
return;
}
if (document.getDocumentCatalog().getNames() == null) {
return;
}
if (document.getDocumentCatalog().getNames().getEmbeddedFiles() == null) {
return;
}
//use a list instead of a name-based map in case there are key collisions
//that could hide attachments
List<NameSpecTuple> specs = new ArrayList<>();
extractFilesfromEFTree(document.getDocumentCatalog().getNames().getEmbeddedFiles(), specs,
0);
//this avoids duplication with the above /FileSpec searching, but also in the case
//where the same underlying file has different names in the EFTree
for (NameSpecTuple nameSpecTuple : specs) {
if (seen.contains(nameSpecTuple.getSpec().getCOSObject())) {
continue;
}
processDoc(nameSpecTuple.getName(), "", nameSpecTuple.getSpec(), new AttributesImpl());
seen.add(nameSpecTuple.getSpec().getCOSObject());
}
}
private void processDocOnAction(String name, String annotationType, PDFileSpecification spec,
AttributesImpl attributes)
throws TikaException, SAXException, IOException {
if (spec == null) {
return;
}
processDoc(name, annotationType, spec, attributes);
extractedFiles.add(spec.getCOSObject());
}
private void processDoc(String name, String annotationType, PDFileSpecification spec,
AttributesImpl attributes)
throws TikaException, SAXException, IOException {
if (spec == null) {
return;
}
if (extractedFiles.contains(spec.getCOSObject())) {
return;
}
if (spec instanceof PDSimpleFileSpecification) {
//((PDSimpleFileSpecification)spec).getFile();
setOrReplaceAttribute("class", "linked", attributes);
setOrReplaceAttribute("id", spec.getFile(), attributes);
xhtml.startElement("div", attributes);
xhtml.endElement("div");
} else if (spec instanceof PDComplexFileSpecification) {
if (attributes.getIndex("source") < 0) {
attributes.addAttribute("", "source", "source", "CDATA", "attachment");
}
extractMultiOSPDEmbeddedFiles(name, annotationType, (PDComplexFileSpecification) spec,
attributes);
}
}
private void extractMultiOSPDEmbeddedFiles(String displayName, String annotationType,
PDComplexFileSpecification spec,
AttributesImpl attributes)
throws IOException, SAXException, TikaException {
if (spec == null) {
return;
}
//current strategy is to pull all, not just first non-null
extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFile(),
spec.getEmbeddedFile(), attributes);
extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileMac(),
spec.getEmbeddedFileMac(), attributes);
extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileDos(),
spec.getEmbeddedFileDos(), attributes);
extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileUnix(),
spec.getEmbeddedFileUnix(), attributes);
//Check for /Thumb (thumbnail image);
// /CI (collection item) adobe specific, can have /adobe:DisplayName and a summary
}
private void extractPDEmbeddedFile(String displayName, String annotationType,
PDComplexFileSpecification spec, String fileName,
PDEmbeddedFile pdEmbeddedFile, AttributesImpl attributes)
throws SAXException, IOException {
if (pdEmbeddedFile == null) {
//skip silently
return;
}
fileName =
(fileName == null || "".equals(fileName.trim())) ? spec.getFileUnicode() : fileName;
fileName = (fileName == null || "".equals(fileName.trim())) ? displayName : fileName;
// TODO: other metadata?
Metadata embeddedMetadata = Metadata.newInstance(context);
embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
//if the stream is missing a size, -1 is returned
long sz = pdEmbeddedFile.getSize();
if (sz > -1) {
embeddedMetadata.set(HttpHeaders.CONTENT_LENGTH, Long.toString(sz));
}
embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString());
embeddedMetadata.set(TikaCoreProperties.ORIGINAL_RESOURCE_NAME, fileName);
if (!StringUtils.isBlank(annotationType)) {
embeddedMetadata.set(PDF.EMBEDDED_FILE_ANNOTATION_TYPE, annotationType);
}
if (!StringUtils.isBlank(pdEmbeddedFile.getSubtype())) {
embeddedMetadata.set(PDF.EMBEDDED_FILE_SUBTYPE, pdEmbeddedFile.getSubtype());
}
if (!StringUtils.isBlank(spec.getFileDescription())) {
embeddedMetadata.set(PDF.EMBEDDED_FILE_DESCRIPTION, spec.getFileDescription());
}
String afRelationship = spec.getCOSObject().getNameAsString(PDFParser.AF_RELATIONSHIP);
if (StringUtils.isBlank(afRelationship)) {
afRelationship = spec.getCOSObject().getString(PDFParser.AF_RELATIONSHIP);
}
if (!StringUtils.isBlank(afRelationship)) {
embeddedMetadata.set(PDF.ASSOCIATED_FILE_RELATIONSHIP, afRelationship);
}
if (!embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, context)) {
return;
}
//open once now so a broken stream is recorded here, not mid-parse
try {
pdEmbeddedFile.createInputStream().close();
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
return;
}
//the file is in the document already: re-open (re-decode) it on rewind
//rather than cache a copy that a digest of a large attachment would spill
TikaInputStream tis = TikaInputStream.get(pdEmbeddedFile::createInputStream,
new TemporaryResources(), null);
setOrReplaceAttribute("class", "embedded", attributes);
setOrReplaceAttribute("id", fileName, attributes);
xhtml.startElement("div", attributes);
xhtml.endElement("div");
try {
embeddedDocumentExtractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml),
embeddedMetadata, context, false);
} finally {
IOUtils.closeQuietly(tis);
}
}
void handleCatchableIOE(IOException e) throws IOException {
if (WriteLimitReachedException.isWriteLimitReached(e)) {
metadata.set(TikaCoreProperties.WRITE_LIMIT_REACHED, "true");
throw e;
}
if (config.isCatchIntermediateIOExceptions()) {
EmbeddedDocumentUtil.recordException(e, metadata, context);
exceptions.add(e);
} else {
throw e;
}
}
/**
* Mirrors {@link #handleCatchableIOE(IOException)} for a per-page OCR timeout: remaining
* pages are still processed, and the caller still surfaces a TikaException wrapping the
* first recorded failure once every page has been attempted.
*/
void handleCatchableTimeout(TikaTimeoutException e) throws TikaTimeoutException {
if (config.isCatchIntermediateIOExceptions()) {
EmbeddedDocumentUtil.recordException(e, metadata, context);
exceptions.add(e);
} else {
throw e;
}
}
/** What the OCR step did with a page. */
enum PageOcr {
/** No engine was dispatched: NO_OCR, no engine, or maxPagesToOcr spent. */
SKIPPED,
/** The engine ran but wrote no text: an annotating enricher, a skip, a failure. */
NO_TEXT,
TEXT
}
PageOcr doOCROnCurrentPage(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
throws IOException, TikaException, SAXException {
PageOcr result = dispatchOcr(pdPage, ocrStrategy);
currentPageOcr = result;
return result;
}
private PageOcr dispatchOcr(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
throws IOException, TikaException, SAXException {
if (ocrStrategy.equals(NO_OCR)) {
return PageOcr.SKIPPED;
}
//count the number of times that OCR would have been called
OCRPageCounter c = context.get(OCRPageCounter.class);
if (c != null) {
c.increment();
}
// Enforce maxPagesToOcr limit
int maxPagesToOcr = config.getOcr().getMaxPagesToOcr();
if (maxPagesToOcr > 0 && c != null && c.getCount() > maxPagesToOcr) {
return PageOcr.SKIPPED;
}
if (ocrEngine == null) {
if (ocrStrategy == OCR_ONLY || ocrStrategy == OCR_AND_TEXT_EXTRACTION) {
throw new TikaException(
"I regret that I couldn't find an OCR engine to handle " +
ocrImageMediaType + ". Name one that covers it in " +
"\"text-recognizers\" (a configured list is authoritative), " +
"add one to the classpath when no list is configured, " +
"or set the OCR strategy to NO_OCR.");
}
return PageOcr.SKIPPED;
}
return enrichCurrentPage(pdPage, ocrEngine) ? PageOcr.TEXT : PageOcr.NO_TEXT;
}
/**
* Runs the annotating enrichers on a page the OCR step skipped, when every page is
* rendered. The rendering emitted as an embedded document is not enriched again.
*/
void annotateCurrentPage(PDPage pdPage) throws IOException, TikaException, SAXException {
if (pageAnnotators != null) {
enrichCurrentPage(pdPage, pageAnnotators);
}
}
/** Runs the engine on the page's render; true if the engine wrote text. */
private boolean enrichCurrentPage(PDPage pdPage, Parser engine)
throws IOException, TikaException, SAXException {
try {
RenderResult renderResult = currentPageRender(pdPage);
Metadata renderMetadata = renderResult.getMetadata();
TextCounter counter = new TextCounter(xhtml);
try (TikaInputStream tis = renderResult.getInputStream()) {
renderMetadata.set(HttpHeaders.CONTENT_TYPE, ocrImageMediaType.toString());
engine.parse(tis,
new EmbeddedContentHandler(new BodyContentHandler(counter)),
renderMetadata, context);
}
// Propagate enrichment metadata added by the OCR parser (e.g. tk:chunks
// from image embedding parsers) back to the parent document so it isn't
// silently discarded when the renderMetadata goes out of scope.
String renderChunks = renderMetadata.get(TikaCoreProperties.TIKA_CHUNKS);
if (renderChunks != null) {
metadata.set(TikaCoreProperties.TIKA_CHUNKS,
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
renderChunks));
}
return counter.sawText();
} catch (IOException e) {
handleCatchableIOE(e);
} catch (TikaTimeoutException e) {
handleCatchableTimeout(e);
} catch (SAXException e) {
throw new IOException("error writing OCR content from PDF", e);
}
return false;
}
/** The page's render, made on first use and closed at page end. */
private RenderResult currentPageRender(PDPage pdPage) throws IOException, TikaException {
if (pageRender == null) {
pageRenderResources = new TemporaryResources();
pageRender = renderCurrentPage(pdPage, pageRenderResources);
}
return pageRender;
}
private void closePageRender() {
RenderResult render = pageRender;
TemporaryResources resources = pageRenderResources;
pageRender = null;
pageRenderResources = null;
try (resources; render) {
// closing deletes the render file
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
}
/** Hands the page's render to the hooks when a PAGES binding wants it. */
private void offerCurrentPageToInference(PDPage pdPage) throws IOException, TikaException {
if (!pagesForInference) {
return;
}
RenderResult render = currentPageRender(pdPage);
if (render.getStatus() != RenderResult.STATUS.SUCCESS) {
return;
}
try (TikaInputStream tis = render.getInputStream()) {
hooks.offerPage(ocrImageMediaType, getCurrentPageNo(), tis.getPath(), context);
}
}
/**
* Appends two serialized tk:chunks JSON arrays without a JSON dependency, so each
* OCR'd page's chunks accumulate on the parent instead of first-page-wins.
* Falls back to the new value if either side is not an array.
*/
static String mergeChunkArrays(String existing, String added) {
if (existing == null || existing.isBlank()) {
return added;
}
String e = existing.trim();
String a = added.trim();
if (!e.startsWith("[") || !e.endsWith("]") || !a.startsWith("[") || !a.endsWith("]")) {
return a;
}
String eBody = e.substring(1, e.length() - 1).trim();
String aBody = a.substring(1, a.length() - 1).trim();
if (eBody.isEmpty()) {
return a;
}
if (aBody.isEmpty()) {
return e;
}
return "[" + eBody + "," + aBody + "]";
}
private RenderResult renderCurrentPage(PDPage pdPage, TemporaryResources tmpResources)
throws IOException, TikaException {
PDFRenderingState renderingState = context.get(PDFRenderingState.class);
if (renderingState == null) {
Metadata pageMetadata = getCurrentPageMetadata(pdPage);
return noContextRenderCurrentPage(pageMetadata, tmpResources);
}
//if the full document has already been rendered, then reuse that file
//TODO: we need to prevent this if only a portion of the page or portions
//of the page have been rendered.
//TODO: we should also figure out how to not reuse the rendering if
//the user wants to render twice (say, full color to display to users, but
//grayscale for (notionally?) better OCR).
PageBasedRenderResults results = (PageBasedRenderResults) renderingState.getRenderResults();
if (results != null) {
List<RenderResult> pageResults = results.getPage(getCurrentPageNo());
if (pageResults.size() == 1) {
return pageResults.get(0);
}
}
Metadata pageMetadata = getCurrentPageMetadata(pdPage);
Renderer thisRenderer = getPDFRenderer();
//if there's a configured renderer and if the rendering strategy is "all"
if (thisRenderer != null &&
config.getOcr().getRenderingStrategy() == OcrConfig.RenderingStrategy.ALL) {
PageRangeRequest pageRangeRequest =
new PageRangeRequest(getCurrentPageNo(), getCurrentPageNo());
if (thisRenderer instanceof PDDocumentRenderer) {
//do not do autocloseable. We need to leave the pdDocument open!
TikaInputStream tis = TikaInputStream.getPlaceholder();
tis.setOpenContainer(pdDocument);
return thisRenderer.render(tis, pageMetadata, context, pageRangeRequest)
.getResults().get(0);
} else {
PDFRenderingState state = context.get(PDFRenderingState.class);
if (state == null) {
throw new IllegalArgumentException("RenderingState must not be null");
}
return thisRenderer.render(state.getTikaInputStream(), pageMetadata, context,
pageRangeRequest).getResults().get(0);
}
} else {
return noContextRenderCurrentPage(pageMetadata, tmpResources);
}
}
private Renderer getPDFRenderer() {
if (renderer == null) {
return null;
}
if (renderer instanceof CompositeRenderer) {
return ((CompositeRenderer) renderer).getLeafRenderer(PDFParser.MEDIA_TYPE);
} else if (renderer.getSupportedTypes(context).contains(PDFParser.MEDIA_TYPE)) {
return renderer;
}
return null;
}
private Metadata getCurrentPageMetadata(PDPage pdPage) {
Metadata pageMetadata = Metadata.newInstance(context);
pageMetadata.set(TikaCoreProperties.TYPE, PDFParser.MEDIA_TYPE.toString());
pageMetadata.set(TikaPagedText.PAGE_NUMBER, getCurrentPageNo());
pageMetadata.set(TikaPagedText.PAGE_ROTATION, (float) pdPage.getRotation());
return pageMetadata;
}
private RenderResult noContextRenderCurrentPage(Metadata pageMetadata,
TemporaryResources tmpResources)
throws IOException, TikaException {
PDFRenderer renderer = null;
switch (config.getOcr().getRenderingStrategy()) {
case NO_TEXT:
renderer = new NoTextPDFRenderer(pdDocument);
break;
case TEXT_ONLY:
renderer = new TextOnlyPDFRenderer(pdDocument);
break;
case VECTOR_GRAPHICS_ONLY:
renderer = new VectorGraphicsOnlyPDFRenderer(pdDocument);
break;
case ALL:
renderer = new PDFRenderer(pdDocument);
break;
}
int dpi = config.getOcr().getDpi();
Path tmpFile = null;
RenderingTracker renderingTracker = context.get(RenderingTracker.class);
if (renderingTracker == null) {
renderingTracker = new RenderingTracker();
context.set(RenderingTracker.class, renderingTracker);
}
int id = renderingTracker.getNextId();
try {
// Check estimated pixel dimensions before rendering to
// prevent OOM on pathologically large pages
long maxPixels = config.getOcr().getMaxImagePixels();
if (maxPixels > 0) {
PDPage currentPage = pdDocument.getPage(pageIndex);
PDRectangle mediaBox = currentPage.getMediaBox();
long estWidth = (long) Math.ceil(mediaBox.getWidth() / 72.0 * dpi);
long estHeight = (long) Math.ceil(mediaBox.getHeight() / 72.0 * dpi);
long estPixels = estWidth * estHeight;
if (estPixels > maxPixels) {
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM,
"Skipping OCR for page " + (pageIndex + 1)
+ ": estimated " + estPixels
+ " pixels exceeds maxImagePixels="
+ maxPixels);
return new RenderResult(RenderResult.STATUS.EXCEPTION,
id, null, pageMetadata);
}
}
BufferedImage image =
renderer.renderImageWithDPI(pageIndex, dpi, config.getOcr().getImageType().getPdfBoxImageType());
//TODO -- get suffix based on OcrImageType
tmpFile = tmpResources.createTempFile();
try (OutputStream os = Files.newOutputStream(tmpFile)) {
//TODO: get output format from TesseractConfig
ImageIOUtil.writeImage(image, config.getOcr().getImageFormat().getFormatName(), os, dpi,
config.getOcr().getImageQuality());
}
} catch (SecurityException e) {
//throw SecurityExceptions immediately
throw e;
} catch (IOException | RuntimeException e) {
//image rendering can throw a variety of runtime exceptions, not just
// IOExceptions...
//need to have a wide catch
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
return new RenderResult(RenderResult.STATUS.EXCEPTION, id, null, pageMetadata);
}
return new RenderResult(RenderResult.STATUS.SUCCESS, id, tmpFile, pageMetadata);
}
@Override
protected void endPage(PDPage page) throws IOException {
metadata.add(PDF.CHARACTERS_PER_PAGE, totalCharsPerPage);
metadata.add(PDF.UNMAPPED_UNICODE_CHARS_PER_PAGE, unmappedUnicodeCharsPerPage);
try {
endPageText();
for (PDAnnotation annotation : page.getAnnotations()) {
processPageAnnotation(annotation);
}
if (config.getOcr().getStrategy() == OCR_AND_TEXT_EXTRACTION) {
doOCROnCurrentPage(page, OCR_AND_TEXT_EXTRACTION);
} else if (pageDecision == PageText.OCR_WANTED) {
if (doOCROnCurrentPage(page, AUTO) != PageOcr.TEXT) {
replay(pendingText);
}
}
// the engine composite already ran the annotators on a page it OCR'd
if (currentPageOcr == PageOcr.SKIPPED) {
annotateCurrentPage(page);
}
offerCurrentPageToInference(page);
PDPageAdditionalActions pageActions = page.getActions();
if (pageActions != null) {
handleDestinationOrAction(pageActions.getC(), ActionTrigger.PAGE_CLOSE);
handleDestinationOrAction(pageActions.getO(), ActionTrigger.PAGE_OPEN);
}
xhtml.endElement("div");
} catch (SAXException | TikaException e) {
throw new IOException("Unable to end a page", e);
} catch (IOException e) {
handleCatchableIOE(e);
} finally {
closePageRender();
currentPageOcr = PageOcr.SKIPPED;
totalCharsPerPage = 0;
unmappedUnicodeCharsPerPage = 0;
pendingText = Collections.emptyList();
}
if (config.isExtractFontNames() && page.getResources() != null) {
for (COSName n : page.getResources().getFontNames()) {
PDFont font = page.getResources().getFont(n);
if (font != null && font.getFontDescriptor() != null) {
String fontName = font.getFontDescriptor().getFontName();
if (fontName != null) {
fontNames.add(fontName);
}
}
}
}
}
private void processPageAnnotation(PDAnnotation annotation) throws TikaException, IOException, SAXException {
String annotationName = annotation.getAnnotationName();
if (annotationTypes.size() < MAX_ANNOTATION_TYPES) {
if (annotationName != null) {
annotationTypes.add(annotationName);
} else {
annotationTypes.add(NULL_STRING);
}
}
String annotationSubtype = annotation.getSubtype();
if (annotationSubtypes.size() < MAX_ANNOTATION_TYPES) {
if (annotationSubtype != null) {
annotationSubtypes.add(annotationSubtype);
} else {
annotationSubtypes.add(NULL_STRING);
}
}
if (annotation instanceof PDAnnotationFileAttachment) {
PDAnnotationFileAttachment fann = (PDAnnotationFileAttachment) annotation;
String subtype = "annotationFileAttachment";
AttributesImpl attributes = new AttributesImpl();
attributes.addAttribute("", "source", "source", "CDATA", subtype);
processDocOnAction("", subtype, fann.getFile(), attributes);
} else if (annotation instanceof PDAnnotationWidget) {
handleWidget((PDAnnotationWidget) annotation);
} else {
if (annotationSubtype == null) {
annotationSubtype = "unknown";
} else if (annotationSubtype.equals(THREE_D) ||
annotation.getCOSObject().containsKey(COSName.THREE_DD)) {
//To make this stricter, we could get the 3DD stream object and see if the
//subtype is U3D or PRC or model/ (prefix for model mime type)
extractOnInstantiate(annotation);
COSDictionary additionalActions = annotation.getCOSObject().getCOSDictionary(COSName.AA);
if (additionalActions != null) {
handlePDAnnotationAdditionalActions(new PDAnnotationAdditionalActions(additionalActions));
}
metadata.set(PDF.HAS_3D, true);
num3DAnnotations++;
}
for (COSDictionary fileSpec : findFileSpecs(annotation.getCOSObject())) {
AttributesImpl attributes = new AttributesImpl();
attributes.addAttribute("", "source", "source", "CDATA", annotationSubtype);
processDocOnAction("", annotationSubtype, createFileSpecification(fileSpec),
attributes);
}
}
if (! config.isExtractAnnotationText()) {
return;
}
// TODO: remove once PDFBOX-1143 is fixed:
PDActionURI uri = getActionURI(annotation);
if (uri != null) {
String link = uri.getURI();
if (link != null && !link.isBlank()) {
xhtml.startElement("div", "class", "annotation");
xhtml.startElement("a", "href", link);
xhtml.characters(link);
xhtml.endElement("a");
xhtml.endElement("div");
}
}
if (annotation instanceof PDAnnotationMarkup) {
PDAnnotationMarkup annotationMarkup = (PDAnnotationMarkup) annotation;
String title = annotationMarkup.getTitlePopup();
String subject = annotationMarkup.getSubject();
String contents = annotationMarkup.getContents();
// TODO: maybe also annotationMarkup.getRichContents()?
if (title != null || subject != null || contents != null) {
xhtml.startElement("div", "class", "annotation");
if (title != null) {
xhtml.startElement("div", "class", "annotationTitle");
xhtml.characters(title);
xhtml.endElement("div");
}
if (subject != null) {
xhtml.startElement("div", "class", "annotationSubject");
xhtml.characters(subject);
xhtml.endElement("div");
}
if (contents != null) {
xhtml.startElement("div", "class", "annotationContents");
xhtml.characters(contents);
xhtml.endElement("div");
}
xhtml.endElement("div");
}
}
}
private void extractOnInstantiate(PDAnnotation annotation) throws IOException, SAXException {
COSDictionary threeDD = annotation.getCOSObject().getCOSDictionary(COSName.THREE_DD);
if (threeDD == null) {
return;
}
COSStream stream = threeDD.getCOSStream(ON_INSTANTIATE);
if (stream == null) {
return;
}
Metadata m = getJavascriptMetadata("3DD_ON_INSTANTIATE", null, null);
if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
try (TikaInputStream tis = TikaInputStream.get(stream::createInputStream,
new TemporaryResources(), null)) {
embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, context, true);
}
}
AttributesImpl attrs = new AttributesImpl();
addNonNullAttribute("class", "javascript", attrs);
addNonNullAttribute("type", "3dd_on_instantiate", attrs);
xhtml.startElement("div", attrs);
xhtml.endElement("div");
}
private List<COSDictionary> findFileSpecs(COSDictionary cosDict) {
Set<COSName> types = new HashSet<>();
types.add(COSName.FILESPEC);
return PDFDOMUtil.findType(cosDict, types, MAX_RECURSION_DEPTH);
}
private void extractFilesfromEFTree(PDNameTreeNode efTree,
List<NameSpecTuple> embeddedFileNames, int depth)
throws IOException {
if (depth > MAX_RECURSION_DEPTH) {
throw new IOException("Hit max recursion depth");
}
Map<String, PDComplexFileSpecification> names = null;
try {
names = efTree.getNames();
} catch (IOException e) {
//LOG?
}
if (names != null) {
for (Map.Entry<String, PDComplexFileSpecification> e : names.entrySet()) {
embeddedFileNames.add(new NameSpecTuple(e.getKey(), e.getValue()));
}
}
List<PDNameTreeNode<PDComplexFileSpecification>> kids = efTree.getKids();
if (kids == null) {
return;
} else {
for (PDNameTreeNode<PDComplexFileSpecification> node : kids) {
extractFilesfromEFTree(node, embeddedFileNames, depth + 1);
}
}
}
private void handleWidget(PDAnnotationWidget widget)
throws TikaException, SAXException, IOException {
if (widget == null) {
return;
}
handleDestinationOrAction(widget.getAction(), ActionTrigger.ANNOTATION_WIDGET);
handlePDAnnotationAdditionalActions(widget.getActions());
}
private void handlePDAnnotationAdditionalActions(PDAnnotationAdditionalActions annotationActions) throws TikaException, IOException, SAXException {
if (annotationActions == null) {
return;
}
handleDestinationOrAction(annotationActions.getBl(), ActionTrigger.ANNOTATION_LOSE_INPUT_FOCUS);
handleDestinationOrAction(annotationActions.getD(), ActionTrigger.ANNOTATION_MOUSE_CLICK);
handleDestinationOrAction(annotationActions.getE(), ActionTrigger.ANNOTATION_CURSOR_ENTERS);
handleDestinationOrAction(annotationActions.getFo(), ActionTrigger.ANNOTATION_RECEIVES_FOCUS);
handleDestinationOrAction(annotationActions.getPC(), ActionTrigger.ANNOTATION_PAGE_CLOSED);
handleDestinationOrAction(annotationActions.getPI(), ActionTrigger.ANNOTATION_PAGE_NO_LONGER_VISIBLE);
handleDestinationOrAction(annotationActions.getPO(), ActionTrigger.ANNOTATION_PAGE_OPENED);
handleDestinationOrAction(annotationActions.getPV(), ActionTrigger.ANNOTATION_PAGE_VISIBLE);
handleDestinationOrAction(annotationActions.getU(), ActionTrigger.ANNOTATION_MOUSE_RELEASED);
handleDestinationOrAction(annotationActions.getX(), ActionTrigger.ANNOTATION_CURSOR_EXIT);
}
@Override
protected void startDocument(PDDocument pdf) throws IOException {
try {
xhtml.startDocument();
extractJavaScriptFromNameTreeNode(pdf);
try {
handleDestinationOrAction(pdf.getDocumentCatalog().getOpenAction(),
ActionTrigger.DOCUMENT_OPEN);
} catch (IOException e) {
//See PDFBOX-3773
//swallow -- no need to report this
}
} catch (TikaException | SAXException e) {
throw new IOException("Unable to start a document", e);
}
}
private void extractJavaScriptFromNameTreeNode(PDDocument pdf) throws SAXException {
if (! config.isExtractActions()) {
return;
}
if (pdf.getDocumentCatalog() == null || pdf.getDocumentCatalog().getNames() == null
|| pdf.getDocumentCatalog().getNames().getJavaScript() == null) {
return;
}
try {
PDJavascriptNameTreeNode pdjntn = pdf.getDocumentCatalog().getNames().getJavaScript();
addJavaScript(pdjntn.getNames());
int depth = 0;
processJavascriptNameTreeNodeKids(pdjntn.getKids(), depth + 1);
} catch (IOException e) {
//swallow
}
}
private void addJavaScript(Map<String, PDActionJavaScript> pdActionJavaScriptMap) throws IOException, SAXException {
if (pdActionJavaScriptMap == null) {
return;
}
for (Map.Entry<String, PDActionJavaScript> e : pdActionJavaScriptMap.entrySet()) {
String action = e.getValue().getAction();
if (StringUtils.isBlank(action)) {
return;
}
AttributesImpl attributes = new AttributesImpl();
addNonNullAttribute("trigger", "namesTree", attributes);
addNonNullAttribute("type", e.getValue().getClass().getSimpleName(), attributes);
processJavaScriptAction("NAMES_TREE", e.getKey(), e.getValue(), attributes);
}
}
private void processJavascriptNameTreeNodeKids(List<PDNameTreeNode<PDActionJavaScript>> kids, int depth) throws IOException, SAXException {
if (kids == null) {
return;
}
if (depth > MAX_RECURSION_DEPTH) {
//hit max recursion
//return silently for now...maybe throw Exception?
return;
}
for (PDNameTreeNode<PDActionJavaScript> pdntn: kids) {
addJavaScript(pdntn.getNames());
processJavascriptNameTreeNodeKids(pdntn.getKids(), depth + 1);
};
}
private void handleDestinationOrAction(PDDestinationOrAction action,
ActionTrigger actionTrigger)
throws IOException, SAXException, TikaException {
if (action == null || !config.isExtractActions()) {
return;
}
triggers.add(actionTrigger.name());
String actionOrDestString = "destination";
if (action instanceof PDAction) {
actionOrDestString = "action";
String actionType = ((PDAction) action).getType();
if (!StringUtils.isBlank(actionType)) {
actionTypes.add(actionType);
}
}
AttributesImpl attributes = new AttributesImpl();
addNonNullAttribute("class", actionOrDestString, attributes);
addNonNullAttribute("type", action.getClass().getSimpleName(), attributes);
addNonNullAttribute("trigger", actionTrigger.name(), attributes);
if (action instanceof PDActionImportData) {
processDocOnAction("", "", ((PDActionImportData) action).getFile(), attributes);
} else if (action instanceof PDActionLaunch) {
PDActionLaunch pdActionLaunch = (PDActionLaunch) action;
addNonNullAttribute("id", pdActionLaunch.getF(), attributes);
addNonNullAttribute("defaultDirectory", pdActionLaunch.getD(), attributes);
addNonNullAttribute("operation", pdActionLaunch.getO(), attributes);
addNonNullAttribute("parameters", pdActionLaunch.getP(), attributes);
processDocOnAction(pdActionLaunch.getF(), "", pdActionLaunch.getFile(), attributes);
} else if (action instanceof PDActionRemoteGoTo) {
PDActionRemoteGoTo remoteGoTo = (PDActionRemoteGoTo) action;
processDocOnAction("", "", remoteGoTo.getFile(), attributes);
} else if (action instanceof PDActionJavaScript) {
processJavaScriptAction(actionTrigger.name(), null, (PDActionJavaScript) action, attributes);
/*} else if (action instanceof PDActionSubmitForm) {
PDActionSubmitForm submitForm = (PDActionSubmitForm) action;
//these are typically urls, not actual file specification
PDFileSpecification fileSpecification = submitForm.getFile();
processDoc("", fileSpecification, new AttributesImpl());*/
} else {
xhtml.startElement("div", attributes);
xhtml.endElement("div");
}
}
private void processJavaScriptAction(String trigger, String jsActionName, PDActionJavaScript jsAction, AttributesImpl attrs) throws IOException, SAXException {
Metadata m = getJavascriptMetadata(trigger, jsActionName, StandardCharsets.UTF_8);
String js = jsAction.getAction();
js = (js == null) ? "" : js;
if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
try (TikaInputStream tis = TikaInputStream.get(js.getBytes(StandardCharsets.UTF_8))) {
embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, context, true);
}
};
setOrReplaceAttribute("class", "javascript", attrs);
setOrReplaceAttribute("type", jsAction.getType(), attrs);
addNonNullAttribute("subtype", jsAction.getSubType(), attrs);
xhtml.startElement("div", attrs);
xhtml.endElement("div");
}
private Metadata getJavascriptMetadata(String trigger, String jsActionName, Charset charset) {
Metadata m = Metadata.newInstance(context);
m.set(HttpHeaders.CONTENT_TYPE, "application/javascript");
m.set(PDF.ACTION_TRIGGER, trigger);
m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.MACRO.name());
if (! StringUtils.isBlank(jsActionName)) {
m.set(PDF.JS_NAME, jsActionName);
}
if (charset != null) {
m.set(HttpHeaders.CONTENT_ENCODING, charset.toString());
}
return m;
}
@Override
protected void endDocument(PDDocument pdf) throws IOException {
try {
// Extract text for any bookmarks:
if (config.isExtractBookmarksText()) {
extractBookmarkText();
}
try {
extractEmbeddedDocuments(pdf);
} catch (IOException e) {
handleCatchableIOE(e);
}
try {
extractIncrementalUpdates();
} catch (IOException e) {
handleCatchableIOE(e);
}
extractXMPXFA();
//extract acroform data at end of doc
if (config.isExtractAcroFormContent() == true) {
try {
extractAcroForm(pdf);
} catch (IOException e) {
handleCatchableIOE(e);
}
}
PDDocumentCatalogAdditionalActions additionalActions =
pdf.getDocumentCatalog().getActions();
handleDestinationOrAction(additionalActions.getDP(),
ActionTrigger.AFTER_DOCUMENT_PRINT);
handleDestinationOrAction(additionalActions.getDS(), ActionTrigger.AFTER_DOCUMENT_SAVE);
handleDestinationOrAction(additionalActions.getWC(),
ActionTrigger.BEFORE_DOCUMENT_CLOSE);
handleDestinationOrAction(additionalActions.getWP(),
ActionTrigger.BEFORE_DOCUMENT_PRINT);
handleDestinationOrAction(additionalActions.getWS(),
ActionTrigger.BEFORE_DOCUMENT_SAVE);
//now record annotationtypes and subtypes
for (String annotationType : annotationTypes) {
metadata.add(PDF.ANNOTATION_TYPES, annotationType);
}
for (String annotationSubtype : annotationSubtypes) {
metadata.add(PDF.ANNOTATION_SUBTYPES, annotationSubtype);
}
for (String trigger : triggers) {
metadata.add(PDF.ACTION_TRIGGERS, trigger);
}
for (String actionType : actionTypes) {
metadata.add(PDF.ACTION_TYPES, actionType);
}
xhtml.endDocument();
} catch (TikaException | SAXException e) {
throw new IOException("Unable to end a document", e);
}
if (fontNames.size() > 0) {
for (String fontName : fontNames) {
metadata.add(Font.FONT_NAME, fontName);
}
}
metadata.set(PDF.TOTAL_UNMAPPED_UNICODE_CHARS, totalUnmappedUnicodeCharacters);
if (totalCharacters > 0) {
metadata.set(PDF.OVERALL_PERCENTAGE_UNMAPPED_UNICODE_CHARS,
(float) totalUnmappedUnicodeCharacters / (float) totalCharacters);
}
metadata.set(PDF.CONTAINS_DAMAGED_FONT, containsDamagedFont);
metadata.set(PDF.CONTAINS_NON_EMBEDDED_FONT, containsNonEmbeddedFont);
metadata.set(PDF.NUM_3D_ANNOTATIONS, num3DAnnotations);
}
private void extractIncrementalUpdates() throws SAXException, IOException {
if (!config.isParseIncrementalUpdates()) {
return;
}
IncrementalUpdateRecord incrementalUpdateRecord =
context.get(IncrementalUpdateRecord.class);
if (incrementalUpdateRecord == null) {
//should log
return;
}
int count = 0;
//don't include the last xref (coz that's the full pdf)
for (int i = 0; i < incrementalUpdateRecord.getOffsets().size() - 1
&& i < config.getMaxIncrementalUpdates(); i++) {
StartXRefOffset xRefOffset = incrementalUpdateRecord.getOffsets().get(i);
//don't count linearized dummy xref offset
//TODO figure out better way of managing this
if (xRefOffset.getStartxref() == 0) {
continue;
}
try {
parseIncrementalUpdate(count, incrementalUpdateRecord.getPath(), xRefOffset);
count++;
} catch (IOException e) {
handleCatchableIOE(e);
}
}
}
private void parseIncrementalUpdate(int count, Path path, StartXRefOffset xRefOffset)
throws SAXException, IOException {
TemporaryResources tmp = new TemporaryResources();
try {
Path update = tmp.createTempFile();
try (InputStream input = Files.newInputStream(path);
OutputStream outputStream = Files.newOutputStream(update, StandardOpenOption.WRITE)) {
IOUtils.copyLarge(input, outputStream, 0, xRefOffset.getEndEofOffset());
}
Metadata updateMetadata = Metadata.newInstance(context);
updateMetadata.set(PDF.INCREMENTAL_UPDATE_NUMBER, count);
updateMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.VERSION.toString());
if (embeddedDocumentExtractor.shouldParseEmbedded(updateMetadata, context)) {
try (TikaInputStream tis = TikaInputStream.get(update)) {
context.set(IsIncrementalUpdate.class, IsIncrementalUpdate.IS_INCREMENTAL_UPDATE);
embeddedDocumentExtractor.parseEmbedded(tis, xhtml, updateMetadata, context, false);
}
}
} finally {
tmp.close();
}
}
void extractBookmarkText() throws SAXException, IOException, TikaException {
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
if (outline != null) {
Set<COSObjectable> seen = new HashSet<>();
extractBookmarkText(outline, seen, 0);
}
}
void extractBookmarkText(PDOutlineNode bookmark, Set<COSObjectable> seen, int itemCount)
throws SAXException, IOException, TikaException {
PDOutlineItem current = bookmark.getFirstChild();
if (itemCount > MAX_BOOKMARK_ITEMS) {
return;
}
if (current != null) {
if (seen.contains(current)) {
return;
}
xhtml.startElement("ul");
while (current != null) {
if (seen.contains(current)) {
break;
}
if (itemCount > MAX_BOOKMARK_ITEMS) {
break;
}
seen.add(current);
xhtml.startElement("li");
xhtml.characters(current.getTitle());
xhtml.endElement("li");
handleDestinationOrAction(current.getAction(), ActionTrigger.BOOKMARK);
// Recurse:
extractBookmarkText(current, seen, itemCount + 1);
current = current.getNextSibling();
itemCount++;
}
xhtml.endElement("ul");
}
}
void extractAcroForm(PDDocument pdf) throws IOException, SAXException, TikaException {
//Thank you, Ben Litchfield, for org.apache.pdfbox.examples.fdf.PrintFields
//this code derives from Ben's code
PDDocumentCatalog catalog = pdf.getDocumentCatalog();
if (catalog == null) {
return;
}
PDAcroForm form = catalog.getAcroForm(null);
if (form == null) {
return;
}
//if it has xfa, try that.
//if it doesn't exist or there's an exception,
//go with traditional AcroForm
PDXFAResource pdxfa = form.getXFA();
if (pdxfa != null) {
//if successful, return
XFAExtractor xfaExtractor = new XFAExtractor();
InputStream is = null;
try {
is = new BufferedInputStream(
UnsynchronizedByteArrayInputStream.builder().setByteArray(pdxfa.getBytes()).get());
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
if (is != null) {
try {
xfaExtractor.extract(is, xhtml, metadata, context);
return;
} catch (XMLStreamException e) {
//if there was an xml parse exception in xfa, try the AcroForm
EmbeddedDocumentUtil.recordException(e, metadata, context);
} finally {
IOUtils.closeQuietly(is);
}
}
}
@SuppressWarnings("rawtypes") List fields = form.getFields();
if (fields == null) {
return;
}
@SuppressWarnings("rawtypes") ListIterator itr = fields.listIterator();
if (itr == null) {
return;
}
xhtml.startElement("div", "class", "acroform");
xhtml.startElement("ol");
while (itr.hasNext()) {
Object obj = itr.next();
if (obj != null && obj instanceof PDField) {
processAcroField((PDField) obj, 0);
}
}
xhtml.endElement("ol");
xhtml.endElement("div");
}
private void processAcroField(PDField field, final int currentRecursiveDepth)
throws SAXException, IOException, TikaException {
if (currentRecursiveDepth >= MAX_RECURSION_DEPTH) {
return;
}
PDFormFieldAdditionalActions pdFormFieldAdditionalActions = field.getActions();
if (pdFormFieldAdditionalActions != null) {
handleDestinationOrAction(pdFormFieldAdditionalActions.getC(),
ActionTrigger.FORM_FIELD_RECALCULATE);
handleDestinationOrAction(pdFormFieldAdditionalActions.getF(),
ActionTrigger.FORM_FIELD_FORMATTED);
handleDestinationOrAction(pdFormFieldAdditionalActions.getK(),
ActionTrigger.FORM_FIELD_KEYSTROKE);
handleDestinationOrAction(pdFormFieldAdditionalActions.getV(),
ActionTrigger.FORM_FIELD_VALUE_CHANGE);
}
if (field.getWidgets() != null) {
for (PDAnnotationWidget widget : field.getWidgets()) {
handleWidget(widget);
}
}
addFieldString(field);
if (field instanceof PDNonTerminalField) {
int r = currentRecursiveDepth + 1;
xhtml.startElement("ol");
for (PDField child : ((PDNonTerminalField) field).getChildren()) {
processAcroField(child, r);
}
xhtml.endElement("ol");
}
}
private void addFieldString(PDField field) throws SAXException {
//Pick partial name to present in content and altName for attribute
//Ignoring FullyQualifiedName for now
String partName = field.getPartialName();
String altName = field.getAlternateFieldName();
StringBuilder sb = new StringBuilder();
AttributesImpl attrs = new AttributesImpl();
if (partName != null) {
sb.append(partName).append(": ");
}
if (altName != null) {
attrs.addAttribute("", "altName", "altName", "CDATA", altName);
}
//return early if PDSignature field
if (field instanceof PDSignatureField) {
handleSignature(attrs, (PDSignatureField) field);
return;
}
String value = field.getValueAsString();
if (value != null && !value.equals("null")) {
sb.append(value);
}
if (attrs.getLength() > 0 || sb.length() > 0) {
xhtml.startElement("li", attrs);
xhtml.characters(sb.toString());
xhtml.endElement("li");
}
}
private void handleSignature(AttributesImpl parentAttributes, PDSignatureField sigField)
throws SAXException {
PDSignature sig = sigField.getSignature();
if (sig == null) {
return;
}
Map<String, String> vals = new TreeMap<>();
vals.put("name", sig.getName());
vals.put("contactInfo", sig.getContactInfo());
vals.put("location", sig.getLocation());
vals.put("reason", sig.getReason());
Calendar cal = sig.getSignDate();
if (cal != null) {
dateFormat.setTimeZone(cal.getTimeZone());
vals.put("date", dateFormat.format(cal.getTime()));
}
//see if there is any data
int nonNull = 0;
for (String val : vals.keySet()) {
if (val != null && !val.equals("")) {
nonNull++;
}
}
//if there is, process it
if (nonNull > 0) {
metadata.set(TikaCoreProperties.HAS_SIGNATURE, "true");
xhtml.startElement("li", parentAttributes);
AttributesImpl attrs = new AttributesImpl();
attrs.addAttribute("", "type", "type", "CDATA", "signaturedata");
xhtml.startElement("ol", attrs);
for (Map.Entry<String, String> e : vals.entrySet()) {
if (e.getValue() == null || e.getValue().equals("")) {
continue;
}
attrs = new AttributesImpl();
attrs.addAttribute("", "signdata", "signdata", "CDATA", e.getKey());
xhtml.startElement("li", attrs);
xhtml.characters(e.getValue());
xhtml.endElement("li");
}
xhtml.endElement("ol");
xhtml.endElement("li");
}
}
/**
* we need to override this because we are overriding {@link #processPages(PDPageTree)}
*
* @return
*/
@Override
public int getCurrentPageNo() {
return pageIndex + 1;
}
/**
* See TIKA-2845 for why we need to override this.
*
* @param pages
* @throws IOException
*/
@Override
protected void processPages(PDPageTree pages) throws IOException {
int maxPages = config.getMaxPages();
int pagesProcessed = 0;
for (PDPage page : pages) {
if (maxPages > 0 && pagesProcessed >= maxPages) {
break;
}
if (getCurrentPageNo() >= getStartPage() && getCurrentPageNo() <= getEndPage()) {
processPage(page);
pagesProcessed++;
}
pageIndex++;
}
}
@Override
public void setStartBookmark(PDOutlineItem pdOutlineItem) {
throw new UnsupportedOperationException(
"We don't currently support this -- See PDFTextStripper's processPages() for how " +
"to implement this.");
}
@Override
public void setEndBookmark(PDOutlineItem pdOutlineItem) {
throw new UnsupportedOperationException(
"We don't currently support this -- See PDFTextStripper's processPages() for how " +
"to implement this.");
}
@Override
protected void showGlyph(Matrix textRenderingMatrix, PDFont font, int code,
Vector displacement) throws IOException {
super.showGlyph(textRenderingMatrix, font, code, displacement);
String unicode = font.toUnicode(code);
if (unicode == null || unicode.isEmpty()) {
unmappedUnicodeCharsPerPage++;
totalUnmappedUnicodeCharacters++;
}
totalCharsPerPage++;
totalCharacters++;
if (font.isDamaged()) {
containsDamagedFont = true;
}
if (!font.isEmbedded()) {
containsNonEmbeddedFont = true;
}
}
private PDFileSpecification createFileSpecification(COSBase cosBase) {
try {
return PDFileSpecification.createFS(cosBase);
} catch (IOException e) {
//swallow for now
}
return null;
}
private static class NameSpecTuple {
private final String name;
private final PDComplexFileSpecification spec;
public NameSpecTuple(String name, PDComplexFileSpecification spec) {
this.name = name;
this.spec = spec;
}
public String getName() {
return name;
}
public PDComplexFileSpecification getSpec() {
return spec;
}
}
private enum PageText {
UNDECIDED, KEEP, OCR_WANTED
}
/** Notes whether any non-whitespace text passed through. */
private static class TextCounter extends ContentHandlerDecorator {
private boolean sawText;
TextCounter(ContentHandler handler) {
super(handler);
}
boolean sawText() {
return sawText;
}
@Override
public void characters(char[] ch, int start, int length) throws SAXException {
for (int i = start; !sawText && i < start + length; i++) {
sawText = !Character.isWhitespace(ch[i]);
}
super.characters(ch, start, length);
}
}
/**
* Sits under xhtml for AUTO with an engine. Records the SAX events between start() and
* stop() so the OCR verdict can replay or drop them; passes everything else through live.
*/
private static class PageTextBuffer implements ContentHandler {
private final ContentHandler delegate;
private List<SaxEvent> buffer;
private StringBuilder text;
PageTextBuffer(ContentHandler delegate) {
this.delegate = delegate;
}
void start() {
buffer = new ArrayList<>();
text = new StringBuilder();
}
/** Stops recording and returns what was recorded (empty if not recording). */
List<SaxEvent> stop() {
List<SaxEvent> recorded = buffer == null ? Collections.emptyList() : buffer;
buffer = null;
return recorded;
}
/** Plain text of the last recording. */
String text() {
return text == null ? "" : text.toString();
}
void replay(List<SaxEvent> events) throws SAXException {
for (SaxEvent e : events) {
e.replay(delegate);
}
}
interface SaxEvent {
void replay(ContentHandler h) throws SAXException;
}
@Override
public void startElement(String uri, String localName, String qName, Attributes atts)
throws SAXException {
if (buffer != null) {
AttributesImpl copy = new AttributesImpl(atts);
buffer.add(h -> h.startElement(uri, localName, qName, copy));
} else {
delegate.startElement(uri, localName, qName, atts);
}
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
if (buffer != null) {
buffer.add(h -> h.endElement(uri, localName, qName));
} else {
delegate.endElement(uri, localName, qName);
}
}
@Override
public void characters(char[] ch, int start, int length) throws SAXException {
if (buffer != null) {
char[] copy = Arrays.copyOfRange(ch, start, start + length);
text.append(copy);
buffer.add(h -> h.characters(copy, 0, copy.length));
} else {
delegate.characters(ch, start, length);
}
}
@Override
public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
if (buffer != null) {
char[] copy = Arrays.copyOfRange(ch, start, start + length);
buffer.add(h -> h.ignorableWhitespace(copy, 0, copy.length));
} else {
delegate.ignorableWhitespace(ch, start, length);
}
}
@Override
public void startPrefixMapping(String prefix, String uri) throws SAXException {
if (buffer != null) {
buffer.add(h -> h.startPrefixMapping(prefix, uri));
} else {
delegate.startPrefixMapping(prefix, uri);
}
}
@Override
public void endPrefixMapping(String prefix) throws SAXException {
if (buffer != null) {
buffer.add(h -> h.endPrefixMapping(prefix));
} else {
delegate.endPrefixMapping(prefix);
}
}
@Override
public void processingInstruction(String target, String data) throws SAXException {
if (buffer != null) {
buffer.add(h -> h.processingInstruction(target, data));
} else {
delegate.processingInstruction(target, data);
}
}
@Override
public void skippedEntity(String name) throws SAXException {
if (buffer != null) {
buffer.add(h -> h.skippedEntity(name));
} else {
delegate.skippedEntity(name);
}
}
@Override
public void setDocumentLocator(Locator locator) {
delegate.setDocumentLocator(locator);
}
@Override
public void startDocument() throws SAXException {
delegate.startDocument();
}
@Override
public void endDocument() throws SAXException {
delegate.endDocument();
}
}
enum ActionTrigger {
AFTER_DOCUMENT_PRINT, AFTER_DOCUMENT_SAVE, ANNOTATION_CURSOR_ENTERS, ANNOTATION_CURSOR_EXIT,
ANNOTATION_LOSE_INPUT_FOCUS, ANNOTATION_MOUSE_CLICK, ANNOTATION_MOUSE_RELEASED,
ANNOTATION_PAGE_CLOSED, ANNOTATION_PAGE_NO_LONGER_VISIBLE, ANNOTATION_PAGE_OPENED,
ANNOTATION_PAGE_VISIBLE, ANNOTATION_RECEIVES_FOCUS, ANNOTATION_WIDGET,
BEFORE_DOCUMENT_CLOSE, BEFORE_DOCUMENT_PRINT, BEFORE_DOCUMENT_SAVE, DOCUMENT_OPEN,
FORM_FIELD, FORM_FIELD_FORMATTED, FORM_FIELD_KEYSTROKE, FORM_FIELD_RECALCULATE,
FORM_FIELD_VALUE_CHANGE, PAGE_CLOSE, PAGE_OPEN, BOOKMARK,
}
}