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.pages.TextPolicy.AUTO;
import static org.apache.tika.parser.pages.TextPolicy.EXTRACT_AND_OCR;
import static org.apache.tika.parser.pages.TextPolicy.NONE;
import static org.apache.tika.parser.pages.TextPolicy.OCR;
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.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Deque;
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 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.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.text.PDFTextStripper;
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.Rendering;
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.inference.InputKind;
import org.apache.tika.parser.pages.PagesConfig;
import org.apache.tika.parser.pages.TextPolicy;
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.RenderResult;
import org.apache.tika.renderer.RenderResults;
import org.apache.tika.renderer.RenderingTracker;
import org.apache.tika.renderer.pdf.pdfbox.PDFBoxRenderer;
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;
// a transcript's word index is ~12k items; the budget bounds a hostile file, not a real one
private final static int MAX_BOOKMARK_ITEMS = 100000;
/** Deeper bookmarks are written as items of the deepest list, within the XML nesting limit. */
private final static int MAX_BOOKMARK_DEPTH = 50;
//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;
/** The effective "pages" block for this parse. */
final PagesConfig pages;
final PageEmitter emitter;
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, inference and emission alike. */
private RenderResults pageRender;
/**
* 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, PagesConfig pages,
PageEmitter emitter,
CompositeContentEnricher contentEnrichers) throws IOException {
this.pdDocument = pdDocument;
this.pages = pages;
this.emitter = emitter;
this.ocrImageMediaType =
MediaType.image(pages.getRender().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);
Parser annotators = null;
if (pages.getEmit().applies(metadata, context)) {
try (ContentEnrichers.Suspension recognizersOff =
ContentEnrichers.suspendRecognizers(context)) {
annotators = ContentEnrichers.get(contentEnrichers, ocrImageMediaType,
renderTarget, context);
}
}
this.pageAnnotators = annotators;
if (pages.getText() == 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.contentEnrichers = contentEnrichers;
embeddedDocumentExtractor = EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
this.hooks = context.get(ParseHooks.class);
boolean wantsPages = false;
if (hooks != null && pages.getInference().contains(InputKind.PAGES)) {
try {
wantsPages = hooks.wantsPages(ocrImageMediaType, context);
} catch (TikaException e) {
throw new IOException(e);
}
}
this.pagesForInference = wantsPages;
if (wantsPages) {
// the TEXT stage skips a document released as pages unless TEXT is listed too
for (InputKind input : pages.getInference()) {
metadata.add(TikaCoreProperties.INFERENCE_RELEASED, input.name());
}
}
}
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();
emitter.started(getCurrentPageNo());
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 = pages.getText() == 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) {
PagesConfig.Auto auto = pages.getOcr().getAuto();
if (totalCharsPerPage <= auto.getTotalCharsPerPage()) {
return true;
}
float percentUnmapped = (float) unmappedUnicodeCharsPerPage / totalCharsPerPage;
float limit = auto.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
Metadata sized = new Metadata();
if (pdEmbeddedFile.getSize() >= 0) {
sized.set(HttpHeaders.CONTENT_LENGTH, Long.toString(pdEmbeddedFile.getSize()));
}
TikaInputStream tis = TikaInputStream.get(pdEmbeddedFile::createInputStream,
new TemporaryResources(), sized);
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;
}
}
/**
* An engine's own failure on one page (a remote OCR service answering 401 or 500, a
* tesseract exit code): recorded like a timeout, the page keeps its text, the remaining
* pages still run, and the first failure surfaces once every page has been attempted.
*/
void handleCatchableEngineFailure(TikaException e) throws TikaException {
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, TextPolicy text)
throws IOException, TikaException, SAXException {
PageOcr result = dispatchOcr(pdPage, text);
currentPageOcr = result;
return result;
}
private PageOcr dispatchOcr(PDPage pdPage, TextPolicy text)
throws IOException, TikaException, SAXException {
if (text != AUTO && text != OCR && text != EXTRACT_AND_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 = pages.getOcr().getMaxPages();
if (maxPagesToOcr > 0 && c != null && c.getCount() > maxPagesToOcr) {
return PageOcr.SKIPPED;
}
if (ocrEngine == null) {
if ((text == OCR || text == EXTRACT_AND_OCR)
&& !ContentEnrichers.isDisabled(context)) {
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 \"pages\": {\"text\": \"EXTRACT\"}.");
}
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);
if (renderResult.getStatus() != RenderResult.STATUS.SUCCESS) {
return false;
}
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);
} finally {
carryOcrWarnings(renderMetadata);
}
// 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 (TikaException e) {
handleCatchableEngineFailure(e);
} catch (SAXException e) {
throw new IOException("error writing OCR content from PDF", e);
}
return false;
}
/** The recognizer's doubts about a page become the document's, each code once. */
private void carryOcrWarnings(Metadata renderMetadata) {
for (String warning : renderMetadata.getValues(TikaCoreProperties.OCR_WARNINGS)) {
if (!Arrays.asList(metadata.getValues(TikaCoreProperties.OCR_WARNINGS))
.contains(warning)) {
metadata.add(TikaCoreProperties.OCR_WARNINGS, warning);
}
}
}
/** The page's render, made on first use and closed at page end; a failure is not retried. */
private RenderResult currentPageRender(PDPage pdPage) throws IOException, TikaException {
if (pageRender == null) {
try {
pageRender = renderCurrentPage(pdPage);
} catch (IOException | TikaException | RuntimeException e) {
pageRender = skipped(getCurrentPageMetadata(pdPage));
// the consumer records why; the page is flagged once, here, whoever asked
metadata.add(Rendering.RENDER_FAILED_PAGE, getCurrentPageNo());
throw e;
}
}
return pageRender.getResults().get(0);
}
private RenderResults skipped(Metadata pageMetadata) {
RenderResults results = new RenderResults(new TemporaryResources());
results.add(new RenderResult(RenderResult.STATUS.EXCEPTION, nextRenderId(), null,
pageMetadata));
return results;
}
private void closePageRender() {
RenderResults render = pageRender;
pageRender = null;
try (render) {
// closing deletes the render file
} catch (IOException e) {
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
}
}
/**
* Emits the page's render as an embedded document when {@code pages.emit} says so; the OCR
* render is shared when it is the same image, else the page is rendered again for it.
*/
private void emitCurrentPage(PDPage pdPage) throws SAXException {
if (!emitter.wants(getCurrentPageNo())) {
return;
}
// an OCR-only strategy draws a different page than the one to emit
boolean shared = pages.emitsSameImage()
&& config.getRenderingStrategy() == OcrConfig.RenderingStrategy.ALL;
RenderResult render = null;
if (shared) {
if (!emitter.accepts(getCurrentPageNo())) {
return;
}
try {
render = currentPageRender(pdPage);
} catch (SecurityException e) {
throw e;
} catch (IOException | TikaException | RuntimeException e) {
// emission's own policy, whoever asked first: an unrenderable page is a warning
EmbeddedDocumentUtil.recordException(e, metadata, context);
return;
}
}
emitter.emit(getCurrentPageNo(), render, xhtml);
}
/** 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 RenderResults renderCurrentPage(PDPage pdPage) throws IOException, TikaException {
Metadata pageMetadata = getCurrentPageMetadata(pdPage);
double[] size = PDFBoxRenderer.pageSize(pdPage);
// too small to hold anything: a policy skip, not a failure
if (pages.getRender().belowMinimum(size[0], size[1])) {
return skipped(pageMetadata);
}
RenderResults results = emitter.render(getCurrentPageNo(), pages.getRender(),
config.getRenderingStrategy(), pageMetadata);
for (RenderResult result : results.getResults()) {
if (result.getStatus() != RenderResult.STATUS.SUCCESS) {
PDFParser.carryRenderWarnings(result, metadata);
}
}
return results;
}
private int nextRenderId() {
RenderingTracker renderingTracker = context.get(RenderingTracker.class);
if (renderingTracker == null) {
renderingTracker = new RenderingTracker();
context.set(RenderingTracker.class, renderingTracker);
}
return renderingTracker.getNextId();
}
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;
}
@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 {
try {
endPageText();
for (PDAnnotation annotation : page.getAnnotations()) {
processPageAnnotation(annotation);
}
if (pages.getText() == EXTRACT_AND_OCR) {
doOCROnCurrentPage(page, EXTRACT_AND_OCR);
} 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);
} catch (IOException e) {
// a page whose text or OCR failed is still emitted and closed
handleCatchableIOE(e);
}
emitCurrentPage(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() || pages.getText() == NONE) {
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 {
// NONE writes no text from any source: bookmarks, forms and annotations included
if (config.isExtractBookmarksText() && pages.getText() != NONE) {
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() && pages.getText() != NONE) {
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) {
extractBookmarkText(outline);
}
}
/**
* Walks the outline without recursion: a chain of items each the only child of the last
* can be as long as the item budget, and a hostile file makes it so. A list opens for the
* children of an item up to {@link #MAX_BOOKMARK_DEPTH}; deeper items join the deepest
* list. Cycles end where an item is met a second time.
*/
private void extractBookmarkText(PDOutlineNode outline)
throws SAXException, IOException, TikaException {
PDOutlineItem current = outline.getFirstChild();
if (current == null) {
return;
}
Set<COSObjectable> seen = new HashSet<>();
// the item whose children are being walked, per level; whether its level opened a list
Deque<PDOutlineItem> parents = new ArrayDeque<>();
Deque<Boolean> opened = new ArrayDeque<>();
xhtml.startElement("ul");
int lists = 1;
int items = 0;
boolean truncated = false;
while (true) {
if (items >= MAX_BOOKMARK_ITEMS && current != null && !seen.contains(current)) {
truncated = true;
current = null;
}
if (current == null || seen.contains(current)) {
if (parents.isEmpty()) {
break;
}
if (opened.pop()) {
xhtml.endElement("ul");
lists--;
}
current = parents.pop().getNextSibling();
continue;
}
seen.add(current);
items++;
xhtml.startElement("li");
xhtml.characters(current.getTitle());
xhtml.endElement("li");
handleDestinationOrAction(current.getAction(), ActionTrigger.BOOKMARK);
PDOutlineItem child = current.getFirstChild();
if (child != null && !seen.contains(child)) {
boolean open = lists < MAX_BOOKMARK_DEPTH;
if (open) {
xhtml.startElement("ul");
lists++;
}
parents.push(current);
opened.push(open);
current = child;
} else {
current = current.getNextSibling();
}
}
xhtml.endElement("ul");
if (truncated) {
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
"bookmark outline truncated at " + MAX_BOOKMARK_ITEMS + " items");
}
}
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 (TikaException e) {
//malformed xfa: record and 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,
}
}