From 6f63f98717aab80d4f9575b74fc72fecac5dc141 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:37 +0200 Subject: [PATCH 01/30] docs: add comparative PDF workflow research reference (#2940) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- ROADMAP.md | 1 + docs/research/pdf-workflows/README.md | 73 +++ .../pdf-workflows/competitive-research.md | 211 +++++++++ .../pdf-workflows/feature-mapping.csv | 125 +++++ .../pdf-workflows/interaction-evidence.md | 143 ++++++ .../pdf-workflows/source-register.csv | 181 ++++++++ .../pdf-workflows/workflow-reference.md | 430 ++++++++++++++++++ docs/roadmap.md | 2 + docs/ui-parity.md | 2 + 9 files changed, 1168 insertions(+) create mode 100644 docs/research/pdf-workflows/README.md create mode 100644 docs/research/pdf-workflows/competitive-research.md create mode 100644 docs/research/pdf-workflows/feature-mapping.csv create mode 100644 docs/research/pdf-workflows/interaction-evidence.md create mode 100644 docs/research/pdf-workflows/source-register.csv create mode 100644 docs/research/pdf-workflows/workflow-reference.md diff --git a/ROADMAP.md b/ROADMAP.md index f104ee0d80..efe122a8b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -339,6 +339,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged. +- **2026-10-17 (research):** Added a [PDF workflow research reference](docs/research/pdf-workflows/README.md): dated public-source locators, 20 workflow research domains, a pinned feature mapping and interaction evidence. Reference documentation; no feature statuses, milestone priorities or estimates changed. - **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). - **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. diff --git a/docs/research/pdf-workflows/README.md b/docs/research/pdf-workflows/README.md new file mode 100644 index 0000000000..32a855a570 --- /dev/null +++ b/docs/research/pdf-workflows/README.md @@ -0,0 +1,73 @@ +# PDF workflow research reference + +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Status:** reference documentation. Feature and roadmap snapshot: [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Detailed code inspection remains pinned to `3f044b62`, dated 2026-10-13. + +This reference consolidates archived comparative research, public source locators and +PdfCraft feature evidence. It separates documented workflows, historical observations, +evaluation criteria and current feature records so contributors can assess the collected +material without treating an old research claim as a present product fact. + +The documentation is independent of feature implementation. It introduces no new project +roadmap, implementation assignments, delivery estimates or feature-completion claims. +Planning remains in [ROADMAP.md](../../../ROADMAP.md) and the +[feature checklist](../../../parity/acrobat-features.toml). + +## Contents + +| Reference | Contents | +|---|---| +| [Comparative research](competitive-research.md) | Nineteen product studies and eighteen workflow families, with evidence limitations and archived corrections. | +| [Source register](source-register.csv) | 180 unique public locators, their archive dates, recovered provenance and explicit verification states. | +| [Workflow reference](workflow-reference.md) | Twenty research domains: existing foundations, retained findings and evaluation dimensions. | +| [Feature mapping](feature-mapping.csv) | 124 existing parity IDs with their exact status and milestone at the pinned snapshot, linked to the research domains. | +| [Interaction evidence](interaction-evidence.md) | Inspected document ownership, job outcomes, save, keyboard and review foundations, distinguished from historical evaluation criteria. | + +## Evidence and dates + +The archive includes sixteen product studies, a community-demand sample, an expanded +desktop/platform survey and interface investigations. Research dates span July–October +2026. Three additional desktop products appear in the expanded survey. The source register +is a navigation aid rather than independent proof of any product's current behavior. + +`archived-not-revalidated` means that the locator was recovered from a dated study; its +supporting passage and current edition have not been checked for this handoff. +`locator-reconstructed-not-revalidated` means that the original study named a source +without preserving its complete URL. Neither state means a web-archive capture exists. +Vendor-hosted community posts remain community reports, not vendor policy statements. + +Community snippets describe questions and reported pain points. They do not establish +market share, incidence or representative satisfaction. Historical pricing, licensing, +legal, security and superiority assertions are excluded. No competitor was installed or +benchmarked for this documentation contribution. + +## Reconciliation with PdfCraft + +Earlier research described measurement, a command palette, visual comparison, Tags panels +and reusable actions as missing. The inspected source and newer checklist already contain +those foundations, including synchronized side-by-side comparison and recursive folder +batches. The workflow reference records those corrections and reuses the existing oracle +tools rather than describing a second implementation. + +The dated mapping is not another live checklist. Current status comes from +`parity/acrobat-features.toml`; the ranked priorities and progress remain in +[gaps.md](../../gaps.md), [roadmap.md](../../roadmap.md) and +[ui-parity.md](../../ui-parity.md). An existing inconsistency in knockout-rendering +evidence is identified without changing its recorded status. + +## Publication and verification scope + +These are newly written summaries and public source links. No third-party screenshots, +visual designs, source code, fonts, sample documents, private paths, account data or raw +session logs are included. Historical implementation classes, toolkit choices and old +effort estimates are not adopted as PdfCraft architecture. + +Static checks cover the feature IDs, statuses, milestone assignments, reference IDs and +local links. Evaluation scenarios are research criteria, not tests already executed. +This handoff claims no native acceptance, screen-reader verification, external-reader +compatibility result, vendor benchmark or compliance certification. + +## Revision history + +| Date | Change | +|---|---| +| 2026-10-17 | Consolidated the archived research, source register, workflow evidence and pinned feature mapping. | diff --git a/docs/research/pdf-workflows/competitive-research.md b/docs/research/pdf-workflows/competitive-research.md new file mode 100644 index 0000000000..f5dc1400dc --- /dev/null +++ b/docs/research/pdf-workflows/competitive-research.md @@ -0,0 +1,211 @@ +# Comparative PDF workflow research + +Prepared 2026-10-17 from a September–October 2026 research archive. This reference summarizes product coverage, workflow distinctions and evidence limits. It is not a current competitor parity assessment, implementation specification or additional roadmap. + +## Scope and evidence rules + +The archive contains sixteen product studies dated September 7–8, a community-demand study, a September 8 synthesis with September 20 reconciliation, September 25 desktop/web/platform and interaction studies, and detailed design investigations through October 8. Three additional desktop products were examined on September 25: Power PDF, PDFelement and SumatraPDF. The accompanying [source register](source-register.csv) contains 180 distinct public source locators recovered from this material or reconstructed from its source titles. + +The product sheets combined documented capabilities, approximate edition/version history, secondary reviews, inference and historical implementation observations. These categories have different evidentiary limits: + +| Evidence category | Information retained | Limit | +|---|---|---| +| Archived primary documentation | A dated vendor-described workflow or technical interface | Not independent reliability evidence, a current edition guarantee or proof of superiority | +| Archived release notes | A reported change in a named release | Not proof of availability in every platform or edition | +| Archived source inspection | Behavior recorded at the inspected revision | Not verification of another revision, release or backend | +| Community reports and search snippets | Reported pain points and terminology | Not market share, incidence, representative satisfaction or security conclusions | +| Historical design investigation | Documented problem, constraints and evaluation dimensions | Not PdfCraft implementation status, an accepted architecture or dependency approval | + +Unless explicitly updated in the register, links are **archived-not-revalidated**. An archive date is the date written in the collected study, not a web-archive capture, freshness guarantee or current product test. **Locator-reconstructed-not-revalidated** identifies a URL reconstructed from a named source rather than recovered as a complete citation. Vendor-hosted community discussions remain community evidence; their hosting does not make them vendor capability statements. + +The register is a source-finding aid, not a claim that every linked page supports every observation in this document. Its scope notes distinguish general entry pages from more specific references. No competitor was installed or benchmarked for this handoff. Screenshots, downloaded proprietary documents, user documents, implementation prompts and copied source code are not included. + +No competitor absence counts, rankings, prices, subscription assertions, security certifications, processing-retention promises or legal deadlines are reproduced as current facts. Corrections within the archive are recorded below. PdfCraft implementation status and planning remain in its existing roadmap, parity records and source evidence. + +## Product coverage + +This table is an index to the subjects examined by each archived study. It does not assert that a product uniquely provides a capability or that PdfCraft lacks it. Product editions and platforms differed within the original studies. + +| Product study | Workflow subjects recorded | Comparison dimensions used in the research | +|---|---|---| +| Adobe Acrobat | Tool contexts; staged redaction; form events; validation/fixups; reusable actions; search and structure | Review versus apply, repeated-operation settings, document structure and edition boundaries | +| ABBYY FineReader | Linked scan/text/word zoom; uncertain-item navigation; dictionaries; page verification; cross-format comparison; scheduled processing | Correction effort, source/text synchronization, review completion and recognition output | +| Foxit PDF Editor | Custom actions; search; form events; structured review; layers; region recognition; deployment policies | GUI/automation consistency, search scope, form compatibility and platform/edition differences | +| PDF-XChange Editor | Object/field properties; annotation filters; dynamic stamps; scan correction; tags; identifiers | Selection precision, property inspection, annotation interchange and reusable configuration | +| Nitro PDF Pro | Sensitive-data review; table/form extraction; Office-oriented navigation; repeated operations | Detection versus removal, structured extraction and task-level discoverability | +| Bluebeam Revu | Markup records; reusable symbols; calibrated measurements; revisions; compare and overlay | Review metadata, technical drawing scale, revision correspondence and interchange | +| Master PDF Editor | Object inspector; menus/toolbars; forms; certificate operations | Live-document inspection, field properties and advanced operations beside the page | +| UPDF | Tool access; reading comfort; cross-page operations; summaries; platform variations | Reading/editing state, page-spanning work and platform capability differences | +| PDF Expert | Focused reading/editing; thumbnail operations; customizable access; signature placement | Page-centered work, document manipulation and contextual properties | +| PDFgear | Task-led entry; region extraction; conversion | Goal-oriented entry, region selection and output artifacts | +| Sejda | Verb-first tools; per-operation limits; filename templates; chains; unsupported-input explanations | Prerequisites, output naming, operation chaining and declared limits | +| iLovePDF | Task grid; chained processing; page/conversion utilities; desktop/web differences | Single-task entry, result reuse and processing-location disclosure | +| PDF24 | Local utilities; scanning/recognition; compression readouts; batch options; hybrid invoices | Measured outcomes, repeated processing and embedded business data | +| Smallpdf | Narrow task flows; conversion; signing categories | Entry friction, option disclosure and task-specific outputs | +| LibreOffice Draw | Import/export; headless conversion; command options; accessibility/export settings | Native editing versus conversion, semantic preservation and format tradeoffs | +| Okular | Keyboard annotation; view history; reading transforms; backend capabilities; signatures | Review navigation, reading state and unsupported-format reporting | +| Power PDF | Office-oriented navigation; deployment differences; refreshed entry surfaces | Navigation, product/edition boundaries and installation context | +| PDFelement | Customizable tool access; reusable annotations; table/form/document workflows | Repeated-action access and grouping of related operations | +| SumatraPDF | Minimal chrome; searchable commands/navigation targets; keyboard reading | Discoverability, reading navigation and command access | + +Public entry points include [Acrobat help](https://helpx.adobe.com/acrobat/user-guide.html), [FineReader help](https://help.abbyy.com/en-us/finereader/16/), [Foxit user guides](https://www.foxit.com/resource-hub/user-guide/), the [PDF-XChange feature catalogue](https://www.pdf-xchange.com/product/pdf-xchange-editor), [Bluebeam markup guidance](https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html), and [SumatraPDF command-palette documentation](https://www.sumatrapdfreader.org/docs/Command-Palette). Their archive or reconstruction states are recorded in the register; listing them here does not assert fresh product verification. + +## Community evidence and its limits + +The September community study used sixteen targeted searches across PDF, administration, legal, archival and related communities. Direct thread retrieval was frequently blocked, so many observations came from indexed snippets. Counts in that study represented distinct surfaced threads with unknown search coverage and overlapping audiences. They were not survey percentages. No thread-frequency ranking or verbatim snippet quotation is reproduced here. + +Five recurring themes were recorded: + +| Theme in the collected material | Distinction expressed by the reports | Evidence limit | +|---|---|---| +| Redaction and saving | Visual covering versus content removal; saved artifact versus on-screen appearance | Reports did not independently establish removal correctness across products | +| Confidential and repeated processing | Processing location, input/output predictability and reuse of options | Search results did not establish representative privacy preferences | +| Layout damage during editing | A small intended change versus font substitution or wider reflow | Individual complaints did not establish defect rates or comparative fidelity | +| Form interactivity | Filling, authoring, flattening, printing and data interchange | Similar terminology did not imply identical field or script compatibility | +| Large and mixed-result jobs | Overall progress versus per-file success, failure and unchanged output | Reported jobs were not a controlled performance benchmark | + +Ownership expectations, interruptions, keyboard review and readable themes also appeared in the collected material. They remain historical observations, rather than current pricing conclusions or statistically measured demand. + +## Workflow families examined + +The archive grouped PDF work into the following eighteen families. The dimensions below describe what the studies compared or distinguished. They do not introduce new acceptance contracts or implementation assignments for PdfCraft. + +### 1. Reading, navigation and search + +The viewing research distinguished page content from temporary reading state: zoom, viewport rotation, reading colors, navigation history and validation indicators. Search and bookmark movement were examined in relation to page labels, physical page numbers and returning to the prior reading context. + +The studies treated current-document search, cross-document search, folder indexing and recognition-backed search as different scopes. Saved sessions and view history were also distinct from saved document edits. Historical comparisons of search depth were documentation-based, not measurements of retrieval accuracy or speed. + +### 2. Text and object editing + +The editing studies separated replacement of existing text, added overlays, image/object changes and whole-page conversion. Font substitution, subset fonts, positioned text, unsupported glyphs, rotation and mixed writing systems were recurring explanations for unexpected editing results. + +The archive evaluated fidelity in terms of both appearance and extractable text. It also examined object bounds, selection properties, reflow disclosure and external-editor round trips. Reports of formatting damage did not establish a product-wide defect rate, and a single successful font/script example did not establish general CJK or right-to-left editing support. + +### 3. Page organization and composition + +Page studies covered selection, insertion, extraction, reordering, reversal, splitting, interleaving and assembly from multiple documents. They distinguished thumbnail position from page identity and distinguished a displayed page label from its physical index. + +Recorded evaluation dimensions included output-name previews, split boundaries, unequal source lengths, page-size differences, retained links/bookmarks/forms and the relationship between undo and selection. The archived cross-document staging layouts were design explorations; they are not adopted interface requirements or evidence of a current PdfCraft gap. + +### 4. OCR and human verification + +The recognition research examined the correction loop around recognition results: source image, recognized text, magnified region, uncertain-item navigation, replacement, skipping and human-reviewed state. The FineReader study emphasized synchronization and repetitive keyboard navigation rather than confidence coloring alone. + +Engine confidence and human review were separate concepts. The archive also distinguished an edited preview from a corrected searchable text layer, region recognition from whole-page recognition, and already-text input from scanned input. Language selection, preprocessing, alternate suggestions, page orientation and failed/cancelled recognition were recorded as separate dimensions. Ensemble architecture and language counts were not independent accuracy evidence; no held-out comparative accuracy result is established by this handoff. + +### 5. Forms and document scripts + +Forms research separated authoring, filling, formatting, validation, calculation, flattening and data interchange. Field lists, tab order, calculation order and event dispatch appeared in the archived comparisons. A populated field and a functioning calculation/event model were not treated as interchangeable evidence. + +The historical script investigations recorded runtime compatibility and security constraints: allowed events and APIs, resource bounds, event recursion, external access and non-executing inspection. Those investigations evaluated another implementation's options. They do not select a runtime, authorize a dependency or establish PdfCraft script behavior. Unsupported fields and unsupported events were distinct from a document that had been verified to execute correctly. + +### 6. Comments, markups and review data + +Annotation studies examined both visible appearance and review records: author, subject, page, status, replies, timestamps and summaries. Bluebeam's study devoted particular attention to structured markup lists, reusable symbol sets and drawing-review metadata. Other studies covered annotation filters, stamps, comment summaries and import/export. + +The archive distinguished filtering from deletion, literal stamp content from values inserted at placement, and standard PDF relationships from application-specific metadata. Interchange and CSV export were considered separately from the appearance of the annotation in its originating application. Specialized construction-review workflows were a distinct research category rather than a general requirement for every PDF editor. + +### 7. Redaction and sanitization + +The collected material separated candidate detection, human review, application of removal, sanitization and verification. Search patterns and entity detection concerned finding possible sensitive material; they did not themselves establish removal. Visual black fill, blur and pixelation were distinguished from removal of the underlying PDF content. + +Recorded assurance dimensions included saved-file extraction, affected images, metadata, annotations, attachments, layers, previous revisions and nested content. Geometry on rotated pages and partial-word cases were separate concerns. The reports also noted that an audit artifact can leak the very content being removed. Claims of uniquely verifiable or universally safe redaction are not reproduced; the archive is not a security certification. + +### 8. Encryption, permissions and hostile-document handling + +Security studies distinguished open-password encryption, permission flags and recipient-certificate encryption. They also examined external links, launch actions, scripts, attachments and processing of malformed files. These are different controls and do not have the same threat model. + +The archive's evaluation dimensions included credential handling, failure/refusal behavior, resource limits and consistency between the displayed operation and its execution policy. PDF permission flags were not equivalent to preventing arbitrary software from extracting accessible content. Historical algorithm and certification references remain source locators, not current conformance findings. + +### 9. Digital signatures and signing requests + +Signing research distinguished a drawn appearance, certificate-based signing, document certification, permitted changes and validation. Integrity of signed bytes, signer trust, timestamps, revocation evidence and later document changes were separate observations. A displayed validation result was tied to the document revision inspected. + +The archived local-request investigation examined prepared documents, manifests, handoff and inspection of returned files. That historical design was not a hosted signing service or identity-verification system. The archive also distinguished the local embedding of validation evidence from retrieval that may require network access. Its PAdES, dependency and legal terminology does not establish PdfCraft's attained signature profiles or the legal status of an output. + +### 10. Document comparison + +Comparison studies separated text, raster/visual, page-structure and annotation differences. They examined page correspondence after insertions, removals, duplication and reordering, together with linked scrolling, change lists, filtering, navigation and report export. + +An overlay and a textual diff supplied different observations; neither alone established comprehensive equivalence. Scanned comparison introduced recognition dependence. The archive also recorded uncertain alignment and the distinction between a filtered view, a complete report and a user-reviewed subset. No comparative false-positive or missed-change rate was measured for this handoff. + +### 11. Batch processing and presets + +The detailed batch investigations recorded ordered steps, versioned preset data, option validation, output naming, conflicts, stop/continue policy, numbering continuity and per-file reports. They distinguished inert configuration data from executable scripts or shell commands. + +Atomicity was an explicit research dimension: a per-file chain could be all-or-nothing while a whole run had mixed outcomes; a whole-run transaction was a different guarantee. Cancellation could leave previously committed work, depending on the documented boundary. The archive also examined watch-folder repetition, result retention and deterministic numbering when files completed in a different order. These historical designs are not a replacement action format or another batch runner for PdfCraft. + +### 12. Conversion, extraction and document fidelity + +Conversion studies distinguished extracted PDF text, reconstructed document structure, page images, editable Office output and tabular data. A file extension, package structure check and visual rendering each supplied different evidence. Table boundaries, cell order, merged cells, page ranges and output settings were recorded separately. + +The archived fidelity questions concerned retained bookmarks, annotations, attachments, form semantics and unsupported content as well as visual layout. The studies also noted that exported text can be interpreted as spreadsheet formulas. Historical claims about conversion breadth or editable output were not independent measurements of arbitrary-document fidelity. + +### 13. Compression, fonts and color + +Optimization studies separated lossless cleanup, image recompression, resolution reduction, font subsetting and color conversion. Estimated savings and measured before/after bytes were different quantities. An output larger than its input was still a possible result of processing. + +The October font investigation documented that a control's name alone did not establish actual subsetting for every font class in the implementation studied. The color investigation examined decoder behavior, embedded profiles, image representations and rendering intent. Their findings are historical case studies, not PdfCraft defect reports or dependency choices. Rendering, selectable text, font handling and retention of associated files were independent evaluation dimensions. + +### 14. Printing, measurements and preflight + +Printing research covered page order, N-up, booklet, duplex, comments and preview. Measurement research distinguished viewport scale, physical page geometry, calibration, units and stored annotations. Rotated pages and nonzero page-box origins affected the interpretation of geometric results. + +Preflight, fixups and production previews were separate categories. A check reported an observation; a fixup changed the document; a production preview concerned the intended output process. The archive examined named profiles and skipped checks without treating a collection of lightweight checks as a complete production or standards validator. + +### 15. Accessibility and archival formats + +The archive distinguished accessible application operation from accessible PDF output. For the application, it examined keyboard paths, focus, labels, status, contrast, custom-painted controls and actual assistive-technology behavior. For the document, it examined tags, reading order, alternative text, language, lists, tables and the relationship between content identifiers and structure. + +The historical tagging investigation bounded the document classes it considered and separated automatic classification from human review. A structure check, a screen-reader session and a standards validator reported different forms of evidence. Automated checks did not by themselves establish document accessibility or legal compliance. The register retains standards references without reproducing the archive's changing legal-deadline assertions. + +### 16. Layers, attachments and structured business documents + +Layer research separated visibility, printing state, membership, ordering and properties. Displaying a layer panel was not evidence that every optional-content transformation was supported. Hidden content also had implications for searching and sanitization. + +The archive treated attachments and embedded XML as potentially essential document data. Hybrid-invoice discussions distinguished detecting and preserving a payload from reading it, validating it, editing it or generating a compliant invoice. Those were separate scopes; the handoff carries no legal mandate, invoice-conformance finding or current product absence assertion. + +### 17. Deployment, automation and integration + +Integration research examined menus, keyboard access, contextual commands, command search, CLI operations, structured automation and watched folders. They were access paths to operations, not equivalent guarantees about authorization, cancellation or execution policy. + +The archive recorded coordinate naming, document identity, input/output paths, confinement, authorization, limits and headless reporting as evaluation dimensions. Silent installation, managed policies, portable builds, platform signing and architecture support were additional deployment subjects. Descriptions of other applications' registries or toolkits do not establish a new architecture for PdfCraft. + +### 18. Assisted recognition and grounded document answers + +The expanded survey covered suggested sensitive entities, image descriptions, extracted structures and answers about document content. It distinguished suggestions from accepted edits, source-grounded answers from unsupported statements, and processing location from the appearance of the interface. + +The studies recorded language coverage, available runtimes, network behavior, source-page references and editable suggestions as evaluation dimensions. They did not supply held-out accuracy measurements, an approved dependency selection or proof that generated descriptions established accessibility. Sensitive-data suggestions and actual redaction remained different stages. + +## Cross-cutting interface observations + +The September 25 interaction studies recorded several recurring dimensions across product families: + +| Dimension | Distinctions recorded in the archive | +|---|---| +| Command access | A command's identity, its entry points, its availability and its explanation when unavailable | +| Task surface | One-shot configuration, repeated review, live-document inspection and long-running monitoring | +| Document context | Active tab versus operation owner; current document versus the revision analyzed | +| Result state | Preview, pending mutation, completed edit, saved output, stale report and partial result | +| Focus and navigation | Entering a tool, moving between related views and returning to the prior reading position | +| State communication | Color, text, shape, control state and assistive-technology semantics | +| Layout and scaling | Narrow windows, large text, display scales, long translations and secondary-panel visibility | +| Visual consistency | Icon meaning, labels, themes and the difference between page colors and application chrome | +| Long operations | Start, cancellation request, partial completion, error, retry and completed output | + +The archive included sketches of persistent OCR/comparison views and inspectors. Those sketches are historical research artifacts, not adopted navigation plans, prescribed workspace layouts or new implementation requirements. No third-party visual design is imported here. + +## Corrections and unresolved evidence in the archive + +Several later reports qualified earlier claims. The following distinctions prevent an archived comparison from becoming an inaccurate current scoreboard: + +1. **Licensing:** later research qualified an early universal subscription assertion. Product licensing varied by edition and date. No current price or subscription conclusion is carried forward. +2. **Shipping evidence:** later inspection distinguished a low-level engine capability, a visible entry point and tested end-to-end behavior. They were not equivalent evidence of a complete product workflow. +3. **Signing terminology:** appearance, certification, long-term validation evidence and qualified-signature services were conflated in parts of the earlier material. This reference treats them separately. +4. **Absence claims:** some negative findings came from documentation searches or snippets rather than exhaustive product tests. They are not reproduced as current facts. +5. **Accuracy and security:** architecture and feature lists were sometimes used to infer recognition accuracy or removal reliability. This handoff records no such superiority or safety conclusion. +6. **Regulatory references:** accessibility and invoicing deadlines were corrected within the archive. No legal deadline or compliance conclusion is asserted here. +7. **Implementation transfer:** toolkit-specific source paths, proposed dependencies and defects from another codebase did not establish PdfCraft behavior. This document retains workflow distinctions and historical observations, not those implementation directions. + +Current PdfCraft planning and feature status remain in the existing roadmap and parity records. The source register and this reference document only describe the collected research and its limits. diff --git a/docs/research/pdf-workflows/feature-mapping.csv b/docs/research/pdf-workflows/feature-mapping.csv new file mode 100644 index 0000000000..d2daefabe7 --- /dev/null +++ b/docs/research/pdf-workflows/feature-mapping.csv @@ -0,0 +1,125 @@ +domain,research_topic,parity_id,status,milestone,snapshot_commit,feature_evidence_source,research_reference,source_register,source_inspection_commit,evidence_limit,evidence_note +B01,Rendering and output fidelity,view.render-pages,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-shadings-patterns,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-transparency,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-knockout,shipped,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Recorded shipped status conflicts with signing-related notes/evidence and transparency notes; maintainer evidence reconciliation required; no status changed here +B01,Rendering and output fidelity,view.render-type3,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-cid-fonts,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-image-codecs,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.text-edit,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rtl-cjk-editing,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rotated-text-editing,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.keep-tags-on-edit,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-text,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-visual,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-report,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-side-by-side,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-filters,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-page-ranges,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-scanned,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.correct-recognized-text,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.editable-text-output,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-text,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-range,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-multiple,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-languages,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.rotation-detection,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.deskew,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-engines,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.despeckle,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.background-removal,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.camera-perspective,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-run,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-create,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-manage,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-prompt-steps,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.autotag,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tags-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tag-properties,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.reading-order-tool,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.order-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.table-editor,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.create-tag-from-selection,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.artifact-marking,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-validate,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-convert,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-results,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-reports,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-verification,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-glyph-removal,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-image-pixels,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-vectors,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-annots-fields,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-struct-tree,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-pattern-locales,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-partial-words,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-folder,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-properties,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-codes,partial,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.cades-pades-bb,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs12,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.windows-cert-store,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.macos-keychain,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.certify,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.timestamp-servers,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs11,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.field-mdp,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-bt,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ltv,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-blta,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ocsp-crl,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.custom-scripts,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.event-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.calculation-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.submit-form,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",edit.action-javascript,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.push-buttons,partial,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-distance,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-scale,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-perimeter,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-area,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-snap,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-info-panel,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-export,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-files,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-choose-pages,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-sort,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-thumbnails,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.field-name-conflicts,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-every-n,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-by-bookmarks,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.struct-tree-merge,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-multiple-files,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-naming,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.from-office,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-docx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-xlsx,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-pptx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,view.large-document-performance,shipped,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,misc.performance-budgets,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,core.lazy-loading,planned,M1,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.advanced-search,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-folder,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-boolean,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-index,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.app-screen-reader,partial,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.keyboard-only,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.replace-page-colors,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.preferences-dialog,partial,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.prefs-search,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.admin-preferences,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.rtl-ui,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,organize.print-presets,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.output-preview,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-warnings,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.convert-colors,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-management,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-redo,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-after-save,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.atomic-batch-edits,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.autosave,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.crash-recovery,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.unsaved-changes-prompt,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.web-recovery,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",view.multiple-windows,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package diff --git a/docs/research/pdf-workflows/interaction-evidence.md b/docs/research/pdf-workflows/interaction-evidence.md new file mode 100644 index 0000000000..7773505747 --- /dev/null +++ b/docs/research/pdf-workflows/interaction-evidence.md @@ -0,0 +1,143 @@ +# Interaction evidence and evaluation reference + +Detailed source inspection used PdfCraft revision +`3f044b6263aa6605c3aa44ca6d065e1ab37d13b6`. A later documentation review used +`924c4da4ac2879ac9107c6c82e20ac6be5570f0a` on 2026-10-17. Links retain those +inspected revisions. Neither review was a runtime parity assessment. + +This reference summarizes interaction findings from archived comparative research and +relates them to existing PdfCraft mechanisms. It contains no implementation plan, +proposed workspace design, feature commitment or new architecture. The project's +roadmap and machine-readable parity checklist retain their planning and status roles. +Historical design examples and test descriptions are distinguished from current product +evidence below. + +## Existing mechanisms and corrected historical descriptions + +Several early comparative notes described capabilities that later existed in PdfCraft. +The table records the inspected evidence and its limits rather than preserving those +absence claims. + +| Area | Inspected evidence | What that evidence establishes | +|---|---|---| +| Command discovery and dispatch | [`commands.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/commands.rs), [`palette.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/palette.rs) | Menus, shortcuts, palette and other entry points share command metadata/dispatch. The palette is an existing feature, not an absent research concept. | +| Measurement | [`measure_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/measure_ui.rs) | Distance/perimeter/area controls, calibration, snapping and saved-measurement state exist. An older session-only calibration description does not describe every current persisted scale. | +| Comparison | [`compare_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/compare_ui.rs) | Text changes, visual regions, document highlights, report and comment actions exist. This older source link does not establish all later split-view behaviour. | +| OCR | [`ocr_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/ocr_ui.rs) | Background recognition, cancellation request and multiple-file processing exist. Cancellation can retain pages already recognized; it is not synonymous with rollback. | +| Accessibility review | [`accessibility.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/accessibility.rs) | Source includes checks for finding navigation, Tags-panel review, automatic text tagging and per-document skipped-rule state. Tests were inspected, not executed for this handoff. | +| Interface appearance | [`theme.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/theme.rs) | A shared token type and System/Light/Dark preferences exist. Their presence is not a measured contrast result. | +| International text input | [`commands.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/commands.rs) | Palette tests cover idle composition frames, Chinese preedit/commit, cancellation and outside-click behaviour. This does not establish native input-method behaviour in every editable field. | +| Automation conventions | [`automation/README.md`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/automation/README.md) | The documented contract uses displayed top-left rectangles, 1-based pages/positions, confined paths and a shared tool table. Historical mixed-coordinate criticism is not current evidence of a defect. | + +The later [feature checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) +marks synchronized side-by-side comparison shipped. Its notes describe two document +views when the older version remains open and the window is at least 1000 pixels wide; +synchronization follows offsets rather than matching content. The record identifies +page-size drift and old-tab closure as limits. It also records Action Wizard +creation/editing/running and recursive CLI/MCP folder batches with bounded recipes, +relative paths, collision-safe output names and per-file reports. + +The later [roadmap](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/ROADMAP.md) +records changed-on-disk Save confirmation and a shared staging-file writer. It describes +new outputs being reopened/checked before publication. These are existing safety +mechanisms; the archived research is not evidence that atomic saves or conflict +notifications are absent. + +Some prose gap tables at the same revision still describe comparison or Tags panels as +missing, while detailed checklist entries and inspected source describe implementations. +This is a documentation inconsistency in the inspected snapshot, not evidence that the +code is absent or that its listed limitations are solved. + +## What the archived interaction research distinguished + +The research compared workflow structures rather than implementation languages. Its +recurring distinctions are useful when reading feature descriptions and test evidence: + +- **Document identity versus current selection.** A tab index, display name and filesystem + path describe different things. Changing the selected tab does not change which source + a previously started operation analyzed. Reopening the same path can create a different + session, and an edit can change the revision within one session. +- **Analysis versus edit versus export.** A finding list describes an input revision; a + preview can remain uncommitted; an export can describe an invocation snapshot. The + existence of a preview does not establish that document bytes changed or a file saved. +- **Cancellation request versus terminal cancellation.** A request can be pending while + a worker reaches a boundary. A completed subset may be intentionally retained. An + atomic commit and the surrounding multi-file run have different interruption scopes. +- **History availability versus restoration outcome.** A named undo entry establishes + that history exists. Successful restoration, rendering refresh and save/reopen are + separate observable outcomes. The research did not reproduce a PdfCraft history failure. +- **Atomic replacement versus destination conflict.** Staging avoids half-written output; + conflict detection deals with another writer changing the intended destination. They + address different failure modes. The roadmap records mechanisms in both categories. +- **Automated finding versus conformance.** A checker pass describes supported checks. + Manual review, unsupported constructs and a report's input identity affect how that + result can be interpreted. A pass is not independent certification. + +These distinctions are an interpretation vocabulary, not a checklist of newly required +features. The underlying product's declared behaviour determines whether retaining a +partial result or refusing an operation is the expected outcome. + +## Evaluation observations by workflow + +The following table describes what the archived evaluations observed or separated. It +neither reports a fresh PdfCraft test run nor assigns implementation tasks. Example +conditions describe evidence scope rather than newly discovered bugs. + +| Workflow | Observable relationship examined | Limits on interpreting the observation | +|---|---|---| +| OCR review | The recognized word, source region, selected row and magnified crop referred to the same token; a saved correction was distinguished from text displayed only in a preview. | Confidence was backend evidence, not a universal accuracy score. Correcting invisible searchable text and visibly editing a scanned phrase were distinct operations. | +| Comparison | Original/revised direction, selected change, displayed pages and exported report referred to the same comparison. Filtering and review disposition were distinguished from the full result set. | Equal text did not establish equal appearance or structure. Offset synchronization and content correspondence were distinct. A missing corresponding page was not page zero or a valid empty match. | +| Batch processing | The input, selected action, current step, saved outputs and per-file failures were separately observable. Completed outputs remained meaningful in a partially failed run. | Per-file atomicity did not imply a whole-run transaction. A retry could repeat a previously committed effect. `command_batch` documents that earlier edits can remain after a later error. | +| Accessibility review | Findings, selected tags and page locations referred to the same reachable objects. Proposed classification and applied structure were distinguished. | An automatic structural result did not establish reading quality. Text-only tagging evidence did not cover arbitrary images, tables or existing structure. | +| Standards checks | The profile, checked revision, findings and saved conversion artifact were separate evidence fields. | Structural checks for a subset of a standard were not a full validation claim. An unavailable external validator was not a passing result. | +| Page organization | Source selection, destination identity, permutation and resulting saved page order were evaluated separately. | Successful page transfer did not by itself establish preservation of every bookmark, form relationship or structure tree. | +| Redaction review | Marked regions, committed removal and checks on the saved output were separate stages. Verification wording was tied to the supported check scope. | A visual rectangle was not removal evidence. Reports and logs could themselves expose removed content. Absence under one extraction strategy did not prove absence under all encodings. | + +Archived state-machine notes used terms such as running, review-ready, saving, complete, +partial, failed and stale. Those terms describe distinct user-visible outcomes; they do +not establish that PdfCraft has, or needs, a single global state-machine type. Likewise, +historical multi-pane layouts described a way to observe several related values at once; +they are not adopted interface designs or a request for a replacement shell. + +## Keyboard, input and appearance evidence + +The research separated semantic labels from complete task access. A headless +accessibility tree can expose a control's name, role and state without establishing that +a person can finish a keyboard-only task or that a native screen reader announces the +result clearly. Focus ownership, return focus after closing a surface, background focus +changes and non-drag alternatives were observed independently from visual arrangement. +No native screen-reader session was performed for this handoff. + +[WCAG 2.2](https://www.w3.org/TR/WCAG22/) supplies technology-neutral criteria for +keyboard operation, focus, contrast and target sizes. It is a reference, not evidence +that this native application conforms to a level. The [WAI-ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) +describes web tab focus/activation semantics; it does not determine native widget or +platform accessibility implementations. + +Input-method research distinguished preedit text, committed text, selection, undo and +cancellation. Enter/Escape can have composition meanings in addition to dialog meanings. +Chinese palette composition tests exist in the inspected source. Their presence does +not establish candidate-window behaviour on Windows, macOS or Linux, or equivalent +coverage in other fields. RTL interface direction and PDF coordinate conventions were +also separate concerns. + +Appearance research distinguished interface chrome, page content and reading-colour +transforms. Switching an interface theme did not constitute editing a PDF's colours. +Token tables described intended colours; painted states, alpha composition, focus and +selection established different evidence. Layout evaluation recorded viewport, scaling, +translation length and text direction because a layout observation at one size did not +establish results at other sizes. No archived palette, icon family, font, screenshot or +prototype layout is included or recommended by this reference. + +## Evidence boundaries + +The material includes archived rationale, source inspection, documented conventions and +proposed-test descriptions from historical research. It does not include fresh native +acceptance, manual assistive-technology checks, independently measured OCR accuracy, +rendering benchmarks or external-reader compatibility results for this handoff. + +Names of tests in linked source establish that the source contains those tests. They do +not mean the tests were rerun here, or that an archived implementation's test result +transfers to PdfCraft. The code-inspection and documentation baselines differ and remain +explicit. Current statuses and future work belong to the repository's existing roadmap +and parity records, not this evaluation reference. diff --git a/docs/research/pdf-workflows/source-register.csv b/docs/research/pdf-workflows/source-register.csv new file mode 100644 index 0000000000..558fef67a5 --- /dev/null +++ b/docs/research/pdf-workflows/source-register.csv @@ -0,0 +1,181 @@ +source_id,product,topic,url,archive_date,evidence_state,origin_id,scope_note +SR001,ABBYY FineReader,Start window and task entry (FineReader 14 guide),https://help.abbyy.com/en-us/finereader/14/user_guide/startwindow_firstpage/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR002,ABBYY FineReader,"User guide index: verification, comparison and automation",https://help.abbyy.com/en-us/finereader/16/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names guide chapters but not full URLs; locator reconstructed for discovery. +SR003,ABBYY FineReader,Product and feature documentation entry,https://pdf.abbyy.com/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study identifies product documentation and brochure; root locator reconstructed. +SR004,ABBYY FineReader Engine SDK,FineReader Engine 12 SDK release notes,https://support.abbyy.com/hc/en-us/articles/37502278922642-FineReader-Engine-12-for-Windows-Release-8-Update-2,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR007,Adobe Acrobat,Acrobat product help index,https://helpx.adobe.com/acrobat/user-guide.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names Help Center but not an exact URL; locator reconstructed for discovery. +SR008,Adobe Express,Adobe Express PDF editing guide,https://helpx.adobe.com/express/web/documents-and-presentations/edit-pdf.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR009,Adobe Express,Adobe Express release notes,https://helpx.adobe.com/express/web/whats-new/release-notes.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR010,Adobe Acrobat,Acrobat Studio product announcement,https://news.adobe.com/news/2026/01/adobe-acrobat-studio-transforms,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR011,Adobe Acrobat,Acrobat productivity product announcement,https://news.adobe.com/news/2026/05/adobes-new-productivity-agent,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR012,Adobe Acrobat,Acrobat JavaScript API reference,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR013,Adobe Acrobat,Acrobat JavaScript developer guide,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR014,Adobe Acrobat,ISO 32000-1:2008 PDF specification,https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR015,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/363,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR016,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/5299/about-studio-prime-end-of-life-eol,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR017,Bluebeam Revu,Documentation locator: markups list mtv,https://support.bluebeam.com/online-help/revu20/Content/RevuHelp/Menus/Window/Panels/Markups/Markups-List--MTV.htm,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR018,Bluebeam Revu,Documentation locator: compare documents vs overlay pages,https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR019,Bluebeam Revu,Documentation locator: manage and review markups with filter list,https://support.bluebeam.com/revu/how-to/manage-and-review-markups-with-filter-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR020,Bluebeam Revu,Documentation locator: track and manage markups using markups list,https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR021,Bluebeam Revu,Documentation locator: revu 20 eol,https://support.bluebeam.com/revu/resources/revu-20-eol.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR022,Bluebeam Revu,Release notes and change history,https://support.bluebeam.com/revu/resources/revu-21-release-notes.html,2026-09-07,archived-not-revalidated,core-bluebeam;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR023,Bluebeam Revu,Documentation locator: revu mcp,https://support.bluebeam.com/revu/resources/revu-mcp.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR024,Bluebeam Revu,Documentation locator: bluebeam subscription faq,https://support.bluebeam.com/revu/subscription/onboarding/bluebeam-subscription-faq.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR025,Bluebeam Revu,Documentation locator: subscription features,https://support.bluebeam.com/revu/subscription/subscription-features.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR026,Bluebeam Revu,Documentation locator: studio prime eol announcement,https://support.bluebeam.com/studio/resources/studio-prime-eol-announcement.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR027,Bluebeam Revu,Documentation locator: link,https://support.bluebeam.com/user-manual/menus/batch/link.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR028,Bluebeam Revu,Documentation locator: sign seal,https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR029,Bluebeam Revu,Documentation locator: slip sheet,https://support.bluebeam.com/user-manual/menus/batch/slip-sheet.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR030,Bluebeam Revu,Documentation locator: compare documents,https://support.bluebeam.com/user-manual/menus/document/compare-documents.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR031,Bluebeam Revu,Documentation locator: ocr,https://support.bluebeam.com/user-manual/menus/document/ocr.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR032,Bluebeam Revu,Documentation locator: overlay pages,https://support.bluebeam.com/user-manual/menus/document/overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR033,Bluebeam Revu,Documentation locator: erase content,https://support.bluebeam.com/user-manual/menus/edit/erase-content.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR034,Bluebeam Revu,Documentation locator: redaction,https://support.bluebeam.com/user-manual/menus/edit/redaction.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR035,Bluebeam Revu,Documentation locator: create forms,https://support.bluebeam.com/user-manual/menus/tools/create-forms.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR036,Bluebeam Revu,Documentation locator: form fields,https://support.bluebeam.com/user-manual/menus/tools/form-fields.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR037,Bluebeam Revu,Documentation locator: manage self signed ids,https://support.bluebeam.com/user-manual/menus/tools/manage-self-signed-ids.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR038,Bluebeam Revu,Documentation locator: create edit sets,https://support.bluebeam.com/user-manual/menus/window/create-edit-sets.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR039,Bluebeam Revu,Documentation locator: punch tool set,https://support.bluebeam.com/user-manual/menus/window/punch-tool-set.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR040,Bluebeam Revu,Licensing and edition reference (historical; no current price assertion),https://www.bluebeam.com/pricing/,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR041,ETSI,Documentation locator: 31914201,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR042,ETSI,Documentation locator: en 31914201v010201p,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR043,Firefox / pdf.js,Form scripting API implementation reference,https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR044,Firefox / pdf.js,Documentation locator: pdf alt text,https://support.mozilla.org/en-US/kb/pdf-alt-text,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR045,Foxit PDF Editor,Documentation locator: pdf products comparison,https://cdn01.foxitsoftware.com/pub/foxit/datasheet/general/en_us/pdf-products-comparison.pdf,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR046,Foxit PDF Editor,Documentation locator: articles,https://kb.foxit.com/s/articles/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR047,Foxit PDF Editor,Documentation locator: 360040658511,https://kb.foxit.com/s/articles/360040658511,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR048,Foxit PDF Editor,Documentation locator: 4410630911764,https://kb.foxit.com/s/articles/4410630911764,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR049,Foxit PDF Editor,Documentation locator: foxit pdf editor perpetual license support policy,https://kb.foxit.com/s/articles/Foxit-PDF-Editor-Perpetual-License-Support-Policy,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR050,Foxit PDF Editor,Documentation locator: steps to upgrade to foxit pdf editor version 14 on windows,https://kb.foxit.com/s/articles/Steps-to-Upgrade-to-Foxit-PDF-Editor-Version-14-on-Windows,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR051,Foxit PDF Editor,Documentation locator: foxit pdf editor aec drawing sets update,https://www.foxit.com/blog/foxit-pdf-editor-aec-drawing-sets-update/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR052,Foxit PDF Editor,Documentation locator: foxits perpetual license support policy what you need to know,https://www.foxit.com/blog/foxits-perpetual-license-support-policy-what-you-need-to-know/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR053,Foxit PDF Editor,Documentation locator: 11200,https://www.foxit.com/company/press/11200.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR054,Foxit PDF Editor,Licensing and edition reference (historical; no current price assertion),https://www.foxit.com/pdf-editor/pricing/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR055,Foxit PDF Editor,Release notes and change history,https://www.foxit.com/pdf-editor/version-history.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR056,Foxit PDF Editor,Documentation locator: add custom commands to custom actions,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR057,Foxit PDF Editor,Documentation locator: shared review,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/shared-review/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR058,Foxit PDF Editor,Published policy or security reference (historical),https://www.foxit.com/support/refund-policy/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR059,Icon system,Icon asset provenance and licensing reference,https://github.com/google/material-design-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR060,Icon system,Icon asset provenance and licensing reference,https://github.com/lucide-icons/lucide,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR061,Icon system,Icon asset provenance and licensing reference,https://github.com/microsoft/fluentui-system-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR062,Icon system,Icon asset provenance and licensing reference,https://github.com/tabler/tabler-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR063,Icon system,License text (historical locator; review exact version before reuse),https://raw.githubusercontent.com/lucide-icons/lucide/main/LICENSE,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR064,IETF,Documentation locator: rfc3161,https://datatracker.ietf.org/doc/html/rfc3161,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR065,iLovePDF,Documentation locator: pdf web or desktop,https://www.ilovepdf.com/blog/pdf-web-or-desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR066,iLovePDF,Documentation locator: desktop,https://www.ilovepdf.com/desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR067,iLovePDF,Licensing and edition reference (historical; no current price assertion),https://www.ilovepdf.com/pricing,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR068,iLovePDF,Documentation locator: sitemap.xml,https://www.ilovepdf.com/sitemap.xml,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR069,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=443403,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR070,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=489299,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR072,KDE,Release notes and change history,https://kde.org/announcements/changelogs/gear/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR073,KDE,Documentation locator: 22.04.0,https://kde.org/announcements/gear/22.04.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR074,KDE,Documentation locator: 23.08.0,https://kde.org/announcements/gear/23.08.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR075,KDE,Documentation locator: 26.08.0,https://kde.org/announcements/gear/26.08.0/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR076,LibreOffice Draw,PDF export and security help index,https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names export help paths without full URLs; locator reconstructed for discovery. +SR077,Master PDF Editor,Documentation locator: downloads,https://code-industry.net/downloads/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR078,Master PDF Editor,Documentation locator: faq,https://code-industry.net/faq/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR079,Master PDF Editor,Documentation locator: masterpdfeditor help,https://code-industry.net/masterpdfeditor-help/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR080,Master PDF Editor,Workspace navigation and object inspection,https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR081,Master PDF Editor,Documentation locator: masterpdfeditor,https://code-industry.net/masterpdfeditor/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR082,Master PDF Editor,Licensing and edition reference (historical; no current price assertion),https://code-industry.net/purchase/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR083,Master PDF Editor,Documentation locator: what is new in master pdf editor 5,https://code-industry.net/what-is-new-in-master-pdf-editor-5/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR084,Microsoft platform documentation,Documentation locator: 9n41msq1wnm8,https://apps.microsoft.com/detail/9n41msq1wnm8,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR085,Microsoft platform documentation,Documentation locator: xpfd51h3vqzfm0,https://apps.microsoft.com/detail/xpfd51h3vqzfm0,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR086,Microsoft platform documentation,Documentation locator: pdf reader,https://explore.microsoft.com/en-us/edge/features/pdf-reader,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR087,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/12609/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR088,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/21169/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR089,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/22140/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR090,Nitro PDF Pro,Documentation locator: automate,https://www.gonitro.com/automate,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR091,Nitro PDF Pro,Documentation locator: bulk send and templates,https://www.gonitro.com/bulk-send-and-templates,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR092,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/documentation/release-notes,2026-09-07,archived-not-revalidated,core-nitro;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR093,Nitro PDF Pro,Documentation locator: pdf pro,https://www.gonitro.com/pdf-pro,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR094,Nitro PDF Pro,Licensing and edition reference (historical; no current price assertion),https://www.gonitro.com/pricing,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR095,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR096,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases/sign-api,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR097,Nitro PDF Pro,Documentation locator: nitros complete guide to secure automated pdf redaction,https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR098,Nitro PDF Pro,Documentation locator: sign,https://www.gonitro.com/sign,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR099,Okular,Project source and technical documentation reference,https://github.com/KDE/okular,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR100,Okular,Product or project documentation entry,https://okular.kde.org/,2026-09-07,archived-not-revalidated,core-okular,Landing/index locator only; locate a specific supporting page before citing a feature. +SR101,Okular,Documentation locator: download,https://okular.kde.org/download/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR102,Okular,Documentation locator: faq,https://okular.kde.org/faq/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR103,Okular,Documentation locator: formats,https://okular.kde.org/formats/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR104,Okular,Project source and technical documentation reference,https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR105,PDF Association,Product or project documentation entry,https://pdfa.org,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR106,PDF Association,Release notes and change history,https://pdfa.org/foxit-20252-and-v14-release-highlights-faster-smarter-more-secure/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR107,PDF Association,Foxit PDF Editor 2026.2 release announcement,https://pdfa.org/foxit-pdf-editor-20262-release-notes/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR108,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR109,PDF Association,UPDF 2.5 release announcement,https://pdfa.org/updf-25-officially-launches-worldwide-the-first-pdf-editor-powered-by-autonomous-ai-agents/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR110,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/wtpdf/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR111,PDF Expert,Product or project documentation entry,https://copilot.pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,Landing/index locator only; locate a specific supporting page before citing a feature. +SR112,PDF Expert,Product or project documentation entry,https://pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR113,PDF Expert,PDF Expert assisted document-reading announcement,https://pdfexpert.com/blog/introducing-ai-chat,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR114,PDF Expert,Product or project documentation entry,https://readdle.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR115,PDF Expert,Documentation locator: is pdf expert available on windows,https://support.readdle.com/pdfexpert/en_US/billing-subscription/is-pdf-expert-available-on-windows,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR116,PDF library documentation,Project source and technical documentation reference,https://raw.githubusercontent.com/podofo/podofo/1.1.0/src/podofo/main/PdfSignerCms.cpp,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR117,PDF-XChange,Documentation locator: features added in v9,https://help.pdf-xchange.com/pdfxe11/features-added-in-v9.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR118,PDF-XChange,Documentation locator: 352,https://www.pdf-xchange.com/knowledgebase/352-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR119,PDF-XChange,Documentation locator: 554,https://www.pdf-xchange.com/knowledgebase/554-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR120,PDF-XChange,Documentation locator: pdf xchange ocr,https://www.pdf-xchange.com/pdf-xchange-ocr,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR121,PDF-XChange,Documentation locator: pdf xchange products comparison chart,https://www.pdf-xchange.com/pdf-xchange-products-comparison-chart,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR122,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/privacy-policy,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR123,PDF-XChange,Documentation locator: pdf xchange editor,https://www.pdf-xchange.com/product/pdf-xchange-editor,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR124,PDF-XChange,Release notes and change history,https://www.pdf-xchange.com/product/pdf-xchange-editor/history,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR125,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/support/security-bulletins.html,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR126,PDF24,Desktop Creator documentation entry,https://creator.pdf24.org/,2026-09-08,locator-reconstructed-not-revalidated,source-title-recovery,Product documentation index; exact supporting feature pages require review. +SR127,PDF24,Release notes and change history,https://creator.pdf24.org/changelog/en.html,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR128,PDF24,Documentation locator: 11,https://creator.pdf24.org/manual/11/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR129,PDF24,Documentation locator: avoid autostart of pdf24,https://help.pdf24.org/en/forums/topic/avoid-autostart-of-pdf24/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR130,PDF24,Documentation locator: all tools,https://tools.pdf24.org/en/all-tools,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR131,PDF24,Documentation locator: compress pdf,https://tools.pdf24.org/en/compress-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR132,PDF24,Documentation locator: creator,https://tools.pdf24.org/en/creator,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR133,PDF24,Documentation locator: ocr pdf,https://tools.pdf24.org/en/ocr-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR134,PDF24,Documentation locator: redact pdf,https://tools.pdf24.org/en/redact-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR135,PDF24,Documentation locator: en,https://www.pdf24.org/en/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR136,PDF4QT,Project source and technical documentation reference,https://github.com/JakubMelka/PDF4QT,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR138,PDFelement,PDFelement version history,https://pdf.wondershare.com/whats-new.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR139,PDFgear,Product or project documentation entry,https://www.pdfgear.com/,2026-09-07,archived-not-revalidated,core-pdfgear,Landing/index locator only; locate a specific supporting page before citing a feature. +SR140,PDFgear,PDFgear version history,https://www.pdfgear.com/whats-new/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR141,PDFsam,Documentation locator: faq,https://pdfsam.org/faq/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR142,Power PDF,Power PDF licensing and edition reference (historical),https://www.tungstenautomation.com/Products/power-pdf/pricing,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR143,Power PDF,Power PDF 2025.3 release announcement,https://www.tungstenautomation.com/press-releases/2025/tungsten-automation-launches-power-pdf-2025-3,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR144,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6.8/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR145,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qcolorspace.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR146,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qimage.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR147,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR148,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/style-reference.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR149,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/whatsnew68.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR150,Qt documentation,Release notes and change history,https://forum.qt.io/topic/156001/style-mistake-in-win11-with-qt-6-7-mingw-11-2-official-online-release,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR151,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.7-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR152,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.8-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR153,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/download,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR154,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/guide,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR155,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/ArtifexSoftware/mujs/tags,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR156,Script runtime documentation,Script runtime repository and licensing reference,https://github.com/quickjs-ng/quickjs,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR157,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/svaarala/duktape,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR158,Script runtime documentation,Script runtime technical and licensing reference,https://mujs.com/,2026-09-07,archived-not-revalidated,design-form-js,Landing/index locator only; locate a specific supporting page before citing a feature. +SR159,Script runtime documentation,License text (historical locator; review exact version before reuse),https://mujs.com/license.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR160,Script runtime documentation,Script runtime technical and licensing reference,https://quickjs-ng.github.io/quickjs/developer-guide/intro/,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR161,Sejda,Project source and technical documentation reference,https://github.com/torakiki/sejda,2026-09-08,archived-not-revalidated,core-sejda,"Archived source locator; supporting passage, edition and present behavior require review." +SR162,Sejda,Product or project documentation entry,https://www.sejda.com/,2026-09-08,archived-not-revalidated,core-sejda,Landing/index locator only; locate a specific supporting page before citing a feature. +SR163,Sejda,Documentation locator: workflows,https://www.sejda.com/workflows,2026-09-09,archived-not-revalidated,design-batch-presets,"Archived source locator; supporting passage, edition and present behavior require review." +SR164,Smallpdf,Documentation locator: compare,https://smallpdf.com/compare,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR165,Smallpdf,Documentation locator: desktop,https://smallpdf.com/desktop,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR166,Smallpdf,Documentation locator: windows,https://smallpdf.com/download/windows,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR167,Smallpdf,Documentation locator: edit pdf,https://smallpdf.com/edit-pdf,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR168,Smallpdf,Licensing and edition reference (historical; no current price assertion),https://smallpdf.com/pricing,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR169,Smallpdf,Published policy or security reference (historical),https://smallpdf.com/privacy,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR170,Stirling-PDF,Product or project documentation entry,https://docs.stirlingpdf.com/,2026-09-25,archived-not-revalidated,expanded-02,Landing/index locator only; locate a specific supporting page before citing a feature. +SR171,Stirling-PDF,Structured automation interface documentation,https://docs.stirlingpdf.com/Configuration/Automation/MCP%20Server/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR172,Stirling-PDF,Automation pipeline documentation,https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR173,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Modes%20and%20Licensing/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR174,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Paid-Offerings/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR175,Stirling-PDF,Project source and technical documentation reference,https://github.com/Stirling-Tools/Stirling-PDF,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR176,SumatraPDF,SumatraPDF change history,https://www.sumatrapdfreader.org/docs/Changelog,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR177,SumatraPDF,Command palette and navigation prefixes,https://www.sumatrapdfreader.org/docs/Command-Palette,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR178,UPDF,Product or project documentation entry,https://ai.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR179,UPDF,Documentation locator: newgoodslist,https://checkout.updf.com/v1/order/newGoodsList?goodsType=1&channel=SEO-EN-AI¤cy=USD,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR180,UPDF,Product or project documentation entry,https://sign.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR181,UPDF,Licensing and edition reference (historical; no current price assertion),https://updf.com/pricing/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR182,UPDF,Documentation locator: tech spec,https://updf.com/tech-spec/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR183,UPDF,Release notes and change history,https://updf.com/whats-new/,2026-09-07,archived-not-revalidated,core-updf;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR184,UPDF,Documentation locator: write a review for updf,https://updf.com/write-a-review-for-updf/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." diff --git a/docs/research/pdf-workflows/workflow-reference.md b/docs/research/pdf-workflows/workflow-reference.md new file mode 100644 index 0000000000..c0a419711a --- /dev/null +++ b/docs/research/pdf-workflows/workflow-reference.md @@ -0,0 +1,430 @@ +# Workflow research and verification reference + +This reference connects archived workflow research with PdfCraft's existing feature catalogue. It is not a roadmap, a set of implementation assignments, or an approval to introduce features, storage formats, dependencies or architecture. Planning remains in the repository's existing `ROADMAP.md` and `parity/` records. +The catalogue and roadmap snapshot is [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Engine and automation names were inspected separately at [`3f044b62`](https://github.com/storytold/pdfcraft/tree/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6), dated 2026-10-13. Catalogue status means the status recorded at that revision; it is not an independent verification verdict. No implementation tests, benchmarks, competitor sessions or conformance checks were executed for this reference. +The [competitive research digest](competitive-research.md) provides the archived context. The [source register](source-register.csv) records source identifiers, URLs, archive dates and verification limits. Links below are locators for the retained research, not fresh confirmations of vendor behavior. The [feature mapping](feature-mapping.csv) contains the corresponding catalogue records. B01–B20 are reference section identifiers, not a second task or priority system. +Evaluation dimensions name what an observation describes; they do not prescribe targets or new acceptance gates. Availability, structural validity, visual fidelity, interoperability and human usability are distinct evidence. Negative or incomplete observations remain distinguishable from successful checks. + +## B01 — Rendering and output fidelity + +**Recorded baseline.** The checklist records six mapped rendering features as partial. Its knockout entry is marked shipped, but cites signing tests and signed-save notes; the transparency entry describes knockout rendering as incomplete. Those are inconsistent catalogue references, not evidence from a fresh rendering test. The current progress log and gap 12 document existing `cargo xtask render-oracle` and `cargo xtask text-oracle` tools, including pixel disagreements and reading-order measurements. These tools report differences; their output is not a fidelity certification. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.render-pages` | partial | M2 | +| `view.render-shadings-patterns` | partial | M2 | +| `view.render-transparency` | partial | M2 | +| `view.render-knockout` | shipped | M11 | +| `view.render-type3` | partial | M2 | +| `view.render-cid-fonts` | partial | M2 | +| `view.render-image-codecs` | partial | M2 | + +**Archived research question.** The archived viewing and conversion studies distinguish whether a file opens, whether its text can be extracted, and whether the visible page matches a reference. These observations concern different kinds of fidelity. + +**Evaluation dimensions.** Pixel differences and their locations; glyph identity and reading order; clipping and transparency; independent-reader outcomes; oracle version and tolerance; unsupported or incomplete analysis. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/render/src/raster.rs::renders_a_blue_rectangle`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/render/src/raster.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B02 — Existing-text editing + +**Recorded baseline.** Text editing and RTL/CJK editing are partial. The mapped rotated-text and tag-preservation entries remain planned. Inspected source exposes text_edit, text_edit_layout, text_fonts and text_font_import; this does not establish general support for every font or script. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `edit.text-edit` | partial | M7 | +| `edit.rtl-cjk-editing` | partial | M7 | +| `edit.rotated-text-editing` | planned | M7 | +| `edit.keep-tags-on-edit` | planned | M7 | + +**Archived research question.** The archived editing studies ask whether correcting text preserves its font, baseline, writing order, nearby content and document structure. A visually acceptable edit and a semantically faithful edit are separate observations. + +**Evaluation dimensions.** Extracted Unicode and logical order; shaping and missing-glyph behavior; retained font/style; neighbouring content; selection/caret behavior; saved/reopened layout; unsupported transformations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/tests.rs); [`crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/bidi.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B03 — Document comparison + +**Recorded baseline.** Text comparison, visual comparison, PDF reports and side-by-side views are shipped in the current checklist. The side-by-side notes describe an old-tab requirement, a minimum window width of 1000 px and offset-based synchronization. They identify content-aligned scrolling and retaining comparison after the old tab closes as limitations. Filters, selected ranges and scanned comparison remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.compare-text` | shipped | M12 | +| `misc.compare-visual` | shipped | M12 | +| `misc.compare-report` | shipped | M12 | +| `misc.compare-side-by-side` | shipped | M12 | +| `misc.compare-filters` | planned | M12 | +| `misc.compare-page-ranges` | planned | M12 | +| `misc.compare-scanned` | planned | M12 | + +**Archived research question.** Archived comparison studies distinguish a textual change list, raster differences, overlays and synchronized old/new context. The research question is how a reviewer associates a reported difference with both document locations. + +**Evaluation dimensions.** Difference type and location; page alignment with insertions or unequal sizes; zoom/scroll correspondence; navigation counts; report destinations; identical-input outcomes; input immutability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/compare/src/lib.rs::insertions_deletions_and_replacements`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/compare/src/lib.rs); [`crates/engine/src/tests.rs::comparing_two_versions_of_a_document`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR018: Bluebeam Revu — Documentation locator: compare documents vs overlay pages](https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html); [SR030: Bluebeam Revu — Documentation locator: compare documents](https://support.bluebeam.com/user-manual/menus/document/compare-documents.html); [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/). Verification state and date are in the source register. + +## B04 — Recognition proofing and correction + +**Recorded baseline.** Recognition of pages, ranges and multiple files is shipped; editable-text output is partial; correction of low-confidence suspects is planned. Inspected source includes ocr_recognize, ocr_recognize_files and ocr_status. These operations alone do not establish a proofing workflow. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.correct-recognized-text` | planned | M10 | +| `ocr.editable-text-output` | partial | M10 | +| `ocr.recognize-text` | shipped | M10 | +| `ocr.recognize-range` | shipped | M10 | +| `ocr.recognize-multiple` | shipped | M10 | + +**Archived research question.** The archived recognition study separates image/text/zoom synchronization, navigation among uncertain material, correction, skipping and human-reviewed state. Confidence from an engine and a human review decision are distinct evidence. + +**Evaluation dimensions.** Word-to-image correspondence; confidence provenance or absence; correction in extracted text after save/reopen; retained scan appearance; skipped versus reviewed state; stale recognition results; keyboard reachability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/tests/ocr_edit.rs); [`crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR031: Bluebeam Revu — Documentation locator: ocr](https://support.bluebeam.com/user-manual/menus/document/ocr.html). Verification state and date are in the source register. + +## B05 — Recognition languages and scan preparation + +**Recorded baseline.** Language support is partial; orientation detection, deskew and pluggable engines are planned. Camera-image cleanup provides partial despeckle, background-removal and perspective capabilities. The notes do not establish equivalent preprocessing of arbitrary scanned pages. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.ocr-languages` | partial | M10 | +| `ocr.rotation-detection` | planned | M10 | +| `ocr.deskew` | planned | M10 | +| `ocr.ocr-engines` | planned | M10 | +| `ocr.despeckle` | partial | M10 | +| `ocr.background-removal` | partial | M10 | +| `ocr.camera-perspective` | partial | M10 | + +**Archived research question.** The archived scan studies distinguish selected language from the language actually recognized and distinguish camera perspective correction from text orientation, deskew and image cleanup. + +**Evaluation dimensions.** Actual backend/language; model availability and provenance; rotation/skew geometry; text accuracy by script; annotation/search coordinate agreement; pages with existing text; duplicate-layer behavior; explicit unsupported options. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ocr.rs); [`crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/camera_clean.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR120: PDF-XChange — Documentation locator: pdf xchange ocr](https://www.pdf-xchange.com/pdf-xchange-ocr); [SR133: PDF24 — Documentation locator: ocr pdf](https://tools.pdf24.org/en/ocr-pdf). Verification state and date are in the source register. + +## B06 — Reusable batch processing + +**Recorded baseline.** Action creation and execution are shipped; management/exchange and prompt-user steps remain planned. The inspected 2026-10-13 actions.rs has 12 step kinds, and action_list/action_run are present. This is an older source observation, not a new inspection of the current runner. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.action-wizard-run` | shipped | M13 | +| `misc.action-wizard-create` | shipped | M13 | +| `misc.action-wizard-manage` | planned | M13 | +| `misc.action-prompt-steps` | planned | M13 | + +**Archived research question.** Archived workflow studies describe named sequences, ordered steps, portable definitions and per-file outcomes. They distinguish a saved process definition from the outcomes of a particular execution. + +**Evaluation dimensions.** Definition/version round-trip; step order and arguments; input/output identity; occupied output names; individual failure reporting; partial batch outcomes; cancellation; path confinement; secret handling in reports. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/actions.rs); [`crates/automation/tests/automation.rs::actions_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR056: Foxit PDF Editor — Documentation locator: add custom commands to custom actions](https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/); [SR172: Stirling-PDF — Automation pipeline documentation](https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/). Verification state and date are in the source register. + +## B07 — Document structure and accessibility authoring + +**Recorded baseline.** Text-only autotag, Tags/property editing, sibling reading-order edits and artifact marking are partial. The current artifact entry covers reachable indirect leaf tags owning direct MCIDs through the panel and accessibility_mark_tag_artifact. Arbitrary painted selection, direct dictionaries and whole tagged subtrees remain outside that entry. accessibility_tags is described as paginated and filterable. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.autotag` | partial | M12 | +| `a11y.tags-panel` | partial | M12 | +| `a11y.tag-properties` | partial | M12 | +| `a11y.reading-order-tool` | partial | M12 | +| `a11y.order-panel` | partial | M12 | +| `a11y.table-editor` | planned | M12 | +| `a11y.create-tag-from-selection` | planned | M12 | +| `a11y.artifact-marking` | partial | M12 | + +**Archived research question.** Archived accessibility studies distinguish automatic classification, actual structure-tree changes, reading order and human interpretation. A visual panel, a structurally valid parent tree and an accessible reading experience are different observations. + +**Evaluation dimensions.** MCID/parent-tree consistency; content ownership; tag and reading order; alternate text; direct/indirect dictionary coverage; malformed or ambiguous structure; appearance after artifact marking; saved/reopened structure; manual-check findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/accessibility.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/); [SR110: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/wtpdf/); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B08 — Preflight and archival validation + +**Recorded baseline.** PDF/A verification and conversion are partial. Preflight analysis, result presentation and reports remain planned. Inspected source exposes pdfa_verify, pdfa_convert and print_readiness; their availability is not proof of full conformance validation. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `optimize.pdfa-validate` | partial | M11 | +| `optimize.pdfa-convert` | partial | M11 | +| `optimize.preflight` | planned | M11 | +| `optimize.preflight-results` | planned | M11 | +| `optimize.preflight-reports` | planned | M11 | + +**Archived research question.** The archived standards studies distinguish declared conformance, implemented structural checks, conversion changes and independent validation. A repairable finding is different from a condition the software cannot evaluate. + +**Evaluation dimensions.** Profile and rule coverage; page/object identification; unsupported checks; fonts and output intents; changes made by conversion; remaining findings after save/reopen; agreement and disagreement with an identified independent validator. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/preflight/src/tests.rs); [`crates/automation/tests/automation.rs::pdfa_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR105: PDF Association — Product or project documentation entry](https://pdfa.org). Verification state and date are in the source register. + +## B09 — Redaction and assurance evidence + +**Recorded baseline.** The mapped excision and verification features are shipped. Redaction properties are also shipped, including overlay/mark styling, persistent defaults, session-only changes and cancel behavior. Pattern locales, partial words and folder redaction remain planned; code sets are partial. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `protect.redaction-verification` | shipped | M8 | +| `protect.redact-glyph-removal` | shipped | M8 | +| `protect.redact-image-pixels` | shipped | M8 | +| `protect.redact-vectors` | shipped | M8 | +| `protect.redact-annots-fields` | shipped | M8 | +| `protect.redact-struct-tree` | shipped | M8 | +| `protect.redact-pattern-locales` | planned | M8 | +| `protect.redact-partial-words` | planned | M8 | +| `protect.redact-folder` | planned | M13 | +| `protect.redaction-properties` | shipped | M8 | +| `protect.redaction-codes` | partial | M8 | + +**Archived research question.** Archived redaction studies distinguish a visible cover from removal of underlying content and distinguish a scoped verification result from an unlimited security guarantee. A report containing removed text can itself disclose sensitive content. + +**Evaluation dimensions.** Search/extraction after save/reopen; image/vector/annotation/structure paths covered; hidden-information treatment; input/output hashes; unsupported constructs; false success on a cover-only control; refusal/cancellation behavior; sensitive data in exported records. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/redact/src/tests.rs::unreadable_content_fails_closed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/redact/src/tests.rs); [`crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ui_group/redact.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR034: Bluebeam Revu — Documentation locator: redaction](https://support.bluebeam.com/user-manual/menus/edit/redaction.html); [SR097: Nitro PDF Pro — Documentation locator: nitros complete guide to secure automated pdf redaction](https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B10 — Digital signatures and validation evidence + +**Recorded baseline.** The mapped B-B, PKCS #12, native Windows/macOS identity and certification capabilities are shipped. Timestamp preferences, PKCS #11 and FieldMDP are planned; B-T/LTV/B-LTA and online revocation support are partial. Inspected source exposes sign_document, sign_list and sign_trust. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `sign.cades-pades-bb` | shipped | M9 | +| `sign.pkcs12` | shipped | M9 | +| `sign.windows-cert-store` | shipped | M9 | +| `sign.macos-keychain` | shipped | M9 | +| `sign.certify` | shipped | M9 | +| `sign.timestamp-servers` | planned | M9 | +| `sign.pkcs11` | planned | M9 | +| `sign.field-mdp` | planned | M9 | +| `sign.pades-bt` | partial | M9 | +| `sign.ltv` | partial | M9 | +| `sign.pades-blta` | partial | M9 | +| `sign.ocsp-crl` | partial | M9 | + +**Archived research question.** Archived signing studies distinguish document integrity, signer trust, permitted changes, timestamp evidence and long-term validation material. A local signing workflow is different from a hosted signature-request service. + +**Evaluation dimensions.** Covered revision; permitted/disallowed changes; intact versus trusted status; timestamp presence and trust; revocation-evidence freshness; requested versus attained level; network-consent state; private-key custody; reopened independent validation. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/crypto.rs); [`crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/pdf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR028: Bluebeam Revu — Documentation locator: sign seal](https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B11 — Forms, events and scripting + +**Recorded baseline.** Custom scripts, event order and calculation order are shipped. Form submission and the mapped general JavaScript action are planned; push-button actions are partial. Inspected source includes js_run, js_document_scripts, form_set_script, form_actions, form_set_actions and js_enabled. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `form.custom-scripts` | shipped | M6 | +| `form.event-order` | shipped | M6 | +| `form.calculation-order` | shipped | M6 | +| `form.submit-form` | planned | M6 | +| `edit.action-javascript` | planned | M6 | +| `form.push-buttons` | partial | M6 | + +**Archived research question.** Archived form studies separate compatibility with installed forms from authoring tools and separate field-event order from document-level actions. Scripting availability does not imply filesystem or network privileges. + +**Evaluation dimensions.** Trigger timing; validation/calculation/format order; committed values after save/reopen; enabled/disabled script state; resource bounds; malformed scripts; unwanted external effects; cancellation and refusal outcomes. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/forms/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR012: Adobe Acrobat — Acrobat JavaScript API reference](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html); [SR013: Adobe Acrobat — Acrobat JavaScript developer guide](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html); [SR043: Firefox / pdf.js — Form scripting API implementation reference](https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js). Verification state and date are in the source register. + +## B12 — Measurement and scale interpretation + +**Recorded baseline.** Distance and scale are partial; perimeter, area, snapping, information display and CSV export are shipped. Current mapped measurement entries are assigned to M12. Inspected source includes the measure_* tool family. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `comment.measure-distance` | partial | M12 | +| `comment.measure-scale` | partial | M12 | +| `comment.measure-perimeter` | shipped | M12 | +| `comment.measure-area` | shipped | M12 | +| `comment.measure-snap` | shipped | M12 | +| `comment.measure-info-panel` | shipped | M12 | +| `comment.measure-export` | shipped | M12 | + +**Archived research question.** Archived technical-review studies distinguish measurement geometry from unit formatting, calibration and interchange. Reading a saved measurement is different from assuming every imported scale is supported. + +**Evaluation dimensions.** Known dimensions and numeric tolerance; selected viewport and scale; compound/fractional formats; rotated/cropped pages; imported unsupported formats; self-intersecting boundaries; saved/reopened values; CSV escaping and spreadsheet safety. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/measure/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR107: PDF Association — Foxit PDF Editor 2026.2 release announcement](https://pdfa.org/foxit-pdf-editor-20262-release-notes/). Verification state and date are in the source register. + +## B13 — Page assembly and document composition + +**Recorded baseline.** Combine, selected page ranges, sorting/grid previews, every-N/bookmark splitting and field-name conflict handling are shipped. Structure-tree merging, multiple-file split and the mapped naming refinements remain planned. Existing combine and page tools were present in the inspected source. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.combine-files` | shipped | M4 | +| `organize.combine-choose-pages` | shipped | M4 | +| `organize.combine-sort` | shipped | M4 | +| `organize.combine-thumbnails` | shipped | M4 | +| `organize.field-name-conflicts` | shipped | M4 | +| `organize.split-every-n` | shipped | M4 | +| `organize.split-by-bookmarks` | shipped | M4 | +| `organize.struct-tree-merge` | planned | M12 | +| `organize.split-multiple-files` | planned | M13 | +| `organize.split-naming` | planned | M4 | + +**Archived research question.** Archived composition studies examine how users review source pages, order and destination before a transfer. Visible selection, the captured source revision and the resulting document are separate pieces of evidence. + +**Evaluation dimensions.** Page order; source/target identity; unsaved-source handling; links, fields and bookmarks; size mismatch; field-name conflicts; structure preservation; source immutability; undo; saved/reopened destinations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/organize/src/tests.rs); [`crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/editing.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B14 — Creation and conversion fidelity + +**Recorded baseline.** Office import and spreadsheet export are planned; DOCX and presentation export are partial. DOCX notes record package validation and DrawingML picture anchors on positioned pages, with that positioned output still unverified in Word. Package validity does not establish visual fidelity. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `create.from-office` | planned | M10 | +| `create.export-docx` | partial | M10 | +| `create.export-xlsx` | planned | M10 | +| `create.export-pptx` | partial | M10 | + +**Archived research question.** Archived conversion studies distinguish reflowed editable output, positioned output, raster output and native PDF edits. Their suitability depends on what information the user needs to retain. + +**Evaluation dimensions.** Text and table semantics; headers/footers; page dimensions; images and vector rules; paint order; exact font matching; package validity; reopened rendering; independent application/version; explicit unsupported input and output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/lib.rs); [`crates/export/src/pptx.rs::pptx_is_a_presentationml_package`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/pptx.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B15 — Document performance and resource use + +**Recorded baseline.** Smooth 500-page scrolling is shipped. Performance budgets and lazy loading of very large files are planned. Existing notes describe whole-file input and remaining save-memory peaks; they do not establish bounded performance for every document. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.large-document-performance` | shipped | M3 | +| `misc.performance-budgets` | planned | M14 | +| `core.lazy-loading` | planned | M1 | + +**Archived research question.** Archived performance studies distinguish open time, first useful page, interaction latency and job completion. Input byte size and page count alone do not describe rendering complexity. + +**Evaluation dimensions.** Release build and hardware; corpus generation; fresh-process runs and variance; peak resident memory; open/edit/save timings; UI-thread stalls; cache state; cancellation latency; bounds on hostile dimensions; output validity after reopen. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/perf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. + +## B16 — Search across document collections + +**Recorded baseline.** Advanced current-document search is partial; folder, Boolean and indexed search are planned. Inspected source includes text_find and extraction. Mapped search entries belong to M12. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.advanced-search` | partial | M12 | +| `view.search-folder` | planned | M12 | +| `view.search-boolean` | planned | M12 | +| `view.search-index` | planned | M12 | + +**Archived research question.** Archived search studies distinguish current-file matching, collection traversal, persistent indexing and navigation back to a location. A result count is incomplete without the set of files actually searched. + +**Evaluation dimensions.** Query semantics; searched/excluded/failed files; path aliases and cycles; root confinement; result and cache bounds; stale locations after edits; location after reopen; cancellation; truncation and incomplete-result reporting. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/viewer.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B17 — Keyboard and assistive-technology interaction + +**Recorded baseline.** App screen-reader support is partial and keyboard-only operation is planned. Reading colors are shipped; current notes cover annotation appearances and highlight legibility. Existing labels and display-color behavior do not establish completion of every task with assistive technology. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.app-screen-reader` | partial | M3 | +| `a11y.keyboard-only` | planned | M14 | +| `a11y.replace-page-colors` | shipped | M12 | + +**Archived research question.** Archived interaction studies distinguish the accessibility tree, keyboard focus and what a platform screen reader announces. Successful automated tree inspection and a successful human task are different evidence. + +**Evaluation dimensions.** Names, roles, values and states; complete focus path; focus restoration; error/completion announcements; disabled-state explanations; selection after page changes; non-color information; platform/screen-reader version; manual versus automated findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/control.rs); [`crates/automation/tests/automation.rs::reading_colors_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular); [SR104: Okular — Project source and technical documentation reference](https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook); [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/). Verification state and date are in the source register. + +## B18 — Preferences and capability disclosure + +**Recorded baseline.** The preferences dialog is partial; settings search, admin defaults and RTL UI are planned. Existing categories include language, document/view, identity, Fill & Sign and JavaScript. Current notes describe XDG migration that preserves configured preferences; they do not establish a complete signature-preferences category. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.preferences-dialog` | partial | M14 | +| `misc.prefs-search` | planned | M14 | +| `misc.admin-preferences` | planned | M14 | +| `misc.rtl-ui` | planned | M14 | + +**Archived research question.** Archived shell and deployment studies distinguish application preferences, document overrides, session state and managed policy. A visible option is different from an available runtime capability. + +**Evaluation dimensions.** Scope and precedence; default versus override; persistence across restart/reopen; malformed stored values; migration preservation; policy-locked state; cancellation of drafts; consistency among supported entry points. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/javascript.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR080: Master PDF Editor — Workspace navigation and object inspection](https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/); [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette). Verification state and date are in the source register. + +## B19 — Print preparation and color behavior + +**Recorded baseline.** Printing, n-up/booklet and previews exist. Saved print presets and the mapped output-preview/color-production features are planned. Inspected source includes print_readiness; that inspection path is not a full production Preflight. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.print-presets` | planned | M10 | +| `print.output-preview` | planned | M11 | +| `print.color-warnings` | planned | M11 | +| `print.convert-colors` | planned | M11 | +| `print.color-management` | planned | M11 | + +**Archived research question.** Archived print studies distinguish display-only reading colors, print-preview appearance, color conversion and the output intent. An ICC profile or a printer selection has meaning only with an identified processing path. + +**Evaluation dimensions.** Page order and dimensions; persistent print options; preview/output correspondence; fonts and color-space findings; profile provenance and rendering intent; unsupported separations; device/platform differences; reopened print-ready output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites the per-feature evidence recorded in its entries. These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B20 — Document identity, history and failure outcomes + +**Recorded baseline.** The mapped undo, atomic batch-edit, autosave, crash-recovery and unsaved-change capabilities are shipped. Current recovery notes cover instance locks that prevent another running instance from offering/discarding live autosaves. Browser recovery and multiple windows remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `core.undo-redo` | shipped | M4 | +| `core.undo-after-save` | shipped | M4 | +| `core.atomic-batch-edits` | shipped | M4 | +| `core.autosave` | shipped | M4 | +| `core.crash-recovery` | shipped | M4 | +| `core.unsaved-changes-prompt` | shipped | M4 | +| `core.web-recovery` | planned | M4 | +| `view.multiple-windows` | planned | M14 | + +**Archived research question.** Archived document-ownership studies distinguish the active tab from the document captured by a long-running operation and distinguish a staged candidate from an accepted save. Those studies are not evidence of a defect in current PdfCraft. + +**Evaluation dimensions.** Captured document/revision; destination identity; close-during-save outcome; external file replacement; write failure; coherent undo/redo; unrelated-document preservation; recovery binding; saved/reopened accepted state; cancellation and reported non-interruptible stages. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::undo_and_redo_restore_exact_states`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/recovery.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. diff --git a/docs/roadmap.md b/docs/roadmap.md index a7f4a86b7e..023688ddc4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,6 +147,8 @@ repaint at the existing 60-second autosave deadline, even with no mouse or keybo Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, encryption and storage-write retry behavior are unchanged; this does not resolve #1803. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects archived findings and a dated feature mapping. Planning remains in this roadmap and the parity checklist. + ## Alpha gate The alpha gate: Acrobat Pro's everyday core workflows must each work end to end on the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3523db9eed..a0ea857f1a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -9,6 +9,8 @@ interface is copied. Gaps ranked in [gaps.md](gaps.md). **UI/UX fidelity ≈ 55% ready** (estimated), **30–60 h** additional to the features total. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects comparative source locators, interaction evidence and evaluation criteria. It does not change this checklist. + ## Measured - **184 registered commands** (`crates/engine/src/commands.rs`), every one in the menus, the From 431a4e68aecde25a67af1c9aa66c3759c5067e18 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:43 +0200 Subject: [PATCH 02/30] Test that moving an image keeps its marked-content sequence intact (#4248) * Test that moving an image keeps it inside its marked-content sequence translate_images changes the image's own cm and leaves its Do where it was, so a moved image stays in the BDC ... EMC sequence that tags it, whether that is a tagged-PDF element or an optional-content layer. Nothing pinned this: the image-move tests covered clips, transforms and repeated moves but not marked content. The test moves an image inside /P <> BDC and inside /OC /L0 BDC and checks that both sequences remain, the Do is still inside its own, and the other sequence gained no image. * Test image moves on a real optional-content layer --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/edit/src/tests.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cbea5c21d2..ecbb8b3645 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3514,7 +3514,10 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec { } } -/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2). +/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2), on a real optional +/// content layer: the catalog's `/OCProperties` declares object 7 as the only OCG and the page +/// resources' `/Properties` maps `/L0` to it, so `/OC /L0 BDC` marks a valid optional-content +/// member rather than a dangling name. fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } @@ -3534,12 +3537,13 @@ fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), Edit fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R] /D << /Order [7 0 R] >> >> >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> /Properties << /L0 7 0 R >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /OCG /Name (Layer 0) >>".into(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -3753,6 +3757,30 @@ fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { assert_eq!(caption, ["Caption"]); } +#[test] +fn moving_an_image_inside_marked_content_leaves_every_sequence_intact() { + // `translate_images` changes the image's own `cm`; its `Do` stays where it was, so a moved + // image stays in the sequence that tags it (an image on a layer sits in `/OC … BDC`). + for content in [ + "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + "/OC /L0 BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + ] { + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap(); + let after = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(after, [before[0] + 10.0, before[1], before[2] + 10.0, before[3]]), "{content}: {after:?}"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let count = |name: &str| ops.iter().filter(|op| op.is(name)).count(); + assert_eq!((count("BDC"), count("EMC")), (2, 2), "{content}: both sequences remain"); + let first = ops.iter().position(|op| op.is("BDC")).unwrap(); + let end = ops.iter().position(|op| op.is("EMC")).unwrap(); + let image = ops.iter().position(|op| op.is("Do")).unwrap(); + assert!(first < image && image < end, "{content}: the image is still inside its own sequence"); + assert!(!ops[end..].iter().any(|op| op.is("Do")), "{content}: the other sequence gained no image"); + } +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); From 7fd6860f4ad81d9cdbfc2d9d80488c837f26f3b0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:49 +0200 Subject: [PATCH 03/30] M13 batch: pin the command-line exit codes before any change (#6296) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- apps/pdfcraft-cli/tests/it/batch_exit.rs | 111 +++++++++++++++++++++++ apps/pdfcraft-cli/tests/it/main.rs | 1 + 2 files changed, 112 insertions(+) create mode 100644 apps/pdfcraft-cli/tests/it/batch_exit.rs diff --git a/apps/pdfcraft-cli/tests/it/batch_exit.rs b/apps/pdfcraft-cli/tests/it/batch_exit.rs new file mode 100644 index 0000000000..6dfb9f61e9 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/batch_exit.rs @@ -0,0 +1,111 @@ +//! The exit codes the CLI has today: `ExitCode::SUCCESS` (0) or `ExitCode::FAILURE` (1). +//! +//! Pinned here so that the exit-code contract is a visible, reviewed change when it comes: a +//! failed file, an outside-root path and a usage error all exit 1 today. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A fresh folder for one test, so the tests can run in parallel and leave nothing behind. +fn workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-exit-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `batch` with `args`, run from the working directory `dir`. +fn batch(dir: &Path, args: &[&str]) -> Output { + Command::new(BIN).current_dir(dir).arg("batch").args(args).output().unwrap() +} + +/// `run --script --report` from the working directory `dir`: one JSON document for the run. +fn run_report(dir: &Path, script: &Path) -> Output { + Command::new(BIN).current_dir(dir).args(["run", "--script"]).arg(script).arg("--report").output().unwrap() +} + +/// The one JSON document a command prints on stdout. +fn reported(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn batch_exits_0_when_every_file_succeeds() { + let dir = workdir("batch-ok"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + let output = batch(&dir, &["page.txt", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 0, "{report}"); + assert!(dir.join("out/page.pdf").exists(), "the converted page is written"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_exits_1_when_one_file_fails() { + let dir = workdir("batch-fail"); + std::fs::write(dir.join("bad.pdf"), "broken").unwrap(); + let output = batch(&dir, &["bad.pdf", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["failed"], 1, "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_usage_error_exits_1_and_creates_nothing() { + let dir = workdir("batch-usage"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + // Baseline: a usage error exits 1 today. A later change to the exit-code contract will + // update this assertion on purpose. + let output = batch(&dir, &["page.txt", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "a usage error prints to stderr, not a report on stdout"); + assert!(!output.stderr.is_empty(), "a usage error says what is wrong"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_outside_root_exits_1_and_creates_nothing() { + let dir = workdir("batch-outside-root"); + let output = Command::new(BIN) + .current_dir(&dir) + .args(["batch", "../outside.pdf", "--steps", "convert", "--out-dir", "out", "--root"]) + .arg(&dir) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("is outside the allowed directory"), "the input is refused by the root: {stderr}"); + assert!(output.stdout.is_empty(), "a refused batch prints no report"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn run_script_report_exits_0_when_ok_and_1_when_a_step_fails() { + let dir = workdir("run-report"); + let script = dir.join("steps.json"); + let blank = serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }); + let steps = serde_json::json!([blank.clone(), { "tool": "no_such_tool", "args": {} }]); + std::fs::write(&script, steps.to_string()).unwrap(); + + let failing = run_report(&dir, &script); + assert_eq!(failing.status.code(), Some(1), "{}", String::from_utf8_lossy(&failing.stderr)); + let report = reported(&failing); + assert_eq!(report["ok"], false, "{report}"); + assert_eq!(report["steps"][1]["status"], "error", "{report}"); + + std::fs::write(&script, serde_json::json!([blank]).to_string()).unwrap(); + let good = run_report(&dir, &script); + assert_eq!(good.status.code(), Some(0), "{}", String::from_utf8_lossy(&good.stderr)); + let report = reported(&good); + assert_eq!(report["ok"], true, "{report}"); + assert_eq!(report["steps"][0]["status"], "ok", "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs index f0b87c97a7..30a6e25882 100644 --- a/apps/pdfcraft-cli/tests/it/main.rs +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -3,6 +3,7 @@ mod audit; mod automation_roots; mod batch; +mod batch_exit; mod check; mod edit; mod extract; From 5aff7c83db443352968d12bd1ad47b6fa9b28e52 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:55 +0200 Subject: [PATCH 04/30] M13 batch: test that failed folder runs leave no output or staging file (#6298) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/batch_failures.rs | 89 ++++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + 2 files changed, 90 insertions(+) create mode 100644 crates/automation/tests/it/batch_failures.rs diff --git a/crates/automation/tests/it/batch_failures.rs b/crates/automation/tests/it/batch_failures.rs new file mode 100644 index 0000000000..f993d85eab --- /dev/null +++ b/crates/automation/tests/it/batch_failures.rs @@ -0,0 +1,89 @@ +//! A batch run that fails a file publishes only the completed outputs, and a +//! publish the file system refuses publishes nothing. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use serde_json::{Value, json}; + +fn result(auto: &mut Automation, args: Value) -> Value { + match auto.call("doc_batch", &args).unwrap().remove(0) { + Content::Json(value) => value, + _ => panic!("expected a report"), + } +} + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +/// Sets a folder's permission bits. +#[cfg(unix)] +fn set_mode(path: &std::path::Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); +} + +/// Puts back the write permission the temporary folder needs before it is removed. +#[cfg(unix)] +struct RestoreMode(std::path::PathBuf); + +#[cfg(unix)] +impl Drop for RestoreMode { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); + } +} + +#[test] +fn failed_inputs_publish_only_completed_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + std::fs::write(dir.path().join("input/bad.pdf"), "not a pdf").unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "{report}"); + let bad = files.iter().find(|file| file["path"].as_str().is_some_and(|path| path.ends_with("bad.pdf"))).unwrap(); + assert_eq!(bad["status"], "error", "{report}"); + assert!(bad["output"].is_null(), "a failed file reports no output: {report}"); + let names: Vec = + std::fs::read_dir(dir.path().join("out")).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(); + // Publish stages its bytes under a dot-prefixed name in the output folder, so + // no entry carrying that prefix may survive the run. + for name in &names { + assert!(!name.starts_with('.'), "temporary name left behind: {name} ({names:?})"); + } + assert_eq!(names, vec!["good.pdf".to_string()], "only the completed output is published"); +} + +#[cfg(unix)] +#[test] +fn read_only_output_folder_publishes_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + let out = dir.path().join("out"); + std::fs::create_dir(&out).unwrap(); + set_mode(&out, 0o555); + let _restore = RestoreMode(out.clone()); + // A root process ignores the mode bits, and then the assertions below would + // pass without testing the refusal. Fail loudly instead of skipping. + assert!(std::fs::write(out.join("probe"), b"").is_err(), "run as a non-root user: the read-only output folder accepted a new file"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 0, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{report}"); + // Publish cannot create its staging file in a folder it may not write, so + // the entry carries the system's error and no output. + assert_eq!(files[0]["status"], "error", "{report}"); + assert!(files[0]["output"].is_null(), "a refused publish reports no output: {report}"); + let error = files[0]["error"].as_str().unwrap(); + assert!(!error.is_empty(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "the refused publish writes nothing into the read-only folder: {report}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 3122fe5825..39f2b8a30d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -5,6 +5,7 @@ mod automation; mod automation_roots; mod backdrop; mod batch; +mod batch_failures; mod button_caption; mod form_bulk; mod hostile_args; From b1c1dbdad3d44b2517dafc418ba4690fe0b3f1fb Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:00 +0200 Subject: [PATCH 05/30] One known-answer case per implemented clause (#6301) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/tests/known_answers.rs | 403 ++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 crates/preflight/tests/known_answers.rs diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs new file mode 100644 index 0000000000..23d503dc62 --- /dev/null +++ b/crates/preflight/tests/known_answers.rs @@ -0,0 +1,403 @@ +//! What `verify` answers for each clause it implements, pinned as a known answer. +//! +//! Each test builds a small file by hand, from the shape the clause describes rather than from +//! anything this repository saves, so a bug shared between the verifier and the writer cannot make a +//! case pass. Two things in the file are not hand-written: the output intent profiles come from +//! `icc::srgb()`, the crate's own sRGB generator, and the encrypted document in the 6.1.3 case is +//! encrypted by the object layer, because writing an encryption dictionary by hand would restate the +//! rules under test. +//! +//! The clause strings are the verifier's own citations, pinned here as `lib.rs` writes them. They +//! have not been compared with the text of the profile yet, so a number that does not name the rule +//! it is attached to is a finding about the verifier and not about this file. +//! +//! The positive case expects no issue under the clause and the negative case expects one; both are +//! asserted by clause string, so a later change that alters an answer fails here first. Cases where +//! the verifier is known to disagree with the standard are left out on purpose and recorded as gaps +//! where the change that adds them is reviewed. Nothing here is a conformance claim. + +use pdfcraft_cos::{Algorithm, Document, NewEncryption}; +use pdfcraft_preflight::{Level, convert, declared, icc, verify}; + +/// A document from hand-written objects and extra trailer entries (copied from the crate's unit +/// tests). +fn build(objs: &[&str], trailer_extra: &str) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// The byte-order mark an XMP packet's `xpacket` instruction names. +const BOM: char = '\u{feff}'; + +/// The XMP packet identifying a file as `part`/`conformance`, with `title` as its `dc:title`. +fn xmp(part: u8, conformance: &str, title: Option<&str>) -> String { + let title = match title { + Some(t) => format!(" {t}\n"), + None => String::new(), + }; + format!( + r#" + + + + {part} + {conformance} +{title} + + +"# + ) +} + +/// The XMP packet of a PDF/A-2b file, with `title` as its title when one is given. +fn a2b_packet(title: Option<&str>) -> String { + xmp(2, "B", title) +} + +/// The catalog's `/Metadata` stream object holding `packet`. +fn xmp_object(packet: &str) -> String { + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{packet}\nendstream", packet.len()) +} + +/// A content stream object holding `content`. +fn content(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// An ASCII-hex stream object holding `bytes`, with `dict` as its other entries. +fn hex_stream(dict: &str, bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect(); + let data = format!("{hex}>"); + format!("<< /Filter /ASCIIHexDecode {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// A 128-byte ICC header for a four-component profile, with no `/N` so the header decides. +fn cmyk_profile_header() -> Vec { + let mut header = vec![0u8; 128]; + header[0..4].copy_from_slice(&128u32.to_be_bytes()); + header[16..20].copy_from_slice(b"CMYK"); + header[20..24].copy_from_slice(b"XYZ "); + header[36..40].copy_from_slice(b"acsp"); + header +} + +/// The catalog's `/OutputIntents`: a `GTS_PDFA1` intent naming the profile in object `n`. +fn output_intent(n: usize) -> String { + let condition = "/OutputConditionIdentifier (sRGB IEC61966-2.1)"; + let intent = format!("<< /Type /OutputIntent /S /GTS_PDFA1 {condition} /DestOutputProfile {n} 0 R >>"); + format!("/OutputIntents [{intent}]") +} + +/// An embedded font program stream object, compressed with `filter` when one is given. Its bytes +/// are not read by any rule: a font program is data, not content. +fn font_program(filter: Option<&str>) -> String { + let filter = match filter { + Some(filter) => format!("/Filter {filter} "), + None => String::new(), + }; + format!("<< /Length1 4 {filter}/Length 4 >>\nstream\nabcd\nendstream") +} + +/// An image XObject that asks to be interpolated, or does not. +fn image(interpolate: bool) -> String { + format!( + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate} /Length 1 >>\nstream\n\u{0}\nendstream" + ) +} + +/// A document whose catalog (object 1) carries `catalog_extra` and, when `packet` is given, the +/// metadata object 3; whose page tree (object 2) holds the single page `page` (object 4); and whose +/// objects 5 onwards are `more`, which `page` and `catalog_extra` refer to by number. An absent +/// `packet` leaves object 3 empty. The page must resolve, so a case that loses it fails instead of +/// passing because no rule ever reached it. +fn page_document(page: &str, packet: Option<&str>, catalog_extra: &str, more: &[&str], trailer_extra: &str) -> Document { + let metadata = match packet { + Some(packet) => xmp_object(packet), + None => "null".to_owned(), + }; + let catalog = match packet { + Some(_) => format!("<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R {catalog_extra} >>"), + None => format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + }; + let pages = "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>"; + let mut objs = vec![catalog.as_str(), pages, metadata.as_str(), page]; + objs.extend_from_slice(more); + let doc = build(&objs, trailer_extra); + assert_eq!(pdfcraft_model::pages(&doc).len(), 1, "the page of this case does not resolve"); + doc +} + +/// Asserts that `verify` reports `clause` for `doc`. +fn assert_reported(doc: &Document, level: Level, clause: &str) { + let issues = verify(doc, level); + assert!(issues.iter().any(|i| i.clause == clause), "no {clause} issue in {issues:#?}"); +} + +/// Asserts that `verify` reports nothing under `clause` for `doc`. +fn assert_not_reported(doc: &Document, level: Level, clause: &str) { + let found: Vec<_> = verify(doc, level).into_iter().filter(|i| i.clause == clause).collect(); + assert!(found.is_empty(), "{clause} was reported: {found:#?}"); +} + +#[test] +fn xmp_metadata_is_required_clause_6_6_2_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let identified = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&identified, Level::A2b, "6.6.2.1"); + assert_eq!(declared(&identified).pdfa, Some((2, "B".into()))); + let bare = page_document(page, None, "", &[], ""); + assert_reported(&bare, Level::A2b, "6.6.2.1"); +} + +#[test] +fn the_declared_part_and_conformance_must_be_the_level_clause_6_6_4() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let matching = a2b_packet(None); + let doc = page_document(page, Some(matching.as_str()), "", &[], ""); + assert_not_reported(&doc, Level::A2b, "6.6.4"); + for (part, conformance) in [(3, "B"), (2, "A")] { + let packet = xmp(part, conformance, None); + let doc = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_reported(&doc, Level::A2b, "6.6.4"); + assert_eq!(declared(&doc).pdfa, Some((part, conformance.to_owned()))); + } +} + +#[test] +fn the_xmp_title_must_match_the_information_dictionary_clause_6_6_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let info = "/Info << /Title (Report) >>"; + let matching = a2b_packet(Some("Report")); + let doc = page_document(page, Some(matching.as_str()), "", &[], info); + assert_not_reported(&doc, Level::A2b, "6.6.3"); + let differing = a2b_packet(Some("Draft")); + let doc = page_document(page, Some(differing.as_str()), "", &[], info); + assert_reported(&doc, Level::A2b, "6.6.3"); +} + +#[test] +fn the_trailer_must_not_name_encryption_clause_6_1_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.1.3"); + // An encrypted file's keys are not written out by hand here, so the /Encrypt dictionary comes + // from the object layer; the rule under test only asks whether the trailer names one. + let mut encrypted = page_document(page, Some(packet.as_str()), "", &[], ""); + let params = NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [7; 32], + }; + encrypted.set_encryption(¶ms).unwrap(); + assert_reported(&encrypted, Level::A2b, "6.1.3"); + let report = convert(&mut encrypted, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.1.3"), "{:#?}", report.remaining); +} + +#[test] +fn device_colour_needs_an_output_intent_clause_6_2_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let doc = page_document(page, Some(packet.as_str()), "", &[rgb.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.3"); + let none = content("q Q"); + let doc = page_document(page, Some(packet.as_str()), "", &[none.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.3"); +} + +#[test] +fn device_rgb_needs_an_rgb_output_intent_clause_6_2_4_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let srgb = hex_stream("/N 3", &icc::srgb()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), srgb.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.4.3"); + // The intent is there, so the neighbour rule about device colour without one stays quiet. + assert_not_reported(&doc, Level::A2b, "6.2.3"); + let cmyk = hex_stream("", &cmyk_profile_header()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), cmyk.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.4.3"); +} + +#[test] +fn the_catalog_must_not_have_additional_actions_clause_6_5_2() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.2"); + let acting = page_document(page, Some(packet.as_str()), "/AA << /WC << /S /GoTo >> >>", &[], ""); + assert_reported(&acting, Level::A2b, "6.5.2"); +} + +#[test] +fn the_catalog_must_not_have_document_javascript_clause_6_5_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.1"); + // An empty name tree is enough: an action object here would report the same clause, and this + // case is about the catalog's own entry. + let tree = "/Names << /JavaScript << /Names [] >> >>"; + let scripted = page_document(page, Some(packet.as_str()), tree, &[], ""); + assert_reported(&scripted, Level::A2b, "6.5.1"); +} + +#[test] +fn the_form_must_not_ask_viewers_for_appearances_clause_6_4_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "/AcroForm << /Fields [] >>", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.4.1"); + let asking = "/AcroForm << /Fields [] /NeedAppearances true >>"; + let doc = page_document(page, Some(packet.as_str()), asking, &[], ""); + assert_reported(&doc, Level::A2b, "6.4.1"); +} + +#[test] +fn forbidden_annotation_types_are_reported_clause_6_3_1() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let link = "<< /Type /Annot /Subtype /Link /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[link], ""); + assert_not_reported(&doc, Level::A2b, "6.3.1"); + let sound = "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[sound], ""); + assert_reported(&doc, Level::A2b, "6.3.1"); +} + +#[test] +fn annotations_must_print_and_not_hide_clause_6_3_2() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let printing = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[printing, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.2"); + let unprinted = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 0 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[unprinted, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); + let hidden = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 6 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[hidden, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); +} + +#[test] +fn annotations_need_an_appearance_stream_clause_6_3_3() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let with = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[with, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.3"); + let without = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[without], ""); + assert_reported(&doc, Level::A2b, "6.3.3"); +} + +#[test] +fn lzw_compressed_streams_are_reported_clause_6_1_7_2() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let text = content("BT /F1 12 Tf 10 10 Td (Hi) Tj ET"); + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FirstChar 72 /LastChar 105 /Widths [500 500] /FontDescriptor 7 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 8 0 R >>"; + let flate = font_program(Some("/FlateDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, flate.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.2"); + // The neighbour rule holds: the font still reads as embedded. + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let lzw = font_program(Some("/LZWDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, lzw.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.1.7.2"); +} + +#[test] +fn streams_must_not_read_external_files_clause_6_1_7_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let held = "<< /Length 4 >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[held], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.1"); + // A stream object nothing refers to: the rule looks at every stream in the file. + let external = "<< /Length 4 /F (data.bin) >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[external], ""); + assert_reported(&doc, Level::A2b, "6.1.7.1"); +} + +#[test] +fn images_must_not_be_interpolated_clause_6_2_8() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q 10 0 0 10 0 0 cm /Im0 Do Q"); + let smooth = image(true); + let doc = page_document(page, Some(packet.as_str()), "", &[smooth.as_str(), drawing.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.8"); + let crisp = image(false); + let doc = page_document(page, Some(packet.as_str()), "", &[crisp.as_str(), drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.8"); +} + +#[test] +fn postscript_xobjects_are_reported_clause_6_2_9() { + let plain = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"; + let with = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /PS0 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q Q"); + let doc = page_document(plain, Some(packet.as_str()), "", &[drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.9"); + let postscript = "<< /Type /XObject /Subtype /PS /Length 0 >>\nstream\n\nendstream"; + let doc = page_document(with, Some(packet.as_str()), "", &[drawing.as_str(), postscript], ""); + assert_reported(&doc, Level::A2b, "6.2.9"); +} + +#[test] +fn fonts_must_be_embedded_clause_6_2_11_4() { + let page = "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let embedded = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 7 0 R >>"; + let program = font_program(None); + let doc = page_document(page, Some(packet.as_str()), "", &[embedded, descriptor, program.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let missing = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[missing], ""); + assert_reported(&doc, Level::A2b, "6.2.11.4"); +} + +/// A file built by hand, apart from its output profile, with everything the verifier asks for: an +/// XMP packet naming PDF/A-2b with a title that matches the document information, an sRGB output +/// intent and a blank page. It checks that the rules agree with each other, which is not by itself a +/// conformance claim. +#[test] +fn minimal_document_has_no_verifier_issues() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(Some("Report")); + let profile = hex_stream("/N 3", &icc::srgb()); + let info = "/Info << /Title (Report) >>"; + let mut doc = page_document(page, Some(packet.as_str()), &output_intent(5), &[profile.as_str()], info); + let issues = verify(&doc, Level::A2b); + assert!(issues.is_empty(), "{issues:#?}"); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"]); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.remaining.is_empty(), "{:#?}", report.remaining); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); +} From 9ff881e533be68ae1a9f8d556dea79d6df39a30a Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:05 +0200 Subject: [PATCH 06/30] The PDF/A panel reports its own checks, not conformance (#6302) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/ui-egui/src/dialogs/standards_ui.rs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/standards_ui.rs b/crates/ui-egui/src/dialogs/standards_ui.rs index 11c0d1fd50..de6052b05c 100644 --- a/crates/ui-egui/src/dialogs/standards_ui.rs +++ b/crates/ui-egui/src/dialogs/standards_ui.rs @@ -35,7 +35,10 @@ impl PdfCraftApp { self.pdfa_verify(); let left = self.pdfa.issues.as_ref().map_or(0, Vec::len); self.notify(if left == 0 { - crate::i18n::fmt(tl!("The document now conforms to {level}; save it to keep the changes"), &[("level", level.label())]) + crate::i18n::fmt( + tl!("PdfCraft's checks now find no problems for {level}. This is not a validator result. Save to keep the changes."), + &[("level", level.label())], + ) } else if left == 1 { crate::i18n::fmt(tl!("Fixed what could be fixed; 1 problem remains"), &[]) } else { @@ -91,7 +94,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.label(egui::RichText::new(tl!("Verify to see what the document needs.")).color(t.text_muted)); } Some(v) if v.is_empty() => { - ui.label(crate::i18n::fmt(tl!("No problems found: the document conforms to {level}."), &[("level", app.pdfa.level.label())])); + ui.label(crate::i18n::fmt( + tl!("PdfCraft's checks found no problems for {level}. This is not a validator result."), + &[("level", app.pdfa.level.label())], + )); } Some(v) => { for i in v { @@ -129,3 +135,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool } close } + +#[cfg(test)] +mod wording_tests { + /// The Standards panel reports what PdfCraft's own checks found; only a run of an external + /// validator can state the stronger thing, and none is recorded here. + #[test] + fn no_standards_panel_wording_claims_conformance() { + let source = include_str!("standards_ui.rs"); + // Built in pieces so that this file does not match itself. + for phrase in [concat!("conform", "s to"), concat!("now ", "conforms")] { + assert!(!source.contains(phrase), "a standards message makes a claim only a validator run can make: {phrase}"); + } + } +} From 0d7ae1b0fc1fffa6d792193cddecff2920e03c46 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:10 +0200 Subject: [PATCH 07/30] Tests: a signed encrypted document's byte ranges cover its earlier revision (#6303) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/sign/tests/it/encrypted_signing.rs | 157 ++++++++++++++++++++++ crates/sign/tests/it/main.rs | 1 + 2 files changed, 158 insertions(+) create mode 100644 crates/sign/tests/it/encrypted_signing.rs diff --git a/crates/sign/tests/it/encrypted_signing.rs b/crates/sign/tests/it/encrypted_signing.rs new file mode 100644 index 0000000000..f1887ff358 --- /dev/null +++ b/crates/sign/tests/it/encrypted_signing.rs @@ -0,0 +1,157 @@ +//! The byte ranges of a signature on an encrypted document. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions}; +use pdfcraft_sign::{SignOptions, Status, TrustStore, pkcs12, signatures}; + +fn data(name: &str) -> Vec { + std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +/// One page with text and an unsigned signature field "Approval". +fn fixture() -> Vec { + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", + ]; + objs.extend_from_slice(more); + write_pdf(&objs) +} + +/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog. +fn write_pdf(objs: &[&[u8]]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(b: &[u8]) -> Document { + Document::open(Arc::new(b.to_vec())).unwrap() +} + +fn opts() -> SignOptions { + SignOptions { + page: 0, + rect: Some([20.0, 100.0, 220.0, 150.0]), + reason: Some("I approve this document".into()), + location: Some("London".into()), + date: "D:20261002120000+01'00'".into(), + ..SignOptions::default() + } +} + +/// `fixture()` protected with an empty user password (opens without asking, like most +/// "secured" forms) and an owner password, then saved. +fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec { + protected_with(alg, "", permissions) +} + +/// `fixture()` protected with the user password `user` and an owner password, then saved. +fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec { + let mut doc = open(&fixture()); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn open_pw(b: &[u8], pw: Option<&str>) -> Document { + Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap() +} + +const ALL_ALGORITHMS: [pdfcraft_cos::Algorithm; 4] = + [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256]; + +/// Where `needle` first appears in `haystack`. +fn find(haystack: &[u8], needle: &[u8], alg: pdfcraft_cos::Algorithm) -> usize { + haystack + .windows(needle.len()) + .position(|w| w == needle) + .unwrap_or_else(|| panic!("{alg:?}: {:?} is not in the added bytes", String::from_utf8_lossy(needle))) +} + +/// The four numbers of the signature dictionary's `/ByteRange` in the added revision. +fn byte_range(added: &[u8], alg: pdfcraft_cos::Algorithm) -> [usize; 4] { + let prefix = b"/ByteRange ["; + let at = find(added, prefix, alg) + prefix.len(); + let end = at + added[at..].iter().position(|b| *b == b']').unwrap_or_else(|| panic!("{alg:?}: the /ByteRange array is not closed")); + let text = std::str::from_utf8(&added[at..end]).unwrap_or_else(|_| panic!("{alg:?}: the /ByteRange numbers are not ASCII")); + let nums: Vec = + text.split_ascii_whitespace().map(|n| n.parse().unwrap_or_else(|_| panic!("{alg:?}: {n:?} is not a byte offset"))).collect(); + assert_eq!(nums.len(), 4, "{alg:?}: /ByteRange has {} numbers, not 4", nums.len()); + [nums[0], nums[1], nums[2], nums[3]] +} + +/// The signature dictionary's `/ByteRange` gives the offsets and lengths of the parts of the +/// file its digest covers. For a signature on an encrypted document that is the whole earlier +/// revision, its encrypted objects included, leaving out only the hex text of `/Contents`. +#[test] +fn signed_encrypted_documents_cover_their_earlier_revision() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for alg in ALL_ALGORITHMS { + let base = protected(alg, -1); + let doc = open_pw(&base, None); + let signed = pdfcraft_sign::sign(&doc, &id, &opts()).unwrap(); + assert!(signed.starts_with(&base), "{alg:?}: an incremental update"); + let added = &signed[base.len()..]; + // The hole: the `<...>` hex string of `/Contents`. + let at = base.len() + find(added, b"/Contents <", alg); + let hole_start = at + b"/Contents ".len(); + let hole_end = hole_start + signed[hole_start..].iter().position(|b| *b == b'>').expect("a closed /Contents string") + 1; + let [a, b, c, d] = byte_range(added, alg); + assert_eq!(a, 0, "{alg:?}: the covered range starts at the start of the file"); + assert_eq!(b, hole_start, "{alg:?}: the covered range ends at {hole_start}, where /Contents begins"); + assert_eq!(c, hole_end, "{alg:?}: the second covered range starts at {hole_end}, where /Contents ends"); + assert_eq!(c + d, signed.len(), "{alg:?}: the covered range ends at the file's end ({c} + {d} of {})", signed.len()); + assert!(hole_start >= base.len(), "{alg:?}: the hole at {hole_start} comes after the whole earlier revision of {} bytes", base.len()); + assert_eq!(signed[hole_start], b'<', "{alg:?}: the hole opens with '<' at {hole_start}"); + assert_eq!(signed[hole_end - 1], b'>', "{alg:?}: the hole closes with '>' at {}", hole_end - 1); + assert!( + signed[hole_start + 1..hole_end - 1].iter().all(u8::is_ascii_hexdigit), + "{alg:?}: /Contents between {hole_start} and {hole_end} is hexadecimal" + ); + let sigs = signatures(&open_pw(&signed, None), &signed, &trust); + let s = sigs.iter().find(|s| s.signed).expect("a signed field"); + assert_eq!(s.status, Status::Valid, "{alg:?}: {:?}", s.details); + // One changed byte inside the covered range, in the earlier revision. + let mut tampered = signed.clone(); + let flip = base.len() - 1; + tampered[flip] ^= 1; + let sigs = signatures(&open_pw(&tampered, None), &tampered, &trust); + let signed_sigs: Vec<_> = sigs.iter().filter(|s| s.signed).collect(); + assert!(!signed_sigs.is_empty(), "{alg:?}: the tampered file still lists its signature"); + assert!( + signed_sigs.iter().all(|s| s.status == Status::Invalid), + "{alg:?}: the byte changed at {flip}, inside the covered range, makes every signature invalid" + ); + } +} diff --git a/crates/sign/tests/it/main.rs b/crates/sign/tests/it/main.rs index 7b8790850b..d526b01e7c 100644 --- a/crates/sign/tests/it/main.rs +++ b/crates/sign/tests/it/main.rs @@ -5,5 +5,6 @@ mod ber; mod chain; mod cms; mod crypto; +mod encrypted_signing; mod pdf; mod store_keys; From a2c6e63d0a4e8b461ecda2aebd7942bca5ca1c6f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:15 +0200 Subject: [PATCH 08/30] Fuzz target for the JavaScript engine's script runner (#6304) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/js/fuzz/Cargo.toml | 24 +++++++++++++++++++++ crates/js/fuzz/fuzz_targets/run_script.rs | 26 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 crates/js/fuzz/Cargo.toml create mode 100644 crates/js/fuzz/fuzz_targets/run_script.rs diff --git a/crates/js/fuzz/Cargo.toml b/crates/js/fuzz/Cargo.toml new file mode 100644 index 0000000000..373028192e --- /dev/null +++ b/crates/js/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pdfcraft-js-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run run_script` +# from crates/js/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-js = { path = ".." } + +[[bin]] +name = "run_script" +path = "fuzz_targets/run_script.rs" +test = false +doc = false +bench = false diff --git a/crates/js/fuzz/fuzz_targets/run_script.rs b/crates/js/fuzz/fuzz_targets/run_script.rs new file mode 100644 index 0000000000..57ccb9ea66 --- /dev/null +++ b/crates/js/fuzz/fuzz_targets/run_script.rs @@ -0,0 +1,26 @@ +//! Feeds arbitrary bytes, read as a script, to the JavaScript run entry with fixed event and document values. +//! +//! The run's result is discarded: the target only checks that no input panics or hangs past the limits. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// The largest script the target runs (64 KiB); larger inputs are skipped. +const MAX_INPUT: usize = 64 << 10; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let script = String::from_utf8_lossy(data).into_owned(); + let event = Event { + name: "Keystroke".into(), + kind: "Field".into(), + target: Some("field1".into()), + value: "value".into(), + change: String::new(), + will_commit: true, + }; + let _outcome = run(&script, &event, &DocInfo::default(), &[], &[], Limits::default()); +}); From c15c909745242013d81e9a7bcea451ff918dc7d7 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:21 +0200 Subject: [PATCH 09/30] Fuzz target for redaction apply (#6305) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/redact/fuzz/Cargo.toml | 25 ++++++++++++++++++++++++ crates/redact/fuzz/fuzz_targets/apply.rs | 24 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 crates/redact/fuzz/Cargo.toml create mode 100644 crates/redact/fuzz/fuzz_targets/apply.rs diff --git a/crates/redact/fuzz/Cargo.toml b/crates/redact/fuzz/Cargo.toml new file mode 100644 index 0000000000..716aa7715a --- /dev/null +++ b/crates/redact/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pdfcraft-redact-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run apply` +# from crates/redact/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-redact = { path = ".." } + +[[bin]] +name = "apply" +path = "fuzz_targets/apply.rs" +test = false +doc = false +bench = false diff --git a/crates/redact/fuzz/fuzz_targets/apply.rs b/crates/redact/fuzz/fuzz_targets/apply.rs new file mode 100644 index 0000000000..347e5ed120 --- /dev/null +++ b/crates/redact/fuzz/fuzz_targets/apply.rs @@ -0,0 +1,24 @@ +//! Feeds arbitrary bytes, read as a PDF, to the redaction marks and the apply entry. +//! +//! Results are discarded: the target checks that no input panics. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_redact::{apply, marks}; + +/// The largest input the target opens (16 MiB); larger inputs are skipped. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _count = marks(&doc).len(); + let _result = apply(&mut doc, None); +}); From 9a95273c7c7f4485503f2d534ca5899948768bef Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:27 +0200 Subject: [PATCH 10/30] Fuzz targets for PDF/A verify and convert (#6308) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/fuzz/Cargo.toml | 32 +++++++++++++++++++ crates/preflight/fuzz/fuzz_targets/convert.rs | 21 ++++++++++++ crates/preflight/fuzz/fuzz_targets/verify.rs | 22 +++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 crates/preflight/fuzz/Cargo.toml create mode 100644 crates/preflight/fuzz/fuzz_targets/convert.rs create mode 100644 crates/preflight/fuzz/fuzz_targets/verify.rs diff --git a/crates/preflight/fuzz/Cargo.toml b/crates/preflight/fuzz/Cargo.toml new file mode 100644 index 0000000000..0e9e77f105 --- /dev/null +++ b/crates/preflight/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pdfcraft-preflight-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run verify` +# from crates/preflight/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-preflight = { path = ".." } + +[[bin]] +name = "verify" +path = "fuzz_targets/verify.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "convert" +path = "fuzz_targets/convert.rs" +test = false +doc = false +bench = false diff --git a/crates/preflight/fuzz/fuzz_targets/convert.rs b/crates/preflight/fuzz/fuzz_targets/convert.rs new file mode 100644 index 0000000000..6340bd585e --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/convert.rs @@ -0,0 +1,21 @@ +//! Feeds arbitrary bytes to `convert` at level A2b; the report it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, convert}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _ = convert(&mut doc, Level::A2b); +}); diff --git a/crates/preflight/fuzz/fuzz_targets/verify.rs b/crates/preflight/fuzz/fuzz_targets/verify.rs new file mode 100644 index 0000000000..7d02aa3ca4 --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/verify.rs @@ -0,0 +1,22 @@ +//! Feeds arbitrary bytes to `verify` at both levels; the issue list it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, verify}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _a2b = verify(&doc, Level::A2b); + let _a3b = verify(&doc, Level::A3b); +}); From 0c744357cb6e17bb5dfc5306eff7a58a2e652972 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:33 +0200 Subject: [PATCH 11/30] M13 batch: an action step that fails now fails its file (#6307) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/action_failures.rs | 66 +++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + crates/engine/src/actions.rs | 8 +-- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 crates/automation/tests/it/action_failures.rs diff --git a/crates/automation/tests/it/action_failures.rs b/crates/automation/tests/it/action_failures.rs new file mode 100644 index 0000000000..b487103a21 --- /dev/null +++ b/crates/automation/tests/it/action_failures.rs @@ -0,0 +1,66 @@ +//! A step that fails fails its file: the file is reported with an error and nothing is written +//! for it. A step that finds nothing to do is still not a failure. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use pdfcraft_engine::actions::{Action, Step, run_on}; +use serde_json::json; + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +fn one_step(step: Step) -> Action { + Action { name: "Custom".into(), description: String::new(), steps: vec![step], builtin: false } +} + +/// The standard fonts can't draw the character, so the watermark step fails and no file is produced. +#[test] +fn failing_watermark_step_fails_the_file_and_publishes_nothing() { + let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_err(), "a failed step must fail its file: {r:?}"); +} + +/// A comment step on a document with no comments has nothing to do, which is not a failure. +#[test] +fn nothing_to_do_step_still_succeeds() { + let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}"); +} + +/// Through the tool: the failing file gets an error entry and no output, and the folder stays empty. +#[test] +fn action_run_marks_the_failing_file_as_error_without_output() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("in"); + std::fs::create_dir_all(&input).unwrap(); + let in_file = input.join("one.pdf"); + std::fs::write(&in_file, pdf()).unwrap(); + let out = dir.path().join("out"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let reply = auto.call( + "action_run", + &json!({ + "paths": [in_file.to_string_lossy()], + "folder": out.to_string_lossy(), + "steps": [{ "step": "add_watermark", "arg": "中" }], + }), + ); + let report = match reply.unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected a report: {other:?}"), + }; + let entry = &report["files"][0]; + assert!(entry["error"].is_string(), "{report}"); + assert!(entry.get("output").is_none(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing is published for a failed file"); +} + +/// A removal step with nothing to remove keeps its existing note and succeeds. +#[test] +fn removal_step_with_nothing_to_remove_still_succeeds() { + let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 39f2b8a30d..a3cd55623c 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod action_failures; mod attachment_key; mod automation; mod automation_roots; diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index 3e942f6a2f..249279e09d 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step - // that didn't remove is: the file would be passed on as cleaned with the data still in it. + // A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is + // published for it: the file would otherwise be passed on without the step it was asked to apply. let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), - Err(e) if removes => return Err(e), - Err(e) => log.push(format!("{}: {e}", step.label())), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())), + Err(e) => return Err(e), } Ok(None) } From ae893c1517d2d6281547c1bf902a403aa1094703 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:13:45 +0200 Subject: [PATCH 12/30] M13 batch: correct parity status and scope the no-replace sentence (#6299) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- docs/cli-batch.md | 4 +++- parity/acrobat-features.toml | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/cli-batch.md b/docs/cli-batch.md index ad2982bb14..dd0339cd12 100644 --- a/docs/cli-batch.md +++ b/docs/cli-batch.md @@ -42,12 +42,14 @@ only `.pdf` inputs are processed. Unsupported extensions are reported as skipped remaining files continue. Earlier successful outputs stay, and unprocessed files are listed as skipped. A file is published only after all its steps succeed. -Existing output names are never replaced, even by concurrent batch writers: +For `batch`, existing output names are never replaced, even by concurrent batch writers: `book.pdf`, `book (2).pdf`, and so on. A temporary file in the destination folder is published without overwriting. Sources are never moved or deleted. An output folder inside an input folder is excluded from discovery, including on reruns. The output folder cannot equal or contain a selected input folder. +The Action Wizard (`action_run`) and folder OCR (`ocr_recognize_files`) do not give this guarantee yet. + The report contains `completed`, `failed`, `skipped`, `stopped` and `files`. Each file records its status, output and step results, or the error/skip reason. The `batch` command exits nonzero on any file failure while still printing the diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9635898230..2e82a0c430 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7844,11 +7844,11 @@ title = "Optimize multiple files" area = "K" tier = "P2" milestone = "M13" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_batch"] +evidence = ["apps/pdfcraft-cli/tests/it/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"] +notes = "Headless and CLI only: doc_batch with the optimize step (`pdfcraft-cli batch --steps optimize`, or `convert,optimize` as the cited test runs) over files and folders. No GUI batch dialog, no report file, no parallel workers, no resume. See docs/cli-batch.md." [[feature]] id = "optimize.preflight" @@ -9452,11 +9452,11 @@ title = "Run guided actions on files" area = "N" tier = "P1" milestone = "M13" -status = "shipped" +status = "partial" commands = ["actions.wizard"] tools = ["action_list", "action_run"] evidence = ["crates/ui-egui/tests/it/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] -notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress." +notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress. Known gap: a failing non-removal step still publishes its output as success (crates/engine/src/actions.rs:200-208)." [[feature]] id = "misc.action-wizard-create" From f9c150c17c2f717e0795b0820b1d5d4fba79f660 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:24 +0200 Subject: [PATCH 13/30] Add IBAN matching to Search and Redact patterns (#4332) * Add IBAN matching to Search and Redact patterns The Pattern/PATTERNS/from_id interface is consumed generically by redact_mark and the UI pattern combobox through the engine re-exports, so one new variant and matcher exposes IBAN in Search and Redact everywhere. A unit test shows valid compact and spaced IBANs are marked while wrong check digits, short runs, unknown countries, letter check digits and token-glued IBANs are not. Add an Iban variant to Pattern, list it in PATTERNS, handle it in label, id and find, and add an iban matcher with a country-code length table, compact or group-of-four-spaced acceptance, alphanumeric start and end boundaries and ISO 7064 mod-97 check-digit validation. Test: iban_matches_compact_spaced_and_rejects_near_matches failed before the change and passes after. * Address review for pull request 4332 * Address review for pull request 4332 * review: address CodeRabbit findings - patterns.rs: fix SWIFT registry IBAN lengths EG 27->29, LY 27->25, SD 24->18 and add NI 28, BI 27, DJ 27, OM 23 (crates/redact/src/patterns.rs:545,549,551,558-561) - patterns.rs: state table source as SWIFT IBAN registry without claiming full ISO 13616 coverage (crates/redact/src/patterns.rs:475) - patterns.rs: regression test with valid registry examples for EG, LY, SD, NI, BI, DJ, OM and a wrong-length EG rejection (crates/redact/src/patterns.rs:892) * Complete the IBAN country table and accept spaces only between groups The country table now lists all 89 countries of the SWIFT IBAN registry (release 99) as one sorted constant. Honduras, Russia, Yemen, Falkland Islands, Mongolia and Somalia were missing. A space is accepted only after a full group of four characters, so a code followed by ordinary words ("BA37 leaves at noon") ends the run instead of being tried against the check digits. Tests: the registry example of every country matches compact and printed, changes to one digit are rejected, and the table and the examples agree. * Advertise the IBAN pattern in the redact_mark tool schema The pattern enum in the advertised schema was kept by hand and still listed phone, email, credit-card, ssn and date, so clients that obey the schema could not select iban, which the runtime already accepted. The enum is now built from the engine's pattern registry, so it cannot drift from it again, and the description names iban. Tests: tools/list advertises exactly the registry's pattern ids and names each in the description, and an iban mark made through the tool covers the spaced German sample and nothing else. --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/automation/src/tools.rs | 12 +- crates/automation/tests/it/automation.rs | 48 +++ crates/redact/src/patterns.rs | 358 ++++++++++++++++++++++- 3 files changed, 414 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bf2abea185..54d94ff4bf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; +use pdfcraft_engine::REDACT_PATTERNS; + use crate::ToolError; #[derive(Clone, Debug)] @@ -30,6 +32,12 @@ fn pages(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 1, "description": format!("1-based page numbers {what}.") }) } +/// The Search & Redact pattern ids, from the engine's registry, so the advertised schema cannot +/// drift from the patterns the runtime accepts. +fn pattern_enum() -> Value { + json!({ "type": "string", "enum": REDACT_PATTERNS.iter().map(|p| p.id()).collect::>() }) +} + fn path(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": format!("{what}: 1-based positions from the top level, e.g. [2, 1] = the first child of the second bookmark.") }) } @@ -605,7 +613,7 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ @@ -616,7 +624,7 @@ pub fn tools() -> Vec { "words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 }, "case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." }, "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, - "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, + "pattern": pattern_enum(), "whole_pages": { "type": "boolean" }, "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), "overlay": { "type": "string" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6f493b824d..5ed32360fd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -1715,6 +1715,25 @@ fn mcp_default_lists_every_tool_and_has_no_meta_tools() { assert_eq!(rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search" }))["error"]["code"], -32602); } +#[cfg(feature = "mcp")] +#[test] +/// `tools/list` advertises exactly the Search & Redact patterns the registry supports, in `pattern` +/// and by name in the description, so a schema-obeying client can select every one of them. +fn the_advertised_redact_mark_schema_offers_every_pattern() { + use pdfcraft_engine::REDACT_PATTERNS; + let registry: Vec<&str> = REDACT_PATTERNS.iter().map(|p| p.id()).collect(); + let mut s = McpServer::new(Automation::new()); + let list = rpc(&mut s, 1, "tools/list", json!({})); + let mark = list["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "redact_mark").expect("redact_mark is listed"); + let advertised: Vec<&str> = + mark["inputSchema"]["properties"]["pattern"]["enum"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(advertised, registry, "the advertised pattern enum must be the registry's ids"); + let description = mark["description"].as_str().unwrap(); + for id in ®istry { + assert!(description.contains(id), "the description must name {id}: {description}"); + } +} + #[cfg(feature = "mcp")] #[test] fn mcp_compact_lists_the_core_tools_and_two_meta_tools() { @@ -4809,6 +4828,35 @@ fn redacting_italian_international_slash_numbers_through_tools() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +/// A client obeying the advertised schema can select iban, and the mark made through the tool +/// covers the spaced number and nothing else. +fn redacting_an_iban_through_tools() { + let table = tools(); + let advertised: Vec<&str> = table.iter().find(|t| t.name == "redact_mark").unwrap().input_schema["properties"]["pattern"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(advertised.contains(&"iban"), "the advertised redact_mark schema must offer iban: {advertised:?}"); + let dir = workdir("redact-iban"); + std::fs::write(dir.join("invoice.txt"), "Pay DE89 3704 0044 0532 0130 00 today\nAccount 12345678 is ours\nTotal due").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "invoice.txt" }))["doc"].as_u64().unwrap(); + let marked = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "iban" })); + assert_eq!(marked["marked"], 1, "the spaced German sample IBAN needs one mark"); + assert!(page_text(&mut a, doc)[0].contains("DE89 3704 0044 0532 0130 00"), "marks alone remove nothing"); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": doc }))["applied"], 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "redacted.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap(); + let text = &page_text(&mut a, reopened)[0]; + assert!(!text.contains("DE89") && !text.contains("0532"), "{text}"); + assert!(text.lines().any(|line| line.trim() == "Pay"), "only the label before the number remains: {text}"); + assert!(text.contains("Account 12345678") && text.contains("Total due"), "text without an IBAN remains: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// One page with the text "Secret 1234" and a Redact annotation that has neither a usable /// QuadPoints array nor a Rect, so there is no area to redact. fn redact_pdf_with_unreadable_mark() -> Vec { diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 59e623a7e7..9662602671 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -1,5 +1,5 @@ //! Search & Redact patterns (Acrobat's Find Text ▸ Patterns): phone numbers, email addresses, -//! credit card numbers, US Social Security numbers and dates. Each matcher works on a page's +//! credit card numbers, US Social Security numbers, dates and IBANs. Each matcher works on a page's //! text as characters and returns character ranges; callers map them to glyphs and areas. use std::ops::Range; @@ -11,9 +11,10 @@ pub enum Pattern { CreditCard, Ssn, Date, + Iban, } -pub const PATTERNS: [Pattern; 5] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date]; +pub const PATTERNS: [Pattern; 6] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date, Pattern::Iban]; impl Pattern { pub fn label(self) -> &'static str { @@ -23,6 +24,7 @@ impl Pattern { Pattern::CreditCard => "Credit Cards", Pattern::Ssn => "Social Security Numbers", Pattern::Date => "Dates", + Pattern::Iban => "IBAN Numbers", } } @@ -33,6 +35,7 @@ impl Pattern { Pattern::CreditCard => "credit-card", Pattern::Ssn => "ssn", Pattern::Date => "date", + Pattern::Iban => "iban", } } @@ -397,6 +400,173 @@ fn date(s: &[char], i: usize) -> Option { Some(k2 + y - i) } +/// An IBAN (ISO 13616): a two-letter country code, two check digits and the rest +/// of the country's exact length in letters and digits, either compact or in the +/// printed groups of four. A space is accepted only after a full group, so an +/// ordinary word after a code ("BA37 leaves at noon") ends the run. Valid iff the +/// number is one modulo 97 after moving the first four characters to the end. The +/// country code must be in the registry, and an alphanumeric character glued to +/// the end rejects the match. +fn iban(s: &[char], i: usize) -> Option { + if !(s.get(i).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 1).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 2).copied().is_some_and(|c| c.is_ascii_digit()) + && s.get(i + 3).copied().is_some_and(|c| c.is_ascii_digit())) + { + return None; + } + // Not the tail of a longer alphanumeric run. + if s.get(i.wrapping_sub(1)).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + let country: String = s.iter().skip(i).take(2).map(char::to_ascii_uppercase).collect(); + let len = iban_length(&country)?; + // The IBAN's characters uppercased, read up to the country's length. + let mut digits = Vec::with_capacity(len); + let mut j = i; + let mut after_space = false; + while digits.len() < len { + match s.get(j) { + Some(c) if c.is_ascii_alphanumeric() => { + digits.push(c.to_ascii_uppercase()); + after_space = false; + } + // A single space between two groups of four. + Some(&' ') if digits.len() % 4 == 0 && !after_space => after_space = true, + _ => return None, + } + j += 1; + } + // Reject an IBAN glued directly to trailing alphanumerics ("...00ABC"). + // A space-separated suffix ("...00 123") is not part of the IBAN. + if s.get(j).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + iban_check(&digits).then_some(j - i) +} + +/// Whether `digits` is a valid IBAN: move the first four characters to the end, +/// replace letters by two digits (A = 10, ..., Z = 35), and the number must be +/// one modulo 97. +fn iban_check(digits: &[char]) -> bool { + let mut rem = 0u32; + for c in digits.iter().skip(4).chain(digits.iter().take(4)).copied() { + let value = iban_value(c); + // A letter stands for two decimal digits (A = 10, ..., Z = 35), + // a digit for one. + rem = if c.is_ascii_digit() { (rem * 10 + value) % 97 } else { (rem * 100 + value) % 97 }; + } + rem == 1 +} + +/// The value of an IBAN character: a digit is itself, a letter is 10 (A) to 35 (Z). +fn iban_value(c: char) -> u32 { + match c { + '0'..='9' => c as u32 - '0' as u32, + _ => c as u32 - 'A' as u32 + 10, + } +} + +/// The exact length of an IBAN for each country code of the SWIFT IBAN registry +/// (release 99, December 2024), counting letters and digits only. A country code +/// that is not listed has no IBANs, so nothing starting with it is matched. +const IBAN_LENGTHS: [(&str, usize); 89] = [ + ("AD", 24), + ("AE", 23), + ("AL", 28), + ("AT", 20), + ("AZ", 28), + ("BA", 20), + ("BE", 16), + ("BG", 22), + ("BH", 22), + ("BI", 27), + ("BR", 29), + ("BY", 28), + ("CH", 21), + ("CR", 22), + ("CY", 28), + ("CZ", 24), + ("DE", 22), + ("DJ", 27), + ("DK", 18), + ("DO", 28), + ("EE", 20), + ("EG", 29), + ("ES", 24), + ("FI", 18), + ("FK", 18), + ("FO", 18), + ("FR", 27), + ("GB", 22), + ("GE", 22), + ("GI", 23), + ("GL", 18), + ("GR", 27), + ("GT", 28), + ("HN", 28), + ("HR", 21), + ("HU", 28), + ("IE", 22), + ("IL", 23), + ("IQ", 23), + ("IS", 26), + ("IT", 27), + ("JO", 30), + ("KW", 30), + ("KZ", 20), + ("LB", 28), + ("LC", 32), + ("LI", 21), + ("LT", 20), + ("LU", 20), + ("LV", 21), + ("LY", 25), + ("MC", 27), + ("MD", 24), + ("ME", 22), + ("MK", 19), + ("MN", 20), + ("MR", 27), + ("MT", 31), + ("MU", 30), + ("NI", 28), + ("NL", 18), + ("NO", 15), + ("OM", 23), + ("PK", 24), + ("PL", 28), + ("PS", 29), + ("PT", 25), + ("QA", 29), + ("RO", 24), + ("RS", 22), + ("RU", 33), + ("SA", 24), + ("SC", 31), + ("SD", 18), + ("SE", 24), + ("SI", 19), + ("SK", 24), + ("SM", 27), + ("SO", 23), + ("ST", 25), + ("SV", 28), + ("TL", 23), + ("TN", 24), + ("TR", 26), + ("UA", 29), + ("VA", 22), + ("VG", 24), + ("XK", 20), + ("YE", 30), +]; + +/// The length of an IBAN of `country`, if the registry lists it. +fn iban_length(country: &str) -> Option { + IBAN_LENGTHS.iter().find(|(code, _)| *code == country).map(|&(_, len)| len) +} + /// Character ranges of `text` matching `pattern`. pub fn find(pattern: Pattern, text: &[char]) -> Vec> { let f: fn(&[char], usize) -> Option = match pattern { @@ -405,6 +575,7 @@ pub fn find(pattern: Pattern, text: &[char]) -> Vec> { Pattern::CreditCard => credit_card, Pattern::Ssn => ssn, Pattern::Date => date, + Pattern::Iban => iban, }; let mut out = Vec::new(); let mut i = 0; @@ -665,4 +836,187 @@ mod tests { let s: Vec = "\u{130}\u{131}I\u{15E}".chars().collect(); assert_eq!(s.iter().copied().map(fold).collect::(), "iii\u{15F}"); } + + #[test] + fn iban_matches_compact_spaced_and_rejects_near_matches() { + assert_eq!(Pattern::from_id("iban").map(Pattern::id), Some("iban")); + + // Spaced and compact IBANs match, a wrong check digit is rejected ("DE89 + // 3704 0044 0532 0130 00" and "DE89370400440532013000" match, "...01" + // does not). + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Spaced IBAN (valid German IBAN: DE89 3704 0044 0532 0130 00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00"), vec!["DE89 3704 0044 0532 0130 00"]); + // Compact IBAN (same number, no spaces) + assert_eq!(found(iban_pattern, "DE89370400440532013000"), vec!["DE89370400440532013000"]); + // Wrong check digit should not match (DE89...01 instead of DE89...00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 01"), Vec::::new()); + // Near match: IBAN followed by extra digits should match only the IBAN + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_rejects_unknown_country_code_wrong_length_and_glued_tail() { + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Unknown country code: the check digits pass mod-97, but ISO 13616 defines no length for XX. + assert_eq!(found(iban_pattern, "XX65000000000000000000"), Vec::::new()); + // DE is 22 characters long. This 15-character number passes mod-97 but has + // the wrong national length, so it must be rejected. + assert_eq!(found(iban_pattern, "DE8300000000000"), Vec::::new()); + // A valid IBAN glued directly to trailing alphanumerics is not an IBAN. + assert_eq!(found(iban_pattern, "DE89370400440532013000ABC"), Vec::::new()); + assert_eq!(found(iban_pattern, "ABCDE89370400440532013000"), Vec::::new()); + // Letters in place of the two check digits. + assert_eq!(found(iban_pattern, "DEAB 3704 0044 0532 0130 00"), Vec::::new()); + // A space-separated suffix does not glue to the IBAN. + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_accepts_spaces_only_between_groups_of_four() { + // A code followed by ordinary words: the words end the run, so nothing is marked. + for s in [ + "Flight BA37 leaves at noon from gate four", + "Flight BA30 leaves at noon", + "Gate BA45 leaves at noon", + "BA72 have water are word", + "GB00 their an her make that", + "SI30 write can all more", + ] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // A valid IBAN with a space in the wrong place, or two spaces, is not marked. + for s in ["DE89 3704 0044 0532 013 000", "DE89 3704 0044 0532 0 13000", "DE8 9370 4004 4053 2013 000", "DE89 3704 0044 0532 0130 00"] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // Any group boundary may be a space or not, and the IBAN inside a sentence is marked alone. + assert_eq!(found(Pattern::Iban, "DE893704 0044 0532013000"), vec!["DE893704 0044 0532013000"]); + assert_eq!(found(Pattern::Iban, "Pay to DE89 3704 0044 0532 0130 00 today"), vec!["DE89 3704 0044 0532 0130 00"]); + } + + /// One example IBAN per country of the SWIFT IBAN registry (release 99). + const IBAN_REGISTRY_EXAMPLES: [&str; IBAN_LENGTHS.len()] = [ + "AD1200012030200359100100", + "AE070331234567890123456", + "AL47212110090000000235698741", + "AT611904300234573201", + "AZ21NABZ00000000137010001944", + "BA391290079401028494", + "BE68539007547034", + "BG80BNBG96611020345678", + "BH67BMAG00001299123456", + "BI4210000100010000332045181", + "BR1800360305000010009795493C1", + "BY13NBRB3600900000002Z00AB00", + "CH9300762011623852957", + "CR05015202001026284066", + "CY17002001280000001200527600", + "CZ6508000000192000145399", + "DE89370400440532013000", + "DJ2100010000000154000100186", + "DK5000400440116243", + "DO28BAGR00000001212453611324", + "EE382200221020145685", + "EG380019000500000000263180002", + "ES9121000418450200051332", + "FI2112345600000785", + "FK88SC123456789012", + "FO6264600001631634", + "FR1420041010050500013M02606", + "GB29NWBK60161331926819", + "GE29NB0000000101904917", + "GI75NWBK000000007099453", + "GL8964710001000206", + "GR1601101250000000012300695", + "GT82TRAJ01020000001210029690", + "HN88CABF00000000000250005469", + "HR1210010051863000160", + "HU42117730161111101800000000", + "IE29AIBK93115212345678", + "IL620108000000099999999", + "IQ98NBIQ850123456789012", + "IS140159260076545510730339", + "IT60X0542811101000000123456", + "JO94CBJO0010000000000131000302", + "KW81CBKU0000000000001234560101", + "KZ86125KZT5004100100", + "LB62099900000001001901229114", + "LC55HEMM000100010012001200023015", + "LI21088100002324013AA", + "LT121000011101001000", + "LU280019400644750000", + "LV80BANK0000435195001", + "LY83002048000020100120361", + "MC5811222000010123456789030", + "MD24AG000225100013104168", + "ME25505000012345678951", + "MK07250120000058984", + "MN121234123456789123", + "MR1300020001010000123456753", + "MT84MALT011000012345MTLCAST001S", + "MU17BOMM0101101030300200000MUR", + "NI45BAPR00000013000003558124", + "NL91ABNA0417164300", + "NO9386011117947", + "OM810180000001299123456", + "PK36SCBL0000001123456702", + "PL61109010140000071219812874", + "PS92PALS000000000400123456702", + "PT50000201231234567890154", + "QA58DOHB00001234567890ABCDEFG", + "RO49AAAA1B31007593840000", + "RS35260005601001611379", + "RU0304452522540817810538091310419", + "SA0380000000608010167519", + "SC18SSCB11010000000000001497USD", + "SD2129010501234001", + "SE4550000000058398257466", + "SI56263300012039086", + "SK3112000000198742637541", + "SM86U0322509800000000270100", + "SO211000001001000100141", + "ST68000100010051845310112", + "SV62CENR00000000000000700025", + "TL380080012345678910157", + "TN5910006035183598478831", + "TR330006100519786457841326", + "UA213223130000026007233566001", + "VA59001123000012345678", + "VG96VPVG0000012345678901", + "XK051212012345678906", + "YE15CBYE0001018861234567891234", + ]; + + /// `iban` in the printed form: groups of four separated by single spaces. + fn printed(iban: &str) -> String { + iban.chars().collect::>().chunks(4).map(|group| group.iter().collect::()).collect::>().join(" ") + } + + #[test] + fn iban_registry_examples_cover_every_country_and_length() { + let from_examples: Vec<(&str, usize)> = IBAN_REGISTRY_EXAMPLES.iter().map(|e| (&e[..2], e.len())).collect(); + assert_eq!(from_examples.as_slice(), IBAN_LENGTHS.as_slice()); + } + + #[test] + fn iban_matches_the_registry_example_of_every_country() { + for example in IBAN_REGISTRY_EXAMPLES { + assert_eq!(found(Pattern::Iban, example), vec![example], "compact {example}"); + let spaced = printed(example); + assert_eq!(found(Pattern::Iban, &spaced), vec![spaced.clone()], "printed {spaced}"); + } + } + + #[test] + fn iban_rejects_a_registry_example_with_one_digit_changed() { + for example in IBAN_REGISTRY_EXAMPLES { + // The last digit becomes the next digit, so the country, the length and the layout stay the same. + let Some(at) = example.rfind(|c: char| c.is_ascii_digit()) else { continue }; + let digit = example[at..=at].parse::().unwrap_or(0); + let changed = format!("{}{}{}", &example[..at], (digit + 1) % 10, &example[at + 1..]); + assert_eq!(found(Pattern::Iban, &changed), Vec::::new(), "{changed}"); + } + } } From b3a90f07f3986f63cc2b19a986f7eb81de86958f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:34 +0200 Subject: [PATCH 14/30] Parity: titles and the PDF/X row say only what is implemented (#6300) * Parity: titles and the PDF/X row say only what is implemented * Parity: the PDF/X row says removal matches the pdfxid: prefix only Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/pdf-spec-parity.md | 5 +++-- parity/acrobat-features.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index aefc959698..e25c30067e 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -2,7 +2,7 @@ > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -121,7 +121,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | PDF/A-1a/b (ISO 19005-1) | verify, convert | none | | | PDF/A-2a/b/u, PDF/A-3a/b/u | verify, convert | **partial**: 2b and 3b structural rules and conversion (sRGB intent; not CMYK) | | | PDF/A-4, 4e, 4f | verify, convert | none | | -| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | none | | +| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | **partial**: PDF/X declarations are carried through Combine and can be removed (`standards_remove_id`, which matches the `pdfxid:` prefix only); no verifier, no converter | | | PDF/UA-1, PDF/UA-2 (ISO 14289) | verify (Preflight), checker | Accessibility Checker (32 rules, Acrobat's own), no PDF/UA verification | | | PDF/E, PDF/VT | verify, convert | none | | | Preflight (profiles, fixups, reports) | hundreds of profiles | none | | @@ -132,6 +132,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter. No feature status changed | | 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | | 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | | 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e82a0c430..f71546b731 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7996,7 +7996,7 @@ notes = "" [[feature]] id = "optimize.pdfa-validate" -title = "Verify PDF/A compliance (1-4)" +title = "Verify PDF/A compliance (2b and 3b so far; 1, 2a, 2u, 3a, 3u, 4 not yet)" area = "K" tier = "P1" milestone = "M11" @@ -8008,7 +8008,7 @@ notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent [[feature]] id = "optimize.pdfa-convert" -title = "Save as PDF/A (1b, 2b, 3b, 4)" +title = "Save as PDF/A (2b and 3b so far)" area = "K" tier = "P1" milestone = "M11" From bb9da1c1c2b65c8cc80fbb3f1ebc282e33da5412 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:39 +0200 Subject: [PATCH 15/30] Convert replaces a removed forbidden annotation, so it is not reported again (#6306) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/src/lib.rs | 3 + crates/preflight/tests/convert_annotations.rs | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 crates/preflight/tests/convert_annotations.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1fb7272b33..5fdec67fac 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -877,6 +877,9 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// One page whose `/Annots` lists a Sound annotation (forbidden by clause 6.3.1) and a link. +fn page_with_a_forbidden_sound_annotation() -> Document { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Link /Rect [40 40 60 60] /P 3 0 R /F 4 /Border [0 0 0] >>", + ], + "", + ) +} + +/// The forbidden annotation is reported before `convert` and by neither `convert` nor a later +/// `verify` afterwards: the object it took off the page is no longer an annotation. +#[test] +fn convert_removes_the_forbidden_annotation_clause_6_3_1() { + let mut doc = page_with_a_forbidden_sound_annotation(); + let before = verify(&doc, Level::A2b); + assert!(before.iter().any(|i| i.clause == "6.3.1"), "the fixture starts with the finding: {before:#?}"); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.3.1"), "6.3.1 remains after convert: {:#?}", report.remaining); + let after = verify(&doc, Level::A2b); + assert!(!after.iter().any(|i| i.clause == "6.3.1"), "6.3.1 is reported after convert: {after:#?}"); +} + +/// The page keeps its other annotation: only the forbidden one leaves `/Annots`. +#[test] +fn convert_keeps_the_other_annotations_on_the_page() { + let mut doc = page_with_a_forbidden_sound_annotation(); + convert(&mut doc, Level::A2b).unwrap(); + let page = doc.get(ObjRef::new(3, 0)); + let dict = page.as_dict().expect("the page is a dictionary"); + let annots = doc.resolve(dict.get(b"Annots").expect("the page has /Annots")); + let kept: Vec = annots.as_array().expect("the page's /Annots is an array").iter().filter_map(Object::as_ref).collect(); + assert_eq!(kept, vec![ObjRef::new(5, 0)]); +} From ee39c7e2d01787c70c521c2a7392790498606cf0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:44 +0200 Subject: [PATCH 16/30] Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision (#6309) * Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision * Permissions: at revision 2, bits 11 and 12 mean nothing Table 22 defines them for revision 3 and later. A revision 2 file with bit 4 clear and bit 11 set was given page changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/crypt/src/lib.rs | 26 +++- crates/crypt/src/tests.rs | 6 +- crates/crypt/tests/it/main.rs | 1 + crates/crypt/tests/it/permissions_revision.rs | 126 ++++++++++++++++++ crates/engine/src/document.rs | 2 +- crates/engine/src/tests.rs | 4 +- 6 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 crates/crypt/tests/it/permissions_revision.rs diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index ff92d9af66..e697bc8553 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -82,17 +82,24 @@ pub enum Auth { User, } -/// Permission bits from `/P` (§7.6.4.2, Table 22). Owners have every permission. +/// Permission flags from `/P`, read the way the handler's revision reads them (ISO 32000-1:2008, +/// Table 22). Owners have every permission. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions { pub bits: i32, pub owner: bool, + /// The `/R` value of the handler that read `bits`. + pub revision: i64, } impl Permissions { fn bit(&self, n: u32) -> bool { self.owner || (self.bits >> (n - 1)) & 1 == 1 } + /// Revision 3 and later read the full flag list of Table 22; revision 2 has its own. + fn is_revision_3_or_later(&self) -> bool { + self.revision >= 3 + } pub fn print(&self) -> bool { self.bit(3) } @@ -107,17 +114,22 @@ impl Permissions { self.bit(6) } pub fn fill_forms(&self) -> bool { - self.bit(9) || self.bit(6) + if self.is_revision_3_or_later() { self.bit(9) || self.bit(6) } else { self.bit(6) } } + /// Revision 2 covers accessibility with the copy flag (bit 5); revision 3 and later with bit 10. pub fn extract_for_accessibility(&self) -> bool { - self.bit(10) + if self.is_revision_3_or_later() { self.bit(10) } else { self.bit(5) } } - /// Insert, rotate or delete pages; create bookmarks and thumbnails. + /// Insert, rotate or delete pages; create bookmarks and thumbnails. Revision 3 and later grant + /// it by bit 11 alone. Table 22 defines bit 11 for those revisions only, so at revision 2 the + /// modify flag (bit 4) covers it. pub fn assemble(&self) -> bool { - self.bit(11) || self.bit(4) + if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those + /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { - self.bit(12) + if self.is_revision_3_or_later() { self.print() && self.bit(12) } else { self.print() } } } @@ -250,7 +262,7 @@ impl SecurityHandler { /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); - Permissions { bits, owner: self.auth == Auth::Owner } + Permissions { bits, owner: self.auth == Auth::Owner, revision: self.dict.r } } /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 6064176d78..09a88ed129 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -240,11 +240,11 @@ fn unsupported_handlers_are_reported() { #[test] fn permission_bits_follow_table_22() { - let p = Permissions { bits: -3904, owner: false }; // 0xFFFFF0C0: only reserved bits set + let p = Permissions { bits: -3904, owner: false, revision: 3 }; // 0xFFFFF0C0: only reserved bits set assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble()); - let p = Permissions { bits: -1, owner: false }; + let p = Permissions { bits: -1, owner: false, revision: 3 }; assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms()); - let p = Permissions { bits: 0, owner: true }; + let p = Permissions { bits: 0, owner: true, revision: 3 }; assert!(p.modify(), "owners ignore /P"); } diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs index 8b39bf1956..e7742bbd75 100644 --- a/crates/crypt/tests/it/main.rs +++ b/crates/crypt/tests/it/main.rs @@ -1,4 +1,5 @@ //! The crate's integration tests, linked into one test binary. +mod permissions_revision; mod proptests; mod qpdf_literals; diff --git a/crates/crypt/tests/it/permissions_revision.rs b/crates/crypt/tests/it/permissions_revision.rs new file mode 100644 index 0000000000..bad709cd51 --- /dev/null +++ b/crates/crypt/tests/it/permissions_revision.rs @@ -0,0 +1,126 @@ +//! The permission accessors of `pdfcraft_crypt::Permissions`, checked against the flag table of +//! ISO 32000-1:2008 (Table 22). Expected values come from that table only. +//! +//! Bit n has the mask `1 << (n - 1)`. Readers use bits 3 to 6 and 9 to 12 and ignore the rest. + +use pdfcraft_crypt::{Algorithm, Auth, NewEncryption, Permissions, SecurityHandler, create}; + +const ID: &[u8] = b"0123456789abcdef"; +const T: bool = true; +const F: bool = false; + +/// The eight accessors in this order: print, high-quality print, modify, copy, accessibility, +/// annotate, fill forms, assemble. +fn cells(p: &Permissions) -> [bool; 8] { + [p.print(), p.print_high_quality(), p.modify(), p.copy(), p.extract_for_accessibility(), p.annotate(), p.fill_forms(), p.assemble()] +} + +/// Creates a file with `flags` at revision `revision`, opens it with the user password, and +/// returns the user's permissions. +fn user_view(alg: Algorithm, revision: i64, flags: i32) -> Permissions { + let made = create( + &NewEncryption { algorithm: alg, user_password: "user", owner_password: "owner", permissions: flags, encrypt_metadata: true, seed: [42; 32] }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.dict().r, revision, "the algorithm writes revision {revision}"); + let opened = SecurityHandler::open(made.dict().clone(), ID, Some("user")).expect("the user password opens the file"); + assert_eq!(opened.auth(), Auth::User, "the user password gives user access"); + opened.permissions() +} + +/// Revision 3 and later: each row is a flag word and the expected eight cells. +const REVISION_3_ROWS: &[(&str, i32, [bool; 8])] = &[ + ("every meaningful flag set, ignored flags clear", -196, [T, T, T, T, T, T, T, T]), + ("every flag set, ignored flags set too", -1, [T, T, T, T, T, T, T, T]), + ("reserved flags only", -4096, [F, F, F, F, F, F, F, F]), + ("ignored flags set, meaningful flags clear", -3901, [F, F, F, F, F, F, F, F]), + ("modify flag clear", -9, [T, T, F, T, T, T, T, T]), + ("copy flag clear", -17, [T, T, T, F, T, T, T, T]), + ("annotate flag clear", -33, [T, T, T, T, T, F, T, T]), + ("annotate and fill-existing flags clear", -289, [T, T, T, T, T, F, F, T]), + ("annotate flag set, fill-existing flag clear", -257, [T, T, T, T, T, T, T, T]), + ("accessibility flag clear", -513, [T, T, T, T, F, T, T, T]), + ("high-quality flag clear, print flag set", -2049, [T, F, T, T, T, T, T, T]), + ("bits 3 to 12 set, reserved bits clear", 4092, [T, T, T, T, T, T, T, T]), + ("positive flag word, bit 32 clear", 2_147_483_647, [T, T, T, T, T, T, T, T]), + ("revision 2 example read at revision 3", -44, [T, T, F, T, T, F, T, T]), +]; + +#[test] +fn revision_three_rows_hold_through_a_created_and_reopened_file() { + for (name, bits, want) in REVISION_3_ROWS { + assert_eq!(cells(&user_view(Algorithm::Rc4_128, 3, *bits)), *want, "{name}: flag word {bits:#010x}"); + } +} + +#[test] +fn owner_access_gives_every_flag() { + let made = create( + &NewEncryption { + algorithm: Algorithm::Rc4_128, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [42; 32], + }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.auth(), Auth::Owner, "the owner password gives owner access"); + assert_eq!(cells(&made.permissions()), [T; 8]); +} + +#[test] +fn revision_two_print_modify_copy_and_annotate_follow_the_table() { + for (bits, want) in [(-4, [T, T, T, T]), (-44, [T, F, T, F]), (-17, [T, T, F, T])] { + let p = user_view(Algorithm::Rc4_40, 2, bits); + assert_eq!([p.print(), p.modify(), p.copy(), p.annotate()], want, "revision 2, flag word {bits}"); + } +} + +#[test] +fn revision_two_with_every_flag_set_grants_accessibility_and_form_filling() { + let p = user_view(Algorithm::Rc4_40, 2, -4); + assert!(p.extract_for_accessibility(), "bit 5 is set, and at revision 2 it covers accessibility"); + assert!(p.fill_forms(), "bit 6 is set"); +} + +#[test] +fn revision_three_assembly_needs_its_own_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -1025); + assert!(p.modify(), "bit 4 is set"); + assert!(!p.assemble(), "assembly needs bit 11; bit 4 alone does not grant it"); +} + +#[test] +fn revision_three_high_quality_print_needs_the_print_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -5); + assert!(!p.print(), "bit 3 is clear"); + assert!(!p.print_high_quality(), "high-quality print needs bit 3 and bit 12"); +} + +#[test] +fn revision_two_accessibility_follows_the_copy_flag() { + let p = user_view(Algorithm::Rc4_40, 2, -17); + assert!(!p.copy(), "bit 5 is clear"); + assert!(!p.extract_for_accessibility(), "at revision 2 accessibility is bit 5"); +} + +#[test] +fn revision_two_form_filling_ignores_bit_nine() { + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.annotate(), "bit 6 is clear"); + assert!(!p.fill_forms(), "at revision 2 form filling is bit 6 alone"); +} + +#[test] +fn revision_two_ignores_the_flags_only_later_revisions_define() { + // Bit 4 is clear and bit 11 set: bit 11 means nothing at revision 2, so assembly stays refused. + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.assemble(), "at revision 2 assembly is bit 4 alone"); + // Bit 3 is set and bit 12 clear: printing at revision 2 has one quality. + let p = user_view(Algorithm::Rc4_40, 2, -2049); + assert!(p.print_high_quality(), "at revision 2 high-quality print is bit 3 alone"); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index fa98a18417..279dc8d563 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -539,7 +539,7 @@ impl Document { _ => "Attachments only", }; let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + let permissions = if pending { pdfcraft_cos::Permissions { owner: false, ..h.permissions() } } else { h.permissions() }; Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a20314c2e0..86fa6495ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -623,7 +623,7 @@ fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { activation: Activation3d::default(), name: String::new(), }; - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); } @@ -6048,7 +6048,7 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty()); session.redo(id).unwrap(); assert_eq!(session.get(id).unwrap().bytes, before); - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments"))); assert_eq!( check_permission( From f16b5d9331da5583fa9abdb973010f358aa5c60f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:52:06 +0200 Subject: [PATCH 17/30] M13 batch: test that results hold no planted secret, and record network-crate reachability (#6297) * M13 batch: test that results hold no planted secret, and record network-crate reachability * netcheck: list socket2, report a walk past the cap, test by the gate rule socket2 can open sockets, so it belongs in the list. A walk past the cap now fails instead of reading as unreachable. The baseline test follows check_rows, so a dependency update that moves a path no longer fails cargo test. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/it/batch_secrets.rs | 84 +++++ crates/automation/tests/it/main.rs | 1 + xtask/baselines/network-crates.json | 74 +++++ xtask/src/main.rs | 6 + xtask/src/netcheck.rs | 344 ++++++++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 crates/automation/tests/it/batch_secrets.rs create mode 100644 xtask/baselines/network-crates.json create mode 100644 xtask/src/netcheck.rs diff --git a/crates/automation/tests/it/batch_secrets.rs b/crates/automation/tests/it/batch_secrets.rs new file mode 100644 index 0000000000..08223f0955 --- /dev/null +++ b/crates/automation/tests/it/batch_secrets.rs @@ -0,0 +1,84 @@ +//! Tool results and errors from the folder tools carry no known test password. +//! +//! The passwords are set on the document and passed to the tool, and must never come back out: a +//! result that echoes one has leaked it into logs, transcripts and MCP callers. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{Changes, Edit, Protection, Session}; +use serde_json::{Value, json}; + +/// The known test passwords; neither may appear in a result or an error. +const OPEN_PASSWORD: &str = "PLANT-OPEN-5f2c9a"; +const OWNER_PASSWORD: &str = "PLANT-OWNER-71de0b"; + +/// A one-page PDF locked with the known test passwords. +/// +/// The open password is the only way in. The batch bates tool rewrites page content, and +/// `Changes::None` forbids that, so this fixture permits every change except extracting pages; +/// the restrictions stay real, since the open password never lifts them. +fn protected_pdf() -> Vec { + let mut session = Session::new(); + let bytes = session.create_from_text("Batch secrets", "Planted password test").unwrap(); + let id = session.open("locked.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some(OPEN_PASSWORD.into()), + permissions_password: Some(OWNER_PASSWORD.into()), + changes: Changes::AnyExceptExtract, + ..Default::default() + }), + ) + .unwrap(); + session.save_bytes(id).unwrap().as_ref().clone() +} + +fn json_result(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut content) => match content.remove(0) { + Content::Json(value) => value, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(error) => panic!("{tool} {args}: {error}"), + } +} + +#[test] +fn results_and_errors_hold_no_planted_password_for_bates_or_batch() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("locked.pdf"), protected_pdf()).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + + // The right open password: numbering succeeds and reports the numbers only. + let numbered = + json_result(&mut a, "doc_bates_multi", json!({"files":[{"input":"locked.pdf","output":"locked-numbered.pdf","password":OPEN_PASSWORD}]})); + assert_eq!(numbered["completed"], 1, "{numbered}"); + assert_eq!(numbered["files"][0]["status"], "ok", "{numbered}"); + // The report carries the per-file entry the Bates tool builds, so a field added to that entry + // lands in the text checked below. + assert_eq!(numbered["files"][0]["first"], 1, "{numbered}"); + assert!(numbered["files"][0]["output"].as_str().is_some_and(|path| path.ends_with("locked-numbered.pdf")), "{numbered}"); + assert!(dir.path().join("locked-numbered.pdf").exists(), "the numbered copy is written"); + + // The wrong open password: the tool refuses the file with an error. + let refused = match a + .call("doc_bates_multi", &json!({"files":[{"input":"locked.pdf","output":"locked-refused.pdf","password":"not-the-open-password"}]})) + { + Err(error) => error.to_string(), + Ok(value) => panic!("a wrong open password must be refused: {value:?}"), + }; + + // The folder job refuses the encrypted file without a password, in its per-file report. + let batch = json_result(&mut a, "doc_batch", json!({"paths":["locked.pdf"],"steps":["optimize"],"out_dir":"out"})); + assert_eq!(batch["failed"], 1, "{batch}"); + assert_eq!(batch["files"][0]["status"], "error", "{batch}"); + assert!(batch["files"][0]["error"].as_str().is_some_and(|text| !text.is_empty()), "{batch}"); + assert!(!dir.path().join("out/locked.pdf").exists(), "a refused file publishes nothing"); + + for report in [serde_json::to_string(&numbered).unwrap(), refused, serde_json::to_string(&batch).unwrap()] { + for secret in [OPEN_PASSWORD, OWNER_PASSWORD] { + assert!(!report.contains(secret), "a result or error echoed a planted password: {report}"); + } + } +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index a3cd55623c..13f24013cd 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -7,6 +7,7 @@ mod automation_roots; mod backdrop; mod batch; mod batch_failures; +mod batch_secrets; mod button_caption; mod form_bulk; mod hostile_args; diff --git a/xtask/baselines/network-crates.json b/xtask/baselines/network-crates.json new file mode 100644 index 0000000000..13f24c43a7 --- /dev/null +++ b/xtask/baselines/network-crates.json @@ -0,0 +1,74 @@ +[ + { + "crate": "pdfcraft-cli", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-cli", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-cli", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-cli", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-automation", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-automation", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-automation", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-automation", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-engine", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-engine", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-engine", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-engine", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + } +] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8e3255d5e6..01beb95ecf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,6 +10,7 @@ mod gates; mod layers; mod license_check; mod macos_integration; +mod netcheck; mod parity; mod screenshots; mod version; @@ -35,6 +36,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), ("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run), ("parity", "Validate parity/acrobat-features.toml against the registry, tools and tests; report progress (--json, --partial)", parity::run), + ( + "netcheck", + "Record which network-capable crates the batch crates can reach in Cargo.lock, and fail on any reachable pair not in xtask/baselines/network-crates.json", + netcheck::run, + ), ( "macos-integration", "Report macOS bundle, Quick Look, Spotlight and PDF integration diagnostics without mutating system state", diff --git a/xtask/src/netcheck.rs b/xtask/src/netcheck.rs new file mode 100644 index 0000000000..0d29a461f1 --- /dev/null +++ b/xtask/src/netcheck.rs @@ -0,0 +1,344 @@ +//! `cargo xtask netcheck`: which network-capable crates the batch path can reach. +//! +//! The batch folder job runs `pdfcraft-cli`, which drives `pdfcraft-automation` over +//! `pdfcraft-engine`; none of them may open a socket. That promise is only as strong as the +//! dependency graph behind those three packages, so this gate walks the resolved graph recorded +//! in `Cargo.lock` and asks, for each of them and each crate that can open a socket, whether that +//! crate is reachable from it. The answer is compared with a reviewed baseline in +//! `xtask/baselines/network-crates.json`: a reachable pair the baseline does not list fails. +//! +//! A lockfile records the resolved dependencies of a package without their kind and without a +//! target: normal, build and development edges look the same, and a dependency only one platform +//! uses is listed beside the rest. The answer here is therefore a superset of what a normal-level, +//! per-target walk would give. It may name a crate that is only a build or a development +//! dependency, or one that only another platform uses; it does not miss one, so it errs toward +//! failing and never toward passing. `--update-baseline` rewrites the baseline; review that diff +//! line by line. +//! +//! The walk is in process, over the lockfile text: it starts no other program and reads no +//! network. This gate is local on purpose, not part of `cargo xtask ci`, which would be a +//! workflow change. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::Path; + +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; + +use crate::gates::root; + +/// The packages the batch path is built from. +const PACKAGES: &[&str] = &["pdfcraft-cli", "pdfcraft-automation", "pdfcraft-engine"]; + +/// Crates that can open a socket: HTTP clients, TLS stacks, async runtimes, socket wrappers and +/// libcurl bindings. +const NETWORK_CRATES: &[&str] = &["curl", "hyper", "isahc", "minreq", "native-tls", "reqwest", "socket2", "tokio", "ureq"]; + +/// The resolved dependency graph of the workspace, relative to the repository root. +const LOCKFILE: &str = "Cargo.lock"; + +/// The reviewed baseline, relative to the repository root. +const BASELINE: &str = "xtask/baselines/network-crates.json"; + +/// The most packages one walk will visit. A lock file has a few hundred packages; the cap stops a +/// damaged file from turning the walk into a long loop. +const MAX_VISITED: usize = 500_000; + +/// What the lock file says about one package and one network-capable crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Row { + /// The package whose dependencies were walked. + #[serde(rename = "crate")] + package: String, + /// The network-capable crate asked about. + name: String, + /// Whether the package reaches the crate in the lock file graph. + reachable: bool, + /// The chain from the package to the crate, ` -> ` separated; empty when unreachable. + #[serde(default)] + path: String, +} + +/// The first word of a dependency entry: `"name"`, `"name 1.2.3"` and `"name 1.2.3 (source)"` +/// all name the same package. +fn entry_name(entry: &str) -> &str { + entry.split_whitespace().next().unwrap_or_default() +} + +/// The package a dependency line names, when the line is one. +fn dependency_name(line: &str) -> Option<&str> { + let quoted = line.strip_prefix('"')?; + let end = quoted.find('"')?; + let name = entry_name("ed[..end]); + (!name.is_empty()).then_some(name) +} + +/// The names in a `dependencies = ["a", "b"]` line, that is, the text between its brackets. +fn inline_names(value: &str) -> Vec<&str> { + value.split(',').filter_map(|item| dependency_name(item.trim())).collect() +} + +/// One edge of the graph: `package` depends on `dependency`. +fn add_edge(graph: &mut BTreeMap>, package: &str, dependency: &str) { + graph.entry(package.to_owned()).or_default().insert(dependency.to_owned()); +} + +/// The dependency graph recorded by a lock file, keyed by package name. +/// +/// Edges are by name: a lock file can hold two versions of one crate, and the walk only asks +/// whether a name can be reached at all. A file that names no package is an error rather than an +/// empty graph, so a format change fails loudly instead of reporting nothing reachable. +fn parse_lockfile(text: &str) -> anyhow::Result>> { + let mut graph: BTreeMap> = BTreeMap::new(); + let mut current: Option = None; + let mut in_list = false; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if in_list { + if line.starts_with(']') { + in_list = false; + } else if let Some(name) = dependency_name(line) + && let Some(package) = current.as_deref() + { + add_edge(&mut graph, package, name); + } + continue; + } + if line.starts_with("[[") { + current = None; + continue; + } + if let Some(name) = line.strip_prefix("name = \"").and_then(|rest| rest.strip_suffix('"')) { + graph.entry(name.to_owned()).or_default(); + current = Some(name.to_owned()); + continue; + } + if let Some(value) = line.strip_prefix("dependencies = [") { + match value.strip_suffix(']') { + Some(inner) => { + for name in inline_names(inner) { + if let Some(package) = current.as_deref() { + add_edge(&mut graph, package, name); + } + } + } + None => in_list = true, + } + } + } + if graph.is_empty() { + bail!("{LOCKFILE} names no package; the lock file format changed or the file is empty"); + } + Ok(graph) +} + +/// The shortest chain from `from` to `to` in the graph, both ends included. A walk past +/// [`MAX_VISITED`] is an error: it has no answer, and "unreachable" would pass the gate. +fn shortest_path(graph: &BTreeMap>, from: &str, to: &str) -> anyhow::Result>> { + let mut parents: BTreeMap = BTreeMap::new(); + let mut queue: VecDeque = VecDeque::from([from.to_owned()]); + let mut visited = 0usize; + while let Some(node) = queue.pop_front() { + visited = visited.saturating_add(1); + if visited > MAX_VISITED { + bail!("walking from {from} to {to} visited more than {MAX_VISITED} packages; is {LOCKFILE} damaged?"); + } + if node == to { + return Ok(Some(walk_back(&parents, node))); + } + let Some(next) = graph.get(&node) else { continue }; + for dependency in next { + if dependency != from && !parents.contains_key(dependency.as_str()) { + parents.insert(dependency.clone(), node.clone()); + queue.push_back(dependency.clone()); + } + } + } + Ok(None) +} + +/// The chain from the start of the walk to `node`, following the discovery parents. +fn walk_back(parents: &BTreeMap, node: String) -> Vec { + let mut chain = vec![node.clone()]; + let mut current = node; + while let Some(parent) = parents.get(¤t) { + chain.push(parent.clone()); + current = parent.clone(); + } + chain.reverse(); + chain +} + +/// One row per package and network-capable crate. +fn rows(graph: &BTreeMap>) -> anyhow::Result> { + let mut rows = Vec::with_capacity(PACKAGES.len().saturating_mul(NETWORK_CRATES.len())); + for package in PACKAGES { + for name in NETWORK_CRATES { + let path = shortest_path(graph, package, name)?; + let path = path.map(|chain| chain.join(" -> ")); + let reachable = path.is_some(); + let row = Row { package: (*package).to_owned(), name: (*name).to_owned(), reachable, path: path.unwrap_or_default() }; + rows.push(row); + } + } + Ok(rows) +} + +/// Write the reviewed baseline: the reachable pairs only, so a reader sees exactly the accepted +/// answers and nothing else. +fn write_baseline(path: &Path, rows: &[Row]) -> anyhow::Result<()> { + let reachable: Vec<&Row> = rows.iter().filter(|r| r.reachable).collect(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&reachable)? + "\n"; + std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; + println!("baseline updated: {} rows in {BASELINE}", reachable.len()); + Ok(()) +} + +fn read_baseline(path: &Path) -> anyhow::Result> { + let text = + std::fs::read_to_string(path).with_context(|| format!("{} is missing: run `cargo xtask netcheck --update-baseline`", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +/// The reachable pairs of a row list, for set comparison. +fn pairs(rows: &[Row]) -> BTreeSet<(&str, &str)> { + rows.iter().filter(|r| r.reachable).map(|r| (r.package.as_str(), r.name.as_str())).collect() +} + +/// Compare the current answers with the reviewed baseline: a reachable pair the baseline does not +/// list fails, and a pair that stopped being reachable or moved to another path is reported for +/// the next baseline update. +fn check_rows(rows: &[Row], reviewed: &[Row]) -> anyhow::Result<()> { + let listed = pairs(reviewed); + let found = pairs(rows); + for row in rows.iter().filter(|r| r.reachable) { + if let Some(before) = reviewed.iter().find(|b| b.reachable && b.package == row.package && b.name == row.name) + && before.path != row.path + { + println!("path changed for {} -> {}: {} (was {})", row.package, row.name, row.path, before.path); + } + } + let gone: Vec = listed.difference(&found).map(|(package, name)| format!("{package} -> {name}")).collect(); + if !gone.is_empty() { + println!("no longer reachable (run --update-baseline to record it): {}", gone.join("; ")); + } + let new: Vec = found.difference(&listed).map(|(package, name)| format!("{package} -> {name}")).collect(); + if new.is_empty() { + println!("OK: all {} reachable pairs are in the reviewed baseline", found.len()); + return Ok(()); + } + for pair in &new { + println!(" - {pair}"); + } + bail!("{} reachable network crate pair(s) are not in {BASELINE}; review them, then run `cargo xtask netcheck --update-baseline`", new.len()) +} + +pub fn run(args: &[String]) -> anyhow::Result<()> { + let update = args.iter().any(|a| a == "--update-baseline"); + let lockfile = root().join(LOCKFILE); + let text = std::fs::read_to_string(&lockfile).with_context(|| format!("reading {}", lockfile.display()))?; + let graph = parse_lockfile(&text)?; + if let Some(package) = PACKAGES.iter().find(|name| !graph.contains_key(**name)) { + bail!("{package} is not in {LOCKFILE}; is it still a workspace member?"); + } + let rows = rows(&graph)?; + let reachable = rows.iter().filter(|r| r.reachable).count(); + println!("netcheck: {} packages x {} network-capable crates; {reachable} reachable", PACKAGES.len(), NETWORK_CRATES.len()); + for row in rows.iter().filter(|r| r.reachable) { + println!(" {} -> {}: {}", row.package, row.name, row.path); + } + let path = root().join(BASELINE); + let reviewed = if update { + write_baseline(&path, &rows)?; + rows.clone() + } else { + read_baseline(&path)? + }; + check_rows(&rows, &reviewed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lock file with a block list, an inline list, a versioned entry and a patch block. + const SAMPLE: &str = r#" +version = 4 + +[[package]] +name = "root" +version = "0.1.0" +dependencies = [ + "middle", + "serde 1.0.0", +] + +[[package]] +name = "middle" +version = "0.1.0" +source = "registry+https://example.invalid/index" +dependencies = [ + "leaf", +] + +[[package]] +name = "leaf" +version = "0.1.0" + +[[package]] +name = "empty" +version = "0.1.0" +dependencies = [] + +[[patch.unused]] +name = "spare" +version = "0.2.0" + +[[package]] +name = "tail" +version = "0.1.0" +dependencies = ["leaf"] +"#; + + #[test] + fn lockfile_dependency_lists_become_edges() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(graph.get("root"), Some(&BTreeSet::from(["middle".to_string(), "serde".to_string()]))); + assert_eq!(graph.get("middle"), Some(&BTreeSet::from(["leaf".to_string()]))); + assert_eq!(graph.get("tail"), Some(&BTreeSet::from(["leaf".to_string()])), "an inline list is read too"); + assert_eq!(graph.get("empty"), Some(&BTreeSet::new())); + assert!(graph.contains_key("leaf"), "a package with no dependency list is still a node"); + assert!(graph.contains_key("spare"), "a patch block names a package too"); + } + + #[test] + fn shortest_path_names_the_chain_and_follows_dependencies_only() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(shortest_path(&graph, "root", "leaf").unwrap(), Some(vec!["root".to_string(), "middle".to_string(), "leaf".to_string()])); + assert_eq!(shortest_path(&graph, "leaf", "root").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "nowhere").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "root").unwrap(), Some(vec!["root".to_string()])); + } + + #[test] + fn a_lockfile_without_packages_is_an_error() { + let error = parse_lockfile("version = 4\n").expect_err("an empty graph must not be read as an answer"); + assert!(error.to_string().contains("names no package"), "{error}"); + } + + #[test] + fn committed_baseline_accepts_the_reachable_pairs_of_the_lockfile_graph() { + let text = std::fs::read_to_string(root().join(LOCKFILE)).expect("Cargo.lock is committed"); + let graph = parse_lockfile(&text).expect("Cargo.lock parses"); + let found = rows(&graph).expect("the walk finishes"); + let reviewed = read_baseline(&root().join(BASELINE)).expect("the baseline is committed"); + // The gate's own rule: a pair that went away or moved is reported, not a failure. + check_rows(&found, &reviewed).expect("every reachable pair is in the baseline"); + } +} From 60f60fc0a2c3fd58f6679483cfae1d2478e673dd Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 22:25:30 +0200 Subject: [PATCH 18/30] Give a redacted or sanitized copy a new file identifier (#3734) * cos: give a redacted or sanitized full save a new file identifier A full save that the document asked for (Document::require_full_save_with_new_id) now writes a fresh /ID pair, so a redacted or sanitized copy does not claim to be the file it came from (ISO 32000-2 section 14.4). Ordinary saves are unchanged. An encrypted file keeps the first string of /ID, because the file key is derived from it, and gets a new second string. An encrypted file whose /ID is not a pair of strings is left as it was, as before. SaveOptions::id_entropy lets a caller or a test supply entropy; with it the new identifier depends only on that entropy and the written bytes. Without it the identifier mixes the standard library's random hash keys and a process-wide counter, the same facility the engine uses for new encryption keys. Checked: the new tests in crates/cos/tests/file_identifier.rs fail on the clean base (compile errors for the missing API), fail on assertions when only the API is in place, and fail again when each pinned line is reverted (the regeneration call, the first-string keep, the flag, the entropy input, and the ordinary-save guard). cargo test -p pdfcraft-cos passes, and clippy with warnings denied is clean. A probe that wrote 16 ordinary full and incremental saves gave byte-identical files with and without this change. * redact: give a redacted or sanitized copy a new file identifier Applying redactions, sanitizing and removing hidden information now ask the cos layer for a new file identifier (Document::require_full_save_with_new_id), so the saved copy no longer carries the /ID of its source. Before this change the previous revision was dropped but the identifier still linked the copy to the original. The request sits in remove_hidden itself, because its per-category callers (the remove-hidden edit and the optimizer's discard option) call it directly, not through sanitize(). sanitize() asks only on its nothing-hidden path, where remove_hidden returned before asking: the rewrite is a new file either way. Checked: the new tests a_redacted_or_sanitized_copy_gets_a_new_identifier and a_remove_hidden_copy_gets_a_new_identifier fail before this change (the request is missing after apply, and after remove_hidden) and fail again when the apply call, the nothing-hidden branch or the remove_hidden request is reverted. cargo test -p pdfcraft-redact passes with the tests included (152 passed, 1 ignored); cargo clippy with warnings denied and cargo fmt --check are clean. * docs(cos): say when a full save regenerates the file identifier The writing section of the crate README described full saves without saying what happens to /ID. It now says that an ordinary save keeps the identifier, that a document which asks for a new one (redaction and sanitizing) gets both strings regenerated, that SaveOptions::id_entropy supplies entropy for reproducible output, and that an encrypted file keeps its first string because the key depends on it. Checked: the text matches the code added in e1f761cc (regenerate_id, new_id_bytes, require_full_save_with_new_id) and the tests in crates/cos/tests/file_identifier.rs. No code changed in this commit. * cos: hash the whole rewritten file into the new identifier The identifier of a requested rewrite hashed only the first 64 KiB of the written file. With caller-supplied id_entropy the identifier is deterministic, so two rewrites of documents with the same length that differ only after byte 65,536 received the same /ID, and a later edit of a large document could keep its previous /ID. Every written byte is now hashed. The output is read once: one loop over 4 KiB chunks feeds both halves of the 16-byte identifier, so the extra cost is one linear pass over the output. Measured with a probe in the workspace dev profile (opt-level 1): hashing 64 MiB took 0.024 s, about 24 ms per 64 MiB of output. A release build was not measured. Ordinary saves never reach this code, and their output is unchanged. Checked: crates/cos/tests/file_identifier.rs gains the_identifier_depends_on_the_whole_rewritten_file. It fails on the branch head before this change ("content past 64 KiB must change the identifier", both identifiers equal) and passes after it; reverting only the chunk loop to the 64 KiB prefix makes it fail again. The six existing tests in that file pass, and so do the full cos and redact test suites. * cos: keep the key valid when an encrypted rewrite has no usable /ID A requested rewrite of an encrypted file whose /ID was absent, had one entry, or had a first entry that is not a string wrote no identifier at all, although the document had asked for a new one. The reader takes the empty string as the key input for these shapes (Document::first_id), so the rewrite now writes the empty string (<>) as the first entry and a new second entry. The key stays valid. A file whose first entry is a string keeps that string, as before. Ordinary saves and unencrypted rewrites are unchanged. Checked: an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one in crates/cos/tests/file_identifier.rs covers the three shapes for RC4-128, AES-128 and AES-256. After the rewrite each file opens with its password, the first string is empty and the second is new. On the branch head the test fails with "the rewrite writes a pair" (left 0, right 2). Reverting only the guard to the head's early return makes it fail again. Isolated, the one-entry shape fails with left 1, right 2, and the non-string shape with left Some(0), right Some(16). The other seven tests in the file pass. * cos: derive the identifier from caller entropy with a fixed hash With SaveOptions::id_entropy the identifier was built with std's DefaultHasher, whose algorithm the standard library does not specify, and with write_usize (4 bytes on wasm32, 8 on 64-bit targets) and native-endian integers. The same entropy and the same file could therefore give different identifiers on different platforms or toolchains, although the documentation promised the bytes depend only on the entropy and the written file. The identifier is now FNV-1a (64 bit, the published constants) over fixed-width big-endian fields and the whole written file: the salt, the lane, id_seed and the length, then the bytes. With caller entropy the salt is the FNV-1a hash of that entropy. Without entropy it is still the standard library's random hash of a process counter, so only the salt differs between the two paths. Every written byte is hashed, in one pass that feeds both lanes. Cost: this pass is slower than the SipHash it replaces. A temporary probe in the workspace dev profile (opt-level 1) hashed 64 MiB through both lanes in 0.129 s (about 500 MiB/s), against 0.024 s before. A release build was not measured. The cost is paid once per requested rewrite; ordinary saves do not reach this code. Checked: the golden test fixed_entropy_rewrite in crates/cos/tests/file_identifier.rs pins the 16 bytes for a fixed entropy and the standard fixture. On the previous hash it fails (left [64, 145, ...], right [50, 125, ...]). An independent re-implementation of the same formula in Python, run over the bytes the writer hashed (everything before startxref), gives the same 16 bytes. The unit test fnv1a_is_the_published_64_bit_algorithm checks the constants against the published vectors. FNV is not collision-resistant against chosen content; that matters only if someone could choose a file's identifier, and the identifier only has to be unique. * Repair review branch integration * cos: take fresh identifier entropy from the browser on wasm Without SaveOptions::id_entropy the salt of a rewritten file's identifier was RandomState and a process-wide counter. On wasm32-unknown-unknown those hash keys are fixed at compile time, so two fresh web apps that followed the same allocation and call sequence reproduced the same salt and the same identifier for identical output, and a redacted copy stayed linkable to its source. The default salt now comes from the browser first: crypto.getRandomValues (Web Crypto, through js_sys, sixteen bytes folded with the same FNV-1a the identifier uses), or Math.random where that API is missing, and only when neither is reachable the previous RandomState salt. js-sys and wasm-bindgen join pdfcraft-cos as wasm32-only dependencies; both are already in the dependency tree of the web shell, and native builds compile neither. Caller entropy is untouched: with id_entropy the identifier is still a pure function of that entropy, the seed and the written bytes. Checked: separate_fresh_processes_get_different_identifiers re-runs this test binary as two fresh child processes over the same fixture and fails when the default salt is made constant (so does the in-process divergence test); stand_in_for_a_fresh_process is its no-op child half. Both pass with the real salt. On WebAssembly no browser test harness exists, so the browser path is covered by the wasm32 cargo check and by construction, not by a test. cargo check --target wasm32-unknown-unknown -p pdfcraft-cos passes, cargo test -p pdfcraft-cos and -p pdfcraft-redact pass, and clippy with warnings denied is clean on both. * Extract full-save trailer assembly Assisted-by: OpenAI Codex --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 2 + crates/cos/Cargo.toml | 7 + crates/cos/README.md | 8 + crates/cos/src/document.rs | 3 + crates/cos/src/document/open.rs | 15 ++ crates/cos/src/writer.rs | 229 ++++++++++++++++++++--- crates/cos/tests/file_identifier.rs | 279 ++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 5 +- crates/redact/src/sanitize.rs | 11 +- crates/redact/src/tests.rs | 81 ++++++++ 10 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 crates/cos/tests/file_identifier.rs diff --git a/Cargo.lock b/Cargo.lock index 1bf064f5d2..e68f422a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4333,6 +4333,7 @@ version = "0.18.1" dependencies = [ "chrono", "hayro-syntax", + "js-sys", "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", @@ -4340,6 +4341,7 @@ dependencies = [ "proptest", "serde_json", "thiserror 2.0.21", + "wasm-bindgen", ] [[package]] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index a2fb3f4c4e..0bf2e894d2 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,13 @@ thiserror = { workspace = true } # PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). chrono = { workspace = true } +# On the web, a rewrite's fresh file identifier takes its entropy from the browser +# (`crypto.getRandomValues`). Both crates are already in the dependency tree of the web shell +# (`js-sys` 0.3 / `wasm-bindgen` 0.2); native builds compile neither. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" + [dev-dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index 398b161625..774f2c4367 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -89,6 +89,14 @@ into compressed object streams with a cross-reference stream (PDF 1.5+); turning classic table any reader accepts. Both forms map accepted xref/header aliases to one output object, retaining shared identity such as page-tree parent links; unrelated generations stay null. +A full save keeps the file's `/ID` as it is, unless the document asked for a new identifier with +`Document::require_full_save_with_new_id` (redaction and sanitizing do). Then both strings are +regenerated, and `SaveOptions::id_entropy` can supply entropy for reproducible output (tests pin +output this way). Without it the identifier mixes in fresh entropy from the host — the browser's +`crypto.getRandomValues` on the web, the operating system's random hash seed on the desktop — so a +fresh copy of the program cannot reproduce it. An encrypted file keeps the first string, because its +key is derived from it. + Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e52aba2ce3..e5ce531f67 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -71,6 +71,9 @@ pub struct Document { encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). full_save: bool, + /// The next full save also gives the file a new identifier (`/ID`), see + /// [`Document::require_full_save_with_new_id`]. + new_id: bool, /// The handler and `/Encrypt` object number that saves use, when encryption changed. out_security: Option>, out_encrypt_num: Option, diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 7a4337c8da..3bf6677e99 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -35,6 +35,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -121,6 +122,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -290,12 +292,25 @@ impl Document { self.full_save = true; } + /// Like [`Document::require_full_save`], and the rewritten file also gets a new file identifier + /// (`/ID`), so that a redacted or sanitized copy never claims to be its source (ISO 32000-2 + /// §14.4). An encrypted file keeps the first string of its identifier, which its key depends on. + pub fn require_full_save_with_new_id(&mut self) { + self.full_save = true; + self.new_id = true; + } + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } + /// The next full save gives the file a new identifier (see [`Document::require_full_save_with_new_id`]). + pub fn new_id_required(&self) -> bool { + self.new_id + } + /// Security was set or removed since the document was opened (the next save applies it). pub fn encryption_changed(&self) -> bool { self.encryption_changed diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 6c4c71580a..d5be18d96c 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -167,11 +167,17 @@ pub struct SaveOptions { /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave /// these out. pub drop_unlinked: bool, + /// Entropy for the file identifier of a rewrite that asks for a new one (see + /// [`Document::require_full_save_with_new_id`]). Given, the identifier depends only on this and + /// on the written bytes, so the same save gives the same file; tests use it to pin output. + /// Without it, fresh entropy from the host (the operating system on native targets, the + /// browser on WebAssembly) is mixed in. + pub id_entropy: Option<[u8; 16]>, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false, id_entropy: None } } } @@ -383,26 +389,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro } } batch.flush(&reader.document, &mut next, &mut rows, &mut out)?; - let mut trailer = Dict::new(); - for key in [&b"Root"[..], b"Info", b"Encrypt"] { - if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { - trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); - } - } - if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); - } - if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); - } - for (key, value) in trailer_in.iter() { - if !trailer_key_rewritten(key) { - trailer.set(key.clone(), renumber(value, &map)); - } - } - if reader.document.output_security().0.is_none() { - ensure_id(&mut trailer, opts, reader.document.bytes()); - } + let mut trailer = full_save_trailer(&reader.document, &trailer_in, &map, opts, &out); if opts.object_streams { write_xref_stream(&mut out, 0, &mut trailer, rows, next); } else { @@ -421,6 +408,34 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro Ok(out) } +/// Assemble the full rewrite's trailer and apply its identifier policy. +fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap, opts: &SaveOptions, out: &[u8]) -> Dict { + let mut trailer = Dict::new(); + for key in [&b"Root"[..], b"Info", b"Encrypt"] { + if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { + trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); + } + } + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, map)); + } + if let Some(id) = trailer_in.get(b"ID") { + trailer.set(b"ID".to_vec(), direct_id(doc, id)); + } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, map)); + } + } + if doc.output_security().0.is_none() { + ensure_id(&mut trailer, opts, doc.bytes()); + } + if doc.new_id_required() { + regenerate_id(&mut trailer, opts, out, doc.output_security().0.is_some()); + } + trailer +} + /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. @@ -669,6 +684,136 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); } +/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not +/// claim to be its source. An encrypted file keeps the first string, which its key is derived from, +/// and gets a new second one. When there is no such string (no `/ID`, one entry, or a first entry +/// that is not a string) the reader takes the empty string as the key input (`Document::first_id`), +/// so the empty string is written first and the key stays valid. +fn regenerate_id(trailer: &mut Dict, opts: &SaveOptions, written: &[u8], encrypted: bool) { + let fresh = Object::String(PdfString { bytes: new_id_bytes(opts, written), hex: true }); + let pair = if encrypted { + let first = match trailer.get(b"ID") { + Some(Object::Array(a)) => match a.first() { + Some(Object::String(s)) => Object::String(s.clone()), + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }, + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }; + vec![first, fresh] + } else { + vec![fresh.clone(), fresh] + }; + trailer.set(b"ID".to_vec(), Object::Array(pair)); +} + +/// FNV-1a over 64 bits, with its published offset basis and prime. The identifier is built from it +/// rather than from `std`'s `DefaultHasher`, whose algorithm the standard library does not specify. +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(state: u64, bytes: &[u8]) -> u64 { + bytes.iter().fold(state, |h, b| (h ^ u64::from(*b)).wrapping_mul(FNV_PRIME)) +} + +/// Sixteen bytes for a file identifier, one 8-byte half per lane. The bytes are a pure function of +/// `SaveOptions::id_entropy`, `id_seed` and the written file: the hash is FNV-1a (above) and every +/// integer is written as a fixed-width big-endian value, so the same input gives the same identifier +/// on every platform and toolchain. Without entropy only the salt changes (see `id_salt`). FNV is +/// not collision-resistant against chosen content; the identifier only has to be unique, not secret. +/// Every written byte is hashed, once, in a single traversal of the output. +fn new_id_bytes(opts: &SaveOptions, written: &[u8]) -> Vec { + let salt = id_salt(opts); + let length = u64::try_from(written.len()).unwrap_or(u64::MAX); + let mut halves = [0u8, 1].map(|lane| { + let mut h = fnv1a(FNV_OFFSET, &salt.to_be_bytes()); + h = fnv1a(h, &[lane]); + h = fnv1a(h, &opts.id_seed.to_be_bytes()); + fnv1a(h, &length.to_be_bytes()) + }); + for chunk in written.chunks(4096) { + for h in &mut halves { + *h = fnv1a(*h, chunk); + } + } + halves.iter().flat_map(|h| h.to_be_bytes()).collect() +} + +/// The salt of an identifier. With caller entropy it is a fixed hash of that entropy (deterministic, +/// for tests and callers who pin output). Without it the salt is fresh: on WebAssembly from the +/// browser ([`browser_salt`], so two fresh web apps that write the same bytes cannot share an +/// identifier), and elsewhere from the standard library's random hash keys — seeded by the operating +/// system, so separate processes differ — mixed with a process-wide counter, which separates +/// successive calls in one process. +fn id_salt(opts: &SaveOptions) -> u64 { + use std::hash::BuildHasher; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + match &opts.id_entropy { + Some(entropy) => fnv1a(FNV_OFFSET, entropy), + None => { + #[cfg(target_arch = "wasm32")] + if let Some(salt) = browser_salt() { + return salt; + } + std::collections::hash_map::RandomState::new().hash_one(COUNTER.fetch_add(1, Ordering::Relaxed)) + } + } +} + +/// One byte from one `Math.random` draw: the draw (in [0, 1)) scaled to [0, 256), integer part +/// taken. The quotient is in [0, 256), so a saturating cast never wraps; a NaN or infinite draw +/// (which a conforming engine never returns) folds in as 0. No input can panic here. +#[cfg(any(target_arch = "wasm32", test))] +fn draw_byte(random: f64) -> u8 { + (random * 256.0).floor().rem_euclid(256.0) as u8 +} + +/// The `Math.random` fallback's mixing: one salt byte per draw (see [`draw_byte`]), folded through +/// FNV-1a exactly like the Web Crypto bytes, so all sixteen bytes of the fold carry entropy from +/// the draws. Pure and platform-independent, so the native test target can exercise it; on wasm +/// each draw comes from `Math.random`. +#[cfg(any(target_arch = "wasm32", test))] +fn salt_from_draws(draws: &[f64; 16]) -> u64 { + let mut bytes = [0u8; 16]; + for (byte, draw) in bytes.iter_mut().zip(draws) { + *byte = draw_byte(*draw); + } + fnv1a(FNV_OFFSET, &bytes) +} + +/// Fresh salt entropy from the browser, on WebAssembly. `crypto.getRandomValues` (Web Crypto, the +/// browser's secure generator, available in windows and workers alike) gives sixteen bytes, folded +/// into one salt. When that API is missing or refuses the call, `Math.random` is mixed instead +/// ([`salt_from_draws`]: sixteen draws, so the salt's full width carries entropy) — browsers seed +/// it afresh for every document, so a fresh instance still cannot reproduce another's salt. Only +/// where neither is reachable does the caller fall back to its `RandomState` salt, whose keys are +/// fixed at compile time on `wasm32-unknown-unknown` — the state before browser entropy was wired +/// in. +#[cfg(target_arch = "wasm32")] +fn browser_salt() -> Option { + use wasm_bindgen::JsCast; + let crypto = js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("crypto")) + .ok() + .filter(|crypto| !crypto.is_undefined() && !crypto.is_null()); + if let Some(crypto) = crypto { + let fill = js_sys::Reflect::get(&crypto, &wasm_bindgen::JsValue::from_str("getRandomValues")) + .ok() + .and_then(|fill| fill.dyn_into::().ok()); + if let Some(fill) = fill { + let bytes = js_sys::Uint8Array::new_with_length(16); + if fill.call1(&crypto, &bytes).is_ok() { + return Some(fnv1a(FNV_OFFSET, &bytes.to_vec())); + } + } + } + // Web Crypto is missing or refused the call: degrade to `Math.random` rather than giving up. + let mut draws = [0.0; 16]; + for draw in &mut draws { + *draw = js_sys::Math::random(); + } + Some(salt_from_draws(&draws)) +} + /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); @@ -711,6 +856,48 @@ mod tests { use proptest::prelude::*; + #[test] + fn fnv1a_is_the_published_64_bit_algorithm() { + // Published FNV-1a (64 bit) test vectors, so the identifier can be reproduced outside this crate. + assert_eq!(super::fnv1a(super::FNV_OFFSET, b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"foobar"), 0x8594_4171_f739_67e8); + } + + #[test] + fn math_random_draws_fill_the_whole_salt_width() { + // Draw i = (i + 1/2)/256 scales to the integer i, so the fold's byte i proves draw i landed + // there: all sixteen bytes carry their own draw's entropy (the fallback once shifted four + // draws into one word, leaving the other four bytes of the fold constant). + let draws: [f64; 16] = std::array::from_fn(|i| (i as f64 + 0.5) / 256.0); + let bytes: Vec = (0..16).collect(); + assert_eq!(super::salt_from_draws(&draws), super::fnv1a(super::FNV_OFFSET, &bytes)); + } + + #[test] + fn every_math_random_draw_reaches_the_salt() { + let base = [0.5; 16]; + let baseline = super::salt_from_draws(&base); + for i in 0..16 { + let mut draws = base; + draws[i] = 0.75; + assert_ne!(super::salt_from_draws(&draws), baseline, "draw {i} did not reach the salt"); + } + } + + #[test] + fn a_math_random_draw_outside_its_range_still_mixes_without_panicking() { + // A conforming `Math.random` stays in [0, 1), but the mixing must not panic outside it. + assert_eq!(super::draw_byte(0.0), 0); + assert_eq!(super::draw_byte(0.5), 128); + assert_eq!(super::draw_byte(255.5 / 256.0), 255); + assert_eq!(super::draw_byte(-0.5 / 256.0), 255); + assert_eq!(super::draw_byte(-1.0), 0); + assert_eq!(super::draw_byte(2.0), 0); + assert_eq!(super::draw_byte(f64::NAN), 0); + assert_eq!(super::draw_byte(f64::INFINITY), 0); + } + use super::*; use crate::Lexer; diff --git a/crates/cos/tests/file_identifier.rs b/crates/cos/tests/file_identifier.rs new file mode 100644 index 0000000000..4612df54f0 --- /dev/null +++ b/crates/cos/tests/file_identifier.rs @@ -0,0 +1,279 @@ +//! The file identifier (`/ID`, ISO 32000-2 §14.4) of a full save. An ordinary save keeps the +//! identifier the file has and writes the same bytes twice. A rewrite the document asked for (after +//! redaction or sanitizing) gives the file a new pair. An encrypted file keeps its first string, +//! because its file key is derived from it. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, Document, NewEncryption, Object, PdfString, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; + +const ID_HEX: &str = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; +const ID_BYTES: [u8; 16] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + +/// One page whose content holds a secret, with `entry` added to the trailer (for example an `/ID`). +fn source_with(entry: Option<&str>) -> Vec { + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", "BT /F1 12 Tf 20 100 Td (TOPSECRET) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + match entry { + Some(entry) => pdf.trailer_entry(entry).build(), + None => pdf.build(), + } +} + +/// [`source_with`] with the standard identifier, or none. +fn source(with_id: bool) -> Vec { + source_with(with_id.then_some(ID_HEX)) +} + +/// A one-page file whose 70,000-byte content stream ends in `last`. Two of these built with +/// different last bytes have the same length and differ only past the first 64 KiB. +fn large_source(last: u8) -> Vec { + let mut content = vec![b' '; 70_000]; + content.push(last); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .build() +} + +fn reopen(bytes: Vec) -> Document { + Document::open(Arc::new(bytes)).unwrap() +} + +/// The strings of the trailer's `/ID` (empty when there is none). +fn id_strings(doc: &Document) -> Vec> { + match doc.trailer().get(b"ID") { + Some(Object::Array(a)) => a.iter().map(|s| s.as_string().map(|s| s.bytes.clone()).unwrap_or_default()).collect(), + _ => Vec::new(), + } +} + +#[test] +fn an_ordinary_save_keeps_the_identifier_and_writes_the_same_bytes() { + let doc = reopen(source(true)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + assert_eq!(id_strings(&reopen(saved)), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); + let appended = reopen(write_incremental(&doc, &opts).unwrap()); + assert_eq!(id_strings(&appended), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); +} + +#[test] +fn an_ordinary_save_of_a_file_without_an_identifier_creates_the_same_one_every_time() { + let doc = reopen(source(false)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn a_rewrite_the_document_asked_for_gets_a_new_identifier() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + assert!(doc.full_save_required() && doc.new_id_required()); + let opts = SaveOptions::default(); + for saved in [write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap()] { + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + for id in &ids { + assert_eq!(id.len(), 16); + assert_ne!(id.as_slice(), ID_BYTES.as_slice(), "the copy must not claim the source's identifier"); + } + } + // Every save is a new identity, not the same one again. + let first = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + let second = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + assert_ne!(first, second); +} + +#[test] +fn a_rewrite_without_an_identifier_still_gets_one() { + let mut doc = reopen(source(false)); + doc.require_full_save_with_new_id(); + let ids = id_strings(&reopen(write_full(&doc, &SaveOptions::default()).unwrap())); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn caller_entropy_makes_the_new_identifier_reproducible() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + let with = |entropy: u8| SaveOptions { id_entropy: Some([entropy; 16]), ..SaveOptions::default() }; + let first = write_full(&doc, &with(1)).unwrap(); + assert_eq!(first, write_full(&doc, &with(1)).unwrap(), "the same entropy gives the same bytes"); + let other = write_full(&doc, &with(2)).unwrap(); + assert_ne!(id_strings(&reopen(first)), id_strings(&reopen(other)), "different entropy gives a different identifier"); +} + +#[test] +fn an_encrypted_rewrite_keeps_the_first_identifier_string_its_key_depends_on() { + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let mut doc = reopen(source(true)); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let protected = write_full(&doc, &SaveOptions::default()).unwrap(); + let before = Document::open_with_password(Arc::new(protected), Some("user")).unwrap(); + + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + // The output opens with the same password only if its first identifier string is unchanged. + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let (old, new) = (id_strings(&before), id_strings(&back)); + assert_eq!(new.len(), 2, "{algorithm:?}"); + assert_eq!(new.first(), old.first(), "{algorithm:?}: the key depends on the first string"); + assert_ne!(new.get(1), old.get(1), "{algorithm:?}: the second string is new"); + } +} + +/// The `/ID` shapes whose key the reader takes as the empty string: none at all, one entry, and a +/// first entry that is not a string. +fn unusable_ids() -> Vec<(&'static str, Option)> { + let empty = Object::String(PdfString::literal(Vec::new())); + vec![ + ("absent", None), + ("one entry", Some(Object::Array(vec![empty.clone()]))), + ("first entry not a string", Some(Object::Array(vec![Object::Int(7), empty]))), + ] +} + +/// An encrypted file whose key is derived from the empty string, with its trailer `/ID` replaced by `id`. +fn protected_with_id(algorithm: Algorithm, id: Option) -> Vec { + let mut doc = reopen(source_with(Some("/ID [<> <>]"))); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + match id { + Some(id) => doc.trailer_mut().set(b"ID".to_vec(), id), + None => { + doc.trailer_mut().remove(b"ID"); + } + } + write_full(&doc, &SaveOptions::default()).unwrap() +} + +#[test] +fn an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one() { + // The key is derived from the first string of /ID, and a missing /ID, a one-entry /ID and a + // first entry that is not a string all count as the empty string. The rewrite keeps that + // (empty) first string, so the file still opens with its password, and writes a new second one. + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + for (shape, id) in unusable_ids() { + let before = Document::open_with_password(Arc::new(protected_with_id(algorithm, id)), Some("user")).unwrap(); + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let ids = id_strings(&back); + let previous = id_strings(&before); + assert_eq!(ids.len(), 2, "{algorithm:?}, {shape}: the rewrite writes a pair"); + assert!(ids.first().is_some_and(|s| s.is_empty()), "{algorithm:?}, {shape}: the first string is the one the key uses"); + assert_eq!(ids.get(1).map(Vec::len), Some(16), "{algorithm:?}, {shape}"); + assert_ne!(ids.get(1), previous.get(1), "{algorithm:?}, {shape}: the second string is new"); + } + } +} + +/// The identifier of [`fixed_entropy_rewrite`]. It was computed once with the FNV-1a formula in the +/// writer; it must not change with the platform or the toolchain. +const GOLDEN_FIXED_ENTROPY_ID: [u8; 16] = [0x32, 0x7d, 0x91, 0x46, 0xb8, 0x6d, 0xb3, 0xe1, 0x97, 0xc3, 0x5e, 0xdc, 0x58, 0x48, 0x3f, 0xba]; + +/// The standard fixture rewritten for a new identifier, with `id_entropy` of 0x42. +fn fixed_entropy_rewrite() -> Vec { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + write_full(&doc, &SaveOptions { id_entropy: Some([0x42; 16]), ..SaveOptions::default() }).unwrap() +} + +#[test] +fn a_fixed_entropy_gives_the_same_identifier_on_every_platform() { + // The identifier is FNV-1a over fixed-width big-endian fields and the written file, so the same + // entropy and bytes give the same 16 bytes on every platform and toolchain. The value also pins + // this writer's output for the fixture. + let golden = GOLDEN_FIXED_ENTROPY_ID.to_vec(); + assert_eq!(id_strings(&reopen(fixed_entropy_rewrite())), vec![golden.clone(), golden]); +} + +#[test] +fn the_identifier_depends_on_the_whole_rewritten_file() { + // Same caller entropy and same length; the two rewrites differ only in one byte of a stream + // that lies past the first 64 KiB of the output. Their identifiers must still differ. + let opts = SaveOptions { id_entropy: Some([9; 16]), ..SaveOptions::default() }; + let rewrite = |last: u8| { + let mut doc = reopen(large_source(last)); + doc.require_full_save_with_new_id(); + write_full(&doc, &opts).unwrap() + }; + let (a, b) = (rewrite(b'A'), rewrite(b'B')); + assert_eq!(a.len(), b.len(), "the two rewrites have the same length"); + let first_difference = a.iter().zip(&b).position(|(x, y)| x != y).unwrap_or(usize::MAX); + assert!(first_difference > 64 * 1024, "the rewrites first differ at byte {first_difference}, inside the first 64 KiB"); + assert_ne!(id_strings(&reopen(a)), id_strings(&reopen(b)), "content past 64 KiB must change the identifier"); +} + +/// Where [`stand_in_for_a_fresh_process`] writes its rewrite. +const ID_PROBE: &str = "PDFCRAFT_TEST_ID_PROBE"; + +/// The child half of [`separate_fresh_processes_get_different_identifiers`]: it runs alone, in its +/// own process, and writes a default-path rewrite of the standard fixture to the file the parent +/// named. Without the variable it is a plain no-op. +#[test] +fn stand_in_for_a_fresh_process() { + let Some(path) = std::env::var_os(ID_PROBE) else { return }; + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + std::fs::write(path, write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); +} + +#[test] +fn separate_fresh_processes_get_different_identifiers() { + // The default identifier is meant to differ between fresh instances of the whole save pipeline, + // not only between two calls in one process (which the counter already separates). On the native + // test target a fresh instance is a fresh process, whose hash keys the operating system seeds; + // the fix under test runs on WebAssembly, where no browser test harness exists — there the + // browser's `crypto.getRandomValues` stands in for this seeding, and this test cannot see it. + let exe = std::env::current_exe().unwrap(); + let probe = |name: &str| { + let path = std::env::temp_dir().join(format!("pdfcraft-id-probe-{}-{name}.pdf", std::process::id())); + let out = std::process::Command::new(&exe) + .args(["--exact", "stand_in_for_a_fresh_process", "--nocapture", "--"]) + .env(ID_PROBE, &path) + .output() + .expect("run this test's own binary as a child"); + assert!(out.status.success(), "{name}: the child failed: {}", String::from_utf8_lossy(&out.stderr)); + let saved = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: the child wrote its file: {e}")); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2, "{name}"); + assert!(ids.iter().all(|s| s.len() == 16), "{name}: {ids:?}"); + let _ = std::fs::remove_file(&path); + ids + }; + let (first, second) = (probe("a"), probe("b")); + assert_ne!(first, second, "two fresh processes must not share an identifier"); +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 41ac47357e..b6a8885c03 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -554,8 +554,9 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co if residue > 0 { return Err(RedactError::Residue(residue)); } - // The previous revision still holds the removed content: the next save must rewrite. - doc.require_full_save(); + // The previous revision still holds the removed content: the next save must rewrite, and the + // rewritten file is a different file from its source, so it gets a new identifier. + doc.require_full_save_with_new_id(); Ok(report) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 6fd4354300..f8088e0bba 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -674,16 +674,21 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result