diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0c008af..4dd43c2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,6 +42,7 @@ package/ PdfView.tsx public component (wrapper over the host component) PdfCache.ts public cache API: AbortSignal, PdfDownloadError PdfDocument.ts public document API: open (AbortSignal, PdfOpenError), close + PdfMethodError.ts methods' rejections: native `[code] message` → PdfMethodError source.ts PdfSource union (or a list) → native [{ uri, base64, headers, cache options, password }] nativeSource.ts base64 text → native-owned ArrayBuffer (once per distinct text) test/base64/ the C++ and Swift base64 decoders against shared cases, and each other @@ -56,6 +57,7 @@ package/ DocumentSetLoader.swift the view's sources in parallel; several assembled into one PDFDocument DisplayedDocument.swift the displayed document and its sources (files, passwords, first pages) PdfLoadFailure.swift Swift error → PdfError + PdfMethodFailure.swift a method's rejection as `[code] message` DocumentCache.swift fresh copy or shared download/revalidation, stale fallback DocumentCacheStore.swift copies on disk: session/persistent dirs, metadata, pins SharedDownload.swift one URLSession request per copy, conditional with a copy @@ -93,6 +95,7 @@ package/ DocumentReads.kt outline, metadata, page sizes, page images on duplicated FDs (view session and document) PageImageRequest.kt renderPage validation and file write (view and document) + PdfMethodException.kt a method's rejection as `[code] message` outline/ bounded PDF parser: outline, page tree, /Info (PdfDate) android/src/main/cpp/ cpp-adapter.cpp JNI_OnLoad: Nitro's natives, then NativeBase64Document's and NativePageCoverage's diff --git a/README.md b/README.md index 9fbeb4b..7082a5e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ PDFKit on iOS, the platform `PdfRenderer` on Android: no bundled PDFium, no WebV - **Forms** — AcroForm fields filled in by touch or from code, read back by name and exported as a filled-in PDF. - **Page overlays** — React views attached to points of a page, moved natively while it scrolls and zooms. - **Document APIs** — outline, page sizes, metadata, page images and printing, with or without a view. -- **Password protection, typed errors, accessibility** — stable error codes identical on both platforms; VoiceOver and TalkBack read each page. +- **Password protection, typed errors, accessibility** — stable error codes identical on both platforms, for load failures and method calls alike; VoiceOver and TalkBack read each page. ## Requirements diff --git a/docs/api-design.md b/docs/api-design.md index 652a37f..4966d2a 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -266,6 +266,40 @@ type PdfErrorCode = `error.nativeDescription` carries the raw platform message for logs. +The ref's promise methods and `PdfDocument`'s reads reject with a +`PdfMethodError` and codes of their own: + +```ts +type PdfMethodErrorCode = + | 'not-loaded' // before onLoad, while a new source loads + | 'closed' // closed or replaced while the call waited + | 'invalid-argument' // page, width, form value, export path + | 'not-supported' // several sources, a platform limit + | 'not-permitted' // printing forbidden, read-only field + | 'busy' // a print dialog is open + | 'io-error' + | 'invalid-pdf' // outline, metadata or form not parsable + | 'out-of-memory' + | 'unknown'; // see nativeDescription +``` + +- **A union of its own.** Adding these to `PdfErrorCode` would break apps + that switch exhaustively over `onError`'s codes. +- **A coded message, not result structs.** Nitro rejections carry only a + message. `open` and `download` resolve a result struct instead, but that + would have changed 11 signatures and every future method. Native code + rejects with `[code] message` (`PdfMethodFailure` in Swift, + `PdfMethodException` in Kotlin), and one JS function parses the first + line: Android puts the exception's class before it (renamed in minified + release builds) and the stack trace after it. A rejection without a + known code is `unknown`, the platform's text in `nativeDescription`. +- **Platform errors get a code by type**: file system errors are + `io-error` on both platforms, `OutOfMemoryError` `out-of-memory`, a parse + failure `invalid-pdf`. +- In 1.1 these methods rejected with plain `Error`s: on Android the message + was the exception's class, message and stack trace, and the same failure + read differently on each platform, so apps could only match on text. + ## What we omit from react-native-pdf, and why | react-native-pdf | Decision | Reason | diff --git a/docs/api.md b/docs/api.md index f25544f..b080992 100644 --- a/docs/api.md +++ b/docs/api.md @@ -75,6 +75,9 @@ each page. The viewer adds no safe-area insets on either platform, like | `search(query)` / `searchNext()` / `searchPrevious()` / `clearSearch()` | Full-text search. `search` is ignored before `onLoad` | | `getStats()` | Render and cache statistics, for diagnostics | +The promise methods reject with a `PdfMethodError`; see +[Method errors](#method-errors). + `goToPage`, `zoomTo` and `goToViewport` ignore `NaN` and `±Infinity`, e.g. `goToPage(Number(text) - 1)` while a page field holds a letter. A fractional page is rounded to the nearest page on both platforms: `goToPage(1.6)` opens @@ -115,6 +118,7 @@ pdf.current?.goToViewport(saved); // a position from onViewportChan | `PdfCache` | Stored downloads: `download()`, `getEntry()`, `remove()`, `getByteSize()`, `clear()` | | `PdfOpenError` | Rejection of `PdfDocument.open`, with the view's error `code` | | `PdfDownloadError` | Rejection of `PdfCache.download`, with a `code` and `httpStatus` | +| `PdfMethodError` | Rejection of the ref's promise methods and `PdfDocument`'s reads, with a `code` | ## Errors @@ -145,6 +149,29 @@ open and the other pages keep working: `error.page !== undefined` tells them apart (it narrows the type to `PdfPageError`). iOS reports load failures only (PDFKit draws the pages). +### Method errors + +The ref's promise methods (`getOutline`, `getPageSizes`, `renderPage`, +`print`, `getFormFields`, `setFormFieldValues`, `exportDocument`) and +`PdfDocument`'s reads reject with a `PdfMethodError`. Its `code` +(`PdfMethodErrorCode`) is stable and identical on both platforms: + +| Code | When | +| --- | --- | +| `not-loaded` | Before `onLoad`, also while a new `source` loads; before the view mounts | +| `closed` | `PdfDocument` after `close()`; the view closed or its `source` changed while the call waited for its turn (a call already running finishes) | +| `invalid-argument` | `renderPage`: a page outside the document, a `width` outside 1–4096 px, an image over 4096 × 4096 px. `setFormFieldValues`: an unknown field, a value its type does not take, text over its maximum length, an option it does not have. `exportDocument`: a `filePath` that is a directory, the displayed file or in the download cache | +| `not-supported` | Several sources (`print`, the form methods, `exportDocument`); a device that cannot print (iOS); filling forms on Android below 15 without SDK extension 13; selecting several options of a list box on iOS; clearing a radio group that cannot be turned off on Android; the outline and metadata of encrypted documents on Android | +| `not-permitted` | The document does not permit printing (iOS); a read-only form field | +| `busy` | A print dialog of the view is open; on Android, the app is not in the foreground | +| `io-error` | A file could not be read or written: a copy of the document, an export, a page image. The print job failed | +| `invalid-pdf` | The outline, metadata or form could not be parsed | +| `out-of-memory` | Out of memory, e.g. while rendering a page image | +| `unknown` | Anything else; see `nativeDescription` | + +`error.message` is one sentence and may change; `error.nativeDescription` +is the platform's text (on Android with the stack trace), for logging. + ## Types | Type | Description | @@ -155,6 +182,7 @@ open and the other pages keep working: | `PdfLoadedSource` | One source as `onLoad` reports it: `{ pageCount, firstPage, filePath?, isStale }` | | `PdfError` / `PdfErrorCode` | Errors and their stable codes | | `PdfLoadError` / `PdfPageError` | `onError`'s argument: a load failure, or a page that could not be rendered after `onLoad` (Android, with `page`) | +| `PdfMethodErrorCode` | The stable codes of `PdfMethodError`, see [Method errors](#method-errors) | | `PdfViewport` | A reading position: `{ page, x, y, scale }` | | `PdfFormField` | A form field, discriminated by `type` (`PdfTextFormField`, `PdfCheckboxFormField`, …) | | `PdfOutlineItem` | An outline entry: `{ title, page?, children }` | diff --git a/docs/guides/forms.md b/docs/guides/forms.md index e5bf3de..2a7ac7e 100644 --- a/docs/guides/forms.md +++ b/docs/guides/forms.md @@ -27,7 +27,7 @@ const { filePath } = await pdf.current!.exportDocument(); // share or upload it over the form field (kept above the keyboard) and a list to pick choices from. `enableFormFilling={false}` leaves them as they are. - A view showing several sources has no form to fill in: fields are drawn - but not editable, and the form methods reject (see + but not editable, and the form methods reject with `not-supported` (see [Several documents as one](sources-and-loading.md#several-documents-as-one)). - `onFormChange` reports the reader's changes only — at once for a toggle or choice, when editing ends for text, never per keystroke — not @@ -37,14 +37,14 @@ const { filePath } = await pdf.current!.exportDocument(); // share or upload it coordinates as `onPageTap` — e.g. to place a `PdfPageOverlay` on a signature field. Choices are their displayed labels. - `setFormFieldValues()` sets fields by name, all or none: it rejects, - changing nothing, for an unknown or read-only field or a value its type - does not take. + changing nothing, for an unknown field or a value its type does not take + (`invalid-argument`) and for a read-only field (`not-permitted`). - `exportDocument()` writes the document as shown to a new file (the cache directory by default, or `{ filePath }`, which must name a file, not a directory), keeping its password protection, off the main thread; the source file is never changed. A remote document's `onLoad` `filePath` is the library's downloaded copy: exporting over it, or - anywhere else in that cache, is rejected. + anywhere else in that cache, is rejected (`invalid-argument`). Printing and `renderPage` show the form as filled in too. - Password protection of an export: the password that opened the document opens the export, with the same permissions (printing, copying, form diff --git a/docs/guides/page-images-and-documents.md b/docs/guides/page-images-and-documents.md index 82de5ff..4fb4609 100644 --- a/docs/guides/page-images-and-documents.md +++ b/docs/guides/page-images-and-documents.md @@ -68,4 +68,5 @@ try { once renders them one at a time. - **Call `close()`** when done: an open document keeps its file open until then (or until the JS garbage collector releases it, which can take - long). Calls after `close()` reject. + long). Calls after `close()` reject with a `PdfMethodError` whose `code` + is `closed`. diff --git a/docs/guides/printing.md b/docs/guides/printing.md index 5c98db3..bb8599e 100644 --- a/docs/guides/printing.md +++ b/docs/guides/printing.md @@ -14,14 +14,15 @@ if (result?.isSubmitted) showToast('Sent to the printer'); Works for `content://` sources, which have no file path. - `jobName` names the job in the print queue and the file when saving as PDF; by default the source's file name, or `'Document'`. -- Rejects before `onLoad`, while the view's print dialog is open, when the - view closes or its `source` changes before the dialog opens (a filled-in - form, and on iOS a protected document, is prepared first: seconds when - the document is long), - when the app is not in the foreground, and on iOS when the device cannot - print or the document's permissions forbid printing (Android has no API - to read them and prints anyway). Also rejects for a view showing several - sources (see +- Rejects with a `PdfMethodError` before `onLoad` (`not-loaded`), while + the view's print dialog is open or, on Android, when the app is not in + the foreground (`busy`), when the view closes or its `source` changes + before the dialog opens (`closed`; a filled-in form, and on iOS a + protected document, is prepared first: seconds when the document is + long), and on iOS when the device cannot print (`not-supported`) or the + document's permissions forbid printing (`not-permitted`; Android has no + API to read them and prints anyway). Also rejects for a view showing + several sources (`not-supported`, see [Several documents as one](sources-and-loading.md#several-documents-as-one)). On iPad the dialog opens as a popover from the view. - Printing a file the app already has needs no viewer: `onLoad`'s diff --git a/docs/guides/sources-and-loading.md b/docs/guides/sources-and-loading.md index ff86257..aa13543 100644 --- a/docs/guides/sources-and-loading.md +++ b/docs/guides/sources-and-loading.md @@ -133,7 +133,8 @@ smaller than its base64 form. shows none of them and `onError` reports `error.sourceIndex`. An empty list is an `invalid-source` error. `onLoadProgress` sums the downloads. - **Reading only:** forms cannot be filled in, and `print`, - `exportDocument`, `getFormFields` and `setFormFieldValues` reject. + `exportDocument`, `getFormFields` and `setFormFieldValues` reject with + `not-supported`. Printing and exporting need one file, and Android has no API that merges PDF files. - Links within a source lead to its pages, and "next page" / "previous diff --git a/docs/limitations.md b/docs/limitations.md index 63636a3..efc831d 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -33,8 +33,8 @@ For an overview of what each platform supports, see the that page, copying ends the selection (as in a text field), dragging a handle to the edge does not scroll, and a handle dragged past the other end stops at the first character. -- **Outline (`getOutline`)**: on Android, encrypted documents reject (their - titles are encrypted). iOS (PDFKit) leaves PDF 1.1 named destinations +- **Outline (`getOutline`)**: on Android, encrypted documents reject with + `not-supported` (their titles are encrypted). iOS (PDFKit) leaves PDF 1.1 named destinations (`/Dest /name`) without a page. - **Metadata (`PdfDocument.getMetadata`)**: on Android, encrypted documents reject, like the outline. XMP metadata is not read, only the information diff --git a/example/describeError.ts b/example/describeError.ts new file mode 100644 index 0000000..2321d5b --- /dev/null +++ b/example/describeError.ts @@ -0,0 +1,8 @@ +import { PdfMethodError, PdfOpenError } from 'react-native-better-pdf'; + +/** A rejection for the event line: an open error's code, a method error's code and message. */ +export function describeError(error: unknown): string { + if (error instanceof PdfOpenError) return error.code; + if (error instanceof PdfMethodError) return `${error.code}: ${error.message}`; + return String(error); +} diff --git a/example/e2e/pdf-document.yaml b/example/e2e/pdf-document.yaml index 89c1126..92b01c2 100644 --- a/example/e2e/pdf-document.yaml +++ b/example/e2e/pdf-document.yaml @@ -30,7 +30,7 @@ appId: software.gmi.betterpdf.example - tapOn: "Thumbnails" - extendedWaitUntil: { visible: "thumbnails: 2 in .* ms", timeout: 10000 } - tapOn: "Close" -- extendedWaitUntil: { visible: "after close: The document is closed", timeout: 5000 } +- extendedWaitUntil: { visible: "after close: closed: The document is closed", timeout: 5000 } # Downloaded like a PdfView source. - tapOn: "metadata \\(remote\\)" @@ -61,7 +61,7 @@ appId: software.gmi.betterpdf.example - runFlow: when: { platform: Android } commands: - - assertVisible: "metadata failed: .*Encrypted documents are not supported.*" + - assertVisible: "metadata failed: not-supported: .*" - assertVisible: "sizes: 595x842 595x842" - assertVisible: "cover: 300x425" diff --git a/example/e2e/stress-close-during-work.yaml b/example/e2e/stress-close-during-work.yaml index da0cf18..254b4f7 100644 --- a/example/e2e/stress-close-during-work.yaml +++ b/example/e2e/stress-close-during-work.yaml @@ -56,7 +56,7 @@ appId: software.gmi.betterpdf.example - tapOn: "Thumbnails" - tapOn: "Close" # Below the bottom edge on Android's screen. - - scrollUntilVisible: { element: "after close: The document is closed", timeout: 30000 } + - scrollUntilVisible: { element: "after close: closed: The document is closed", timeout: 30000 } # Still alive. - tapOn: "Viewer" - runFlow: { file: _open-list-entry.yaml, env: { ENTRY: "Bundled sample.*" } } diff --git a/example/e2e/viewer-forms.yaml b/example/e2e/viewer-forms.yaml index 7958473..f1bf51f 100644 --- a/example/e2e/viewer-forms.yaml +++ b/example/e2e/viewer-forms.yaml @@ -27,7 +27,7 @@ env: # Rejected while the form is as loaded, when iOS exports a copy of the file: # an export into a directory used to delete it with everything in it. - tapOn: "Bad" -- extendedWaitUntil: { visible: "bad: .*read-only.* \\| .*cannot replace the displayed.* \\| .*export path is a directory.*; keep.txt kept", timeout: 10000 } +- extendedWaitUntil: { visible: "bad: not-permitted: .* \\| invalid-argument: .* \\| invalid-argument: .*; keep.txt kept", timeout: 10000 } # By touch: text is reported once, when editing ends; toggles at once. - tapOn: { point: "${NAME}" } @@ -85,7 +85,7 @@ env: visible: "fields 13: name=Ewa email=jan@example.com notes=First line/Second line agree=on plan=pro country=France colours=Blue id=A-1234 phone= address.city=Kraków postcode=00-950 date= signature=unsigned" timeout: 5000 - tapOn: "Bad" -- extendedWaitUntil: { visible: "bad: .*read-only.* \\| .*cannot replace the displayed.* \\| .*export path is a directory.*; keep.txt kept", timeout: 10000 } +- extendedWaitUntil: { visible: "bad: not-permitted: .* \\| invalid-argument: .* \\| invalid-argument: .*; keep.txt kept", timeout: 10000 } - tapOn: "Fields" - extendedWaitUntil: { visible: "fields 13: name=Ewa .*", timeout: 5000 } @@ -122,7 +122,7 @@ env: - tapOn: "Fill" - extendedWaitUntil: { visible: "fill: done", timeout: 5000 } - tapOn: "Bad" -- extendedWaitUntil: { visible: "bad: .*read-only.* \\| .*cannot replace the displayed.* \\| .*export path is a directory.* \\| .*in the cache of downloaded documents.*; keep.txt kept", timeout: 10000 } +- extendedWaitUntil: { visible: "bad: not-permitted: .* \\| invalid-argument: .* \\| invalid-argument: .* \\| invalid-argument: .*; keep.txt kept", timeout: 10000 } - tapOn: "‹ Back" - runFlow: { file: _open-list-entry.yaml, env: { ENTRY: "Remote form(, .*)?" } } - extendedWaitUntil: { visible: "1 / 2", timeout: 15000 } diff --git a/example/e2e/viewer-sources.yaml b/example/e2e/viewer-sources.yaml index 3277cec..0259356 100644 --- a/example/e2e/viewer-sources.yaml +++ b/example/e2e/viewer-sources.yaml @@ -60,7 +60,7 @@ appId: software.gmi.betterpdf.example # Viewing only: printing a set is rejected. - tapOn: "Print" -- extendedWaitUntil: { visible: "print: .*several sources.*", timeout: 5000 } +- extendedWaitUntil: { visible: "print: not-supported: Printing is not available with several sources", timeout: 5000 } - tapOn: "‹ Back" # All or nothing: a failing source fails the load and is named. diff --git a/example/screens/DocumentsScreen.tsx b/example/screens/DocumentsScreen.tsx index 3985726..b4535ae 100644 --- a/example/screens/DocumentsScreen.tsx +++ b/example/screens/DocumentsScreen.tsx @@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from 'react'; import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; import { PdfDocument, - PdfOpenError, type PdfDocumentMetadata, type PdfOutlineItem, type PdfPageImage, type PdfSource, } from 'react-native-better-pdf'; +import { describeError } from '../describeError'; import { ensureFixture, FIXTURE_SERVER, type FixtureName } from '../documents'; import { Button, colors } from '../ui'; @@ -50,13 +50,6 @@ const TARGETS: Target[] = [ const MAX_THUMBNAILS = 12; -/** An open error's code, else the message's first line (on Android the stack follows). */ -function describeError(error: unknown): string { - if (error instanceof PdfOpenError) return error.code; - const message = error instanceof Error ? error.message : String(error); - return message.split('\n')[0] ?? message; -} - const date = (ms: number | undefined) => ms === undefined ? '-' : new Date(ms).toISOString(); diff --git a/example/screens/FormBar.tsx b/example/screens/FormBar.tsx index 9311129..1061ff8 100644 --- a/example/screens/FormBar.tsx +++ b/example/screens/FormBar.tsx @@ -2,6 +2,7 @@ import { Directory, File, Paths } from 'expo-file-system'; import { useState, type RefObject } from 'react'; import { StyleSheet, View } from 'react-native'; import type { PdfFormField, PdfViewRef } from 'react-native-better-pdf'; +import { describeError } from '../describeError'; import { Button } from '../ui'; /** @@ -34,8 +35,7 @@ export function FormBar({ }) { const [exported, setExported] = useState(null); const fail = (what: string) => (error: unknown) => - // Android's rejections carry the Java stack trace: first line only. - onEvent(`${what}: ${String(error).split('\n')[0]}`); + onEvent(`${what}: ${describeError(error)}`); return (