/* * Copyright (C) 2024 The Android Open Source Project * * Licensed 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 android.graphics.pdf; import static android.graphics.pdf.PdfLinearizationTypes.PDF_DOCUMENT_TYPE_LINEARIZED; import static android.graphics.pdf.PdfLinearizationTypes.PDF_DOCUMENT_TYPE_NON_LINEARIZED; import android.annotation.FlaggedApi; import android.annotation.NonNull; import android.annotation.Nullable; import android.graphics.Bitmap; import android.graphics.Matrix; import android.graphics.Point; import android.graphics.Rect; import android.graphics.pdf.content.PdfPageGotoLinkContent; import android.graphics.pdf.content.PdfPageImageContent; import android.graphics.pdf.content.PdfPageLinkContent; import android.graphics.pdf.content.PdfPageTextContent; import android.graphics.pdf.flags.Flags; import android.graphics.pdf.models.FormEditRecord; import android.graphics.pdf.models.FormWidgetInfo; import android.graphics.pdf.models.PageMatchBounds; import android.graphics.pdf.models.jni.LoadPdfResult; import android.graphics.pdf.models.selection.PageSelection; import android.graphics.pdf.models.selection.SelectionBoundary; import android.graphics.pdf.utils.Preconditions; import android.os.ParcelFileDescriptor; import android.system.ErrnoException; import android.system.Os; import android.system.OsConstants; import android.util.Log; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; /** * Represents a PDF document processing class. * * @hide */ public class PdfProcessor { /** Represents a PDF without form fields */ public static final int PDF_FORM_TYPE_NONE = 0; /** Represents a PDF with form fields specified using the AcroForm spec */ public static final int PDF_FORM_TYPE_ACRO_FORM = 1; /** Represents a PDF with form fields specified using the entire XFA spec */ public static final int PDF_FORM_TYPE_XFA_FULL = 2; /** Represents a PDF with form fields specified using the XFAF subset of the XFA spec */ public static final int PDF_FORM_TYPE_XFA_FOREGROUND = 3; private static final String TAG = "PdfProcessor"; private static final Object sPdfiumLock = new Object(); private PdfDocumentProxy mPdfDocument; public PdfProcessor() { PdfDocumentProxy.loadLibPdf(); } /** * Creates an instance of {@link PdfDocumentProxy} on successful loading of the PDF document. * This method ensures that an older {@link PdfDocumentProxy} instance is closed and then loads * the new document. This method should be run on a {@link android.annotation.WorkerThread} as * it is long-running task. * * @param fileDescriptor {@link ParcelFileDescriptor} for the input PDF document. * @param params instance of {@link LoadParams} which includes the password as well. * @throws IOException if an error occurred during the processing of the PDF document. * @throws SecurityException if the password is incorrect. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public void create(ParcelFileDescriptor fileDescriptor, @Nullable LoadParams params) throws IOException { Preconditions.checkNotNull(fileDescriptor, "Input FD cannot be null"); ensurePdfDestroyed(); try { Os.lseek(fileDescriptor.getFileDescriptor(), 0, OsConstants.SEEK_SET); } catch (ErrnoException ee) { throw new IllegalArgumentException("File descriptor not seekable"); } String password = (params != null) ? params.getPassword() : null; synchronized (sPdfiumLock) { LoadPdfResult result = PdfDocumentProxy.createFromFd(fileDescriptor.detachFd(), password); switch (result.status) { case NEED_MORE_DATA, PDF_ERROR, FILE_ERROR -> throw new IOException("Unable to load the document!"); case REQUIRES_PASSWORD -> throw new SecurityException("Password required to access document"); case LOADED -> this.mPdfDocument = result.pdfDocument; default -> throw new RuntimeException("Unexpected error has occurred!"); } } } /** Returns the number of pages in the PDF document */ public int getNumPages() { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getNumPages(); } } /** * Returns the {@link List} of {@link PdfPageTextContent} for the page number specified. In case * of the multiple column textual content, the order is not guaranteed and the text is returned * as it is seen by the processing library. * * @param pageNum page number of the document * @return list of the textual content encountered on the page. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public List getPageTextContents(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); PdfPageTextContent content = new PdfPageTextContent(mPdfDocument.getPageText(pageNum)); return List.of(content); } } /** * Returns the alternate text for each image encountered on the specified page as a * {@link List} of {@link PdfPageImageContent}. The primary use case of this method is for * accessibility. * * @param pageNum page number of the document * @return list of the alt text for each image on the page. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public List getPageImageContents(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getPageAltText(pageNum).stream() .map(PdfPageImageContent::new) .collect(Collectors.toList()); } } /** * Returns the width of the given page of the PDF document. It is not guaranteed that all the * pages of the document will have the same dimensions */ public int getPageWidth(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getPageWidth(pageNum); } } /** * Returns the height of the given page of the PDF document. It is not guaranteed that all the * pages of the document will have the same dimensions */ public int getPageHeight(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getPageHeight(pageNum); } } /** * Renders a page to a bitmap for the specified page number. * *

Should be invoked on the {@link android.annotation.WorkerThread} as it is a long-running * task. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public void renderPage( int pageNum, Bitmap bitmap, Rect destClip, Matrix transform, RenderParams params) { Preconditions.checkNotNull(bitmap, "Destination bitmap cannot be null"); Preconditions.checkNotNull(params, "RenderParams cannot be null"); Preconditions.checkArgument(bitmap.getConfig() == Bitmap.Config.ARGB_8888, "Unsupported pixel format"); Preconditions.checkArgument(transform == null || transform.isAffine(), "Transform not affine"); int renderMode = params.getRenderMode(); Preconditions.checkArgument(renderMode == RenderParams.RENDER_MODE_FOR_DISPLAY || renderMode == RenderParams.RENDER_MODE_FOR_PRINT, "Unsupported render mode"); Preconditions.checkArgument(clipInBitmap(destClip, bitmap), "destClip not in bounds"); final int contentLeft = (destClip != null) ? destClip.left : 0; final int contentTop = (destClip != null) ? destClip.top : 0; final int contentRight = (destClip != null) ? destClip.right : bitmap.getWidth(); final int contentBottom = (destClip != null) ? destClip.bottom : bitmap.getHeight(); // If transform is not set, stretch page to whole clipped area if (transform == null) { int clipWidth = contentRight - contentLeft; int clipHeight = contentBottom - contentTop; transform = new Matrix(); transform.postScale((float) clipWidth / getPageWidth(pageNum), (float) clipHeight / getPageHeight(pageNum)); transform.postTranslate(contentLeft, contentTop); } float[] transformArr = new float[9]; transform.getValues(transformArr); synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); mPdfDocument.render( pageNum, bitmap, contentLeft, contentTop, contentRight, contentBottom, transformArr, renderMode, params.areAnnotationsDisabled()); } } /** * Searches the specified page with the specified query. Should be run on the * {@link android.annotation.WorkerThread} as it is a long-running task. * * @param pageNum page number of the document * @param query the search query * @return list of {@link PageMatchBounds} that represents the highlighters which can span * multiple * lines as well. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public List searchPageText(int pageNum, String query) { Preconditions.checkNotNull(query, "Search query cannot be null"); synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.searchPageText(pageNum, query).unflattenToList(); } } /** * Return a PageSelection which represents the selected content that spans between the * two boundaries, both of which can be either exactly defined with text indexes, or * approximately defined with points on the page.The resulting Selection will also be * exactly defined with both indexes and points.If the start and stop boundary are both * the same point, selects the word at that point. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public PageSelection selectPageText(int pageNum, SelectionBoundary start, SelectionBoundary stop) { Preconditions.checkNotNull(start, "Start selection boundary cannot be null"); Preconditions.checkNotNull(stop, "Stop selection boundary cannot be null"); synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); android.graphics.pdf.models.jni.PageSelection legacyPageSelection = mPdfDocument.selectPageText( pageNum, android.graphics.pdf.models.jni.SelectionBoundary.convert(start), android.graphics.pdf.models.jni.SelectionBoundary.convert(stop)); if (legacyPageSelection != null) { return legacyPageSelection.convert(); } return null; } } /** Get the bounds and URLs of all the links on the given page. */ @FlaggedApi(Flags.FLAG_ENABLE_PDF_VIEWER) public List getPageLinkContents(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getPageLinks(pageNum).unflattenToList(); } } /** Returns bookmarks and other goto links (within the current document) on a page */ public List getPageGotoLinks(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getPageGotoLinks(pageNum); } } /** Retains object in memory related to a page when that page becomes visible. */ public void retainPage(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); mPdfDocument.retainPage(pageNum); } } /** Releases object in memory related to a page when that page is no longer visible. */ public void releasePage(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); mPdfDocument.releasePage(pageNum); } } /** * Returns the linearization flag on the PDF document. */ @PdfLinearizationTypes.PdfLinearizationType public int getDocumentLinearizationType() { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.isPdfLinearized() ? PDF_DOCUMENT_TYPE_LINEARIZED : PDF_DOCUMENT_TYPE_NON_LINEARIZED; } } /** * Returns the form type of the loaded PDF * * @throws IllegalArgumentException if an unrecognized PDF form type is returned */ public int getPdfFormType() { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); int pdfFormType = mPdfDocument.getFormType(); return switch (pdfFormType) { case PDF_FORM_TYPE_ACRO_FORM, PDF_FORM_TYPE_XFA_FULL, PDF_FORM_TYPE_XFA_FOREGROUND -> pdfFormType; default -> PDF_FORM_TYPE_NONE; }; } } /** Returns true if this PDF prefers to be scaled for printing. */ public boolean scaleForPrinting() { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.scaleForPrinting(); } } /** * Returns information about all form widgets on the page, or an empty list if there are no form * widgets on the page. * *

Optionally restricted by {@code types}. If {@code types} is empty, all form widgets on the * page will be returned. */ @NonNull public List getFormWidgetInfos( int pageNum, @NonNull @FormWidgetInfo.WidgetType int[] types) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); return mPdfDocument.getFormWidgetInfos(pageNum, types); } } /** * Returns information about the widget with {@code annotationIndex}. * *

{@code annotationIndex} refers to the index of the annotation within the page's "Annot" * array in the PDF document. This info is available on results of previous calls via {@link * FormWidgetInfo#getWidgetIndex()}. */ @NonNull FormWidgetInfo getFormWidgetInfoAtIndex(int pageNum, int annotationIndex) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); FormWidgetInfo result = mPdfDocument.getFormWidgetInfo(pageNum, annotationIndex); if (result == null) { throw new IllegalArgumentException("No widget found at this index."); } return result; } } /** Returns information about the widget at the given point. */ @NonNull public FormWidgetInfo getFormWidgetInfoAtPosition(int pageNum, int x, int y) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); FormWidgetInfo result = mPdfDocument.getFormWidgetInfo(pageNum, x, y); if (result == null) { throw new IllegalArgumentException("No widget found at point."); } return result; } } /** * Applies a {@link FormEditRecord} to the PDF. * * @return a list of rectangular areas invalidated by form widget operation *

For click type {@link FormEditRecord}s, performs a click on {@link * FormEditRecord#getClickPoint()} *

For set text type {@link FormEditRecord}s, sets the text value of the form widget. *

For set indices type {@link FormEditRecord}s, sets the {@link * FormEditRecord#getSelectedIndices()} as selected and all others as unselected for the * form widget indicated by the record. */ @NonNull public List applyEdit(int pageNum, @NonNull FormEditRecord editRecord) { Preconditions.checkNotNull(editRecord, "Edit record cannot be null"); Preconditions.checkArgument(pageNum >= 0, "Invalid page number"); if (editRecord.getType() == FormEditRecord.EDIT_TYPE_CLICK) { return applyEditTypeClick(pageNum, editRecord); } else if (editRecord.getType() == FormEditRecord.EDIT_TYPE_SET_INDICES) { return applyEditTypeSetIndices(pageNum, editRecord); } else if (editRecord.getType() == FormEditRecord.EDIT_TYPE_SET_TEXT) { return applyEditSetText(pageNum, editRecord); } return Collections.emptyList(); } @FlaggedApi(Flags.FLAG_ENABLE_FORM_FILLING) private List applyEditTypeClick(int pageNum, @NonNull FormEditRecord editRecord) { Preconditions.checkNotNull(editRecord.getClickPoint(), "Can't apply click edit record without point"); Point clickPoint = editRecord.getClickPoint(); synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); List results = mPdfDocument.clickOnPage(pageNum, clickPoint.x, clickPoint.y); if (results == null) { throw new IllegalArgumentException("Cannot click on this widget."); } return results; } } @FlaggedApi(Flags.FLAG_ENABLE_FORM_FILLING) private List applyEditTypeSetIndices(int pageNum, @NonNull FormEditRecord editRecord) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); int[] selectedIndices = editRecord.getSelectedIndices(); List results = mPdfDocument.setFormFieldSelectedIndices( pageNum, editRecord.getWidgetIndex(), selectedIndices); if (results == null) { throw new IllegalArgumentException("Cannot set selected indices on this widget."); } return results; } } @FlaggedApi(Flags.FLAG_ENABLE_FORM_FILLING) private List applyEditSetText(int pageNum, @NonNull FormEditRecord editRecord) { Preconditions.checkNotNull(editRecord.getText(), "Can't apply set text record without text"); String text = editRecord.getText(); synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); List results = mPdfDocument.setFormFieldText(pageNum, editRecord.getWidgetIndex(), text); if (results == null) { throw new IllegalArgumentException("Cannot set form field text on this widget."); } return results; } } /** Ensures that any previous {@link PdfDocumentProxy} instance is closed. */ public void ensurePdfDestroyed() { synchronized (sPdfiumLock) { if (mPdfDocument != null) { try { mPdfDocument.destroy(); } catch (Throwable t) { Log.e(TAG, "Error closing PdfDocumentProxy", t); } finally { mPdfDocument = null; } } } } /** * Saves the current state of the loaded PDF document to the given writable * ParcelFileDescriptor. */ public void write(ParcelFileDescriptor destination, boolean removePasswordProtection) { Preconditions.checkNotNull(destination, "Destination FD cannot be null"); if (removePasswordProtection) { cloneWithoutSecurity(destination); } else { saveAs(destination); } } /** * Creates a copy of the current document without security, if it is password protected. This * may be necessary for the PrintManager which can't handle password-protected files. * * @param destination points to where pdfclient should make a copy of the pdf without security. */ private void cloneWithoutSecurity(ParcelFileDescriptor destination) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); mPdfDocument.cloneWithoutSecurity(destination); } } /** * Saves the current document to the given {@link ParcelFileDescriptor}. * * @param destination where the currently open PDF should be written. */ private void saveAs(ParcelFileDescriptor destination) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); mPdfDocument.saveAs(destination); } } private boolean clipInBitmap(@Nullable Rect clip, Bitmap destination) { if (clip == null) { return true; } return clip.left >= 0 && clip.top >= 0 && clip.right <= destination.getWidth() && clip.bottom <= destination.getHeight(); } private void assertPdfDocumentNotNull() { Preconditions.checkNotNull(mPdfDocument, "PdfDocumentProxy cannot be null"); } }