Files
pdfcraft/crates/engine
+4 a6d48707f7 feat(preflight): verify image resolution and bleed without rewriting the file (#1408)
* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* test upload session tests only

* Include the print-readiness module from the OCR module so the crate compiles.

* Record the one-line print-readiness patch so the dispatch can be restored exactly.

* Restore the print-readiness dispatch from the original sources.

* M3: select an image on the page and copy it (#1363)

* M3: select an image on the page and copy it

Select tool: a click on a raster image selects it (also one drawn through Form artwork, or with
text over it such as an OCR layer); ⌘C copies it as a file in its original format (a JPEG as
stored in the PDF, otherwise a lossless PNG). Right-click ▸ Copy Image copies its pixels at full
resolution, and Copy Image in Original Format the file; Edit text & images has both for the
selected image. Both respect the document's copy permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q

* M3: translate the copy-image strings into Czech, Kazakh, Slovene and Turkish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q

* Copy image: keep the clipboard file in a private per-user folder

Copy Image in Original Format wrote into the shared temporary folder
(/tmp/pdfcraft-clipboard) and deleted everything in it first, so another
user on the machine could plant a link or folder there and have PdfCraft
delete or overwrite their target. The file now goes in PdfCraft's
per-user cache folder (or the portable data folder), which is checked to
be a real folder and set to 0700 on Unix (refused when it isn't ours).
Only earlier copies (regular files named '<stem> p<N> img<M>.jpg|png')
are removed, links are never followed, and the new file is created with
create_new so it can't be redirected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: escape Windows paths in synthetic GoToR file specifications

canonicalize() returns \\?\C:\... on Windows; unescaped backslashes in a PDF literal string become control characters, so link_open refused the target and main's Windows CI failed. Taken from #1359's helper fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(cli): edit refuses move/insert destinations past the end (#1330) (#1400)

Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sign): refuse to sign a field whose /V is not a signature (#1328) (#1402)

Follow-up to #1374. Signing an existing field refused a field whose /V
is a signature dictionary and signed one whose /V is missing or null,
but a /V that is neither (an empty string, a number, a name, an array)
was treated as unsigned and overwritten with the new signature.

Signing such a field by name now fails with "the value of the field
... is not a signature" and leaves the document as it is. A missing or
null /V, direct or indirect, still signs, and a signed field is still
refused as already signed. The signature list is unchanged: it still
shows such a field as unsigned, since it carries no signature.

Adds a_field_whose_value_is_not_a_signature_is_refused_by_name, which
fails before this change.

* fix(forms): keep a whitespace-only value in a text field without a format (#1323) (#1403)

The commit-time Keystroke check returned an empty value for any
whitespace-only text before looking at the field's format, so setting a
space, a tab or a non-breaking space in a plain text field reported
success and removed the field's value. Text and XFDF imports go through
the same setter and lost the value the same way.

Blank input now clears only a field with a format (number, percent,
date, time, special or mask), as before; a field without one stores the
text as given. An empty value still clears any field.

Adds tests::whitespace_only_text_is_kept_in_a_field_without_a_format
and a keystroke assertion in af::tests::numbers_format_like_acrobat,
both of which fail before this change.

* fix(content): skip an inline image with an unreadable key to its EI (#1318) (#1404)

When a key in an inline image dictionary was not a name (`BI /W 4 H 4
ID ... EI`), the parser ended the image at the end of the stream and
counted nothing as skipped, so every operator after the image was lost:
text_lines found no text on a page whose text follows such an image.

An image whose parameters can't be read is now skipped up to the EI
that ends it, the skipped bytes are counted, and parsing resumes after
it. Without an EI the image still runs to the end of the stream, as
before.

Adds tests::an_unreadable_inline_image_is_skipped_to_its_end, which
fails before this change.

* fix(automation): doc_save reports a required full rewrite as not incremental (#1329) (#1405)

doc_save reported "incremental": !full, from the arguments alone. When
an in-place save had to be a full rewrite (after doc_protect adds or
removes encryption, or after Sanitize), write_incremental fell back to
write_full, but the result still said the save was incremental.

The result now says whether the written file starts with the file it
was saved over, read from the new Document::saved_bytes. An ordinary
in-place save still reports true and an explicit full save false.

Adds an_in_place_save_reports_a_required_rewrite_as_not_incremental,
which fails before this change for both doc_protect and
doc_remove_hidden.

* fix(audit): an identity cm between re and fill no longer hides a cover (#1333) (#1410)

Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): Current page prints the viewed page, not a page labelled with its number (#1308) (#1411)

The Print dialog turned Current page into the range "N" for the page on
screen, and ranges read numbers as page labels first. In a document
labelled i, ii, iii, 1, 2, viewing the first page printed the fourth,
whose label is "1".

Current page is now passed as a position, the way Selected pages is,
through print::select_listed. A typed range still reads labels first.

Adds print_ui::tests::current_page_is_the_viewed_page_not_a_label,
which fails before this change.

* fix(render): draw Turkish Ğ İ Ş ğ ş in non-embedded fonts (vendored hayro-interpret patch 26) (#1409)

A PDF whose fonts aren't embedded is drawn with PDFium's Foxit stand-ins
for the standard 14 fonts. Those carry only the 230-odd glyphs of the
Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond
them in its /Differences mapped to .notdef and drew nothing. iText 2
writes Turkish exactly this way (Times New Roman left unembedded,
/Gbreve /Idotaccent /Scedilla /gbreve /scedilla over Ð Ý Þ ð þ), and such
letters showed blanks for Ğ, İ, Ş, ğ and ş while ı, ç, ö and ü drew.

StandardKind now builds a Latin Extended-A letter the stand-in lacks from
the base letter and accent it does have, as Type 1 seac composites and
the standard-14 AFM composites are built: the accent is centred on the
letter, raised over capitals and letters with ascenders (and moved along
the slant in italics), cedillas hang below and ogoneks from the right
edge. Composite glyph ids sit above any font's glyph count, one per
character code; their width comes from the standard metrics of the name.

Test: raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts
(fails without the patch: Gbreve drew no pixel).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10: edit OCR text on scanned pages so the change shows (#1397)

* M10: edit OCR text on scanned pages so the change shows

Editing a recognised word on a scan rewrote it as invisible OCR text, so
the page still showed the old word, and the whole OCR layer grouped into
one paragraph that rewrapped when one word changed.

- An edited invisible paragraph is drawn visibly, over a cover in the
  paper colour sampled around it, in the scan's sampled ink colour
  (or the chosen colour).
- OCR words group into phrases: a table cell or a tab stop splits them
  and each scanned line stays apart; a phrase grows rightwards.
- Styled rewrites measure with the text matrix's horizontal scale, so
  stretched OCR words keep their widths instead of overlapping.
- The inline editor covers the scanned word and grows like the result.

* M10: size OCR text from each word's letters

A word box from recognition is tight around the ink, but the text layer
assumed every box spans Helvetica's ascender-descender height. A
capitals-only word such as "DT" spans only the cap height, so its text
was set about 23% too small and its baseline floated up; editing it
then drew the new word visibly smaller than the scanned one.

The em and baseline now come from the word's letters: cap height or
x-height on top, descender or baseline at the bottom. On a real scanned
form, a rewritten "DT" -> "DSI" now matches the scanned word's height
and baseline. The engine OCR-edit tests build their words the way a scan
would box them.

* Edit OCR text: sample colours only for invisible blocks, from the current bytes

Session::apply looked up every edited block with a fresh text_blocks pass and
re-serialized the whole document to render the sampling tile. Text blocks are
now cached per page on the editor (keyed on the unmodified document's source
identity, so any edit, undo or save invalidates them), visible blocks skip
sampling entirely, and the tile renders from the document's current bytes.
Adds a visible stretched-text regression test and documents that editing any
invisible text makes it visible over a painted cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Edit text: support per-font visual-order Indic text and nukta reordering (#1380)

* Edit text: support per-font visual-order Indic text and nukta reordering

- Detect legacy visual-order Indic fonts via Metrics.visual_order (fonts with
  Indic codepoints without OpenType GSUB tables, or legacy visual font names).
- Implement Indic visual-to-logical and logical-to-visual pre-base vowel
  reordering in crates/edit/src/indic.rs, with support for decomposed/precomposed
  nuktas and conjuncts.
- Apply reordering during extraction and source font reuse only when visual_order
  is set, leaving modern logical-order text untouched.
- Add unit tests for nuktas, conjuncts, and round-trips in crates/edit/src/indic.rs
  and font visual_order tests in crates/edit/src/tests.rs.

* Visual-order Indic: positive evidence only, ActualText kept logical, no font decoding

- A font is visual order only when it is a known legacy Indic family (subset tag
  allowed; aps-/dv- as prefixes) with an Indic Unicode mapping. A missing GSUB is
  no evidence (bare CFF and subset fonts drop it), so Metrics::from_dict no longer
  decodes every embedded font program on every call.
- Lines with /ActualText text are already logical: skip indic::to_logical, so
  "बढ़िया" no longer becomes "बढ़यिा".
- reading_lines no longer runs bidi recovery on every line (it changed RTL
  reading_blocks output for all documents); only the Indic recovery remains.
- indic.rs indexes through get().
- Tests use a ToUnicode fixture (not visual-order ActualText), cover an edit
  round-trip, logical ActualText in a legacy font, and a subset Noto font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M12: preview text structure proposals for auto-tagging (#1383)

* M12: preview text structure proposals for auto-tagging

* Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check

The preview is read-only with no UI command yet, like render_preview, so it no
longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12: preview text structure proposals for auto-tagging

* Keep read-only auto-tag preview separate from unavailable command

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Test current-page print previews and saved output (#1415)

Extend the coverage of #1411 with preview raster selection and a dialog-driven PDF save test. Reopen and render the saved PDF to verify the viewed physical page when labels restart. Cover duplicate labels and selection guards without changing production code.

Refs #1308, #1411

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>

* fix(content): count an unreadable inline image with no EI as skipped (#1318) (#1418)

Follow-up to #1404. An inline image whose parameters can't be read and that has no EI after them ran to the end of the stream as a BI op with no data, and those bytes were not counted. It is now skipped like one with an EI, its bytes counted in Parsed::skipped. A stream that ends inside the parameters (BI /W 1) still keeps its BI op.

* feat(ui): add command palette arrow-key navigation (#1419)

* M4: Compare and cache actual Combine file duplicates (#1420)

Different equal-sized PDFs with the same name no longer receive duplicate warnings. Share exact-content detection between Grid and List, and cache the result using the existing list revision to remove the per-redraw all-pairs scan.

Cover distinct contents, actual duplicates, source parts, cache reuse, removal, undo, redo and closing the tab. Both UI regressions fail before the fix.

Fixes #1303

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>

* feat(preflight): verify image resolution and bleed without rewriting the file (#1408)

Squashed from the PR branch (its history added and removed a workflow file;
the final tree has no .github changes).

* printcheck: proper engine module, one TrimBox note, count unmeasurable images

Declares printcheck in engine/src/lib.rs instead of a #[path] module inside
ocr.rs. Pages without a TrimBox get one informational document-level
finding instead of a bleed warning per page; bleed is only measured
against a real TrimBox. Placements with a non-finite or degenerate matrix
are counted as resolution unknown (info finding, text line and JSON
field) rather than passing every check. Adds a print_readiness test
through Automation::call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Francesco Clemeno <checcomeno@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: umesh429 <umesh.ghanti@gmail.com>
Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com>
Co-authored-by: TheLastRoadRunner <99679216+TheLastRoadRunner@users.noreply.github.com>
Co-authored-by: Tangu Achilis <43493154+TanguAchilis@users.noreply.github.com>
Co-authored-by: krinry <121806532+krinry@users.noreply.github.com>
Co-authored-by: Earl Co <earlvanze@gmail.com>
Co-authored-by: LabDennis <dennis@leistenschneider.pro>
Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Pratham Bhanushali <134723366+Morpheus004@users.noreply.github.com>
2026-10-12 08:52:06 -04:00
..

pdfcraft-engine

The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates.

  • Layer: L6. It depends on most of L1–L4 and on nothing above itself. No UI toolkit — egui, winit, eframe and rfd are forbidden below L7, and xtask layers enforces it.
  • The crate denies unwrap/expect/panic!/todo!. Entry points that run untrusted-document code keep a last-resort catch_unwind guard so an escaped panic becomes an error and the user keeps their document.

Two frontends exist on top of it today: pdfcraft-ui-egui (desktop and web) and pdfcraft-automation (headless tools, the CLI and MCP). Anything one can do, the other must be able to do — see AGENTS.md §3.

The editing model

Each open document holds a pdfcraft_cos::Document: the object graph plus a copy-on-write overlay.

  1. An edit runs on a clone. On success the previous state is pushed onto the undo stack. Clones share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo affordable on a 200 MB file.
  2. After every edit the working file is produced by an incremental write: the original bytes plus one appended revision.
  3. The view is refreshed from those bytes, so what you see is exactly what Save will write.
  4. Saving rebases onto the written bytes, so the next save appends only new edits.

Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed password); and unknown data survives a round trip because nothing is reconstructed that wasn't understood.

API

let mut s = Session::new();
let id = s.open("report.pdf", Some(path), bytes, None)?;   // -> DocId

s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?;
let label = s.undo(id)?;                                    // "Rotate pages"
s.redo(id)?;

let doc = s.get(id).ok_or("gone")?;                         // &Document: queries, never edits
doc.can_undo(); doc.editable(); doc.allows_printing();
doc.text_blocks(page); doc.page_boxes(page); doc.security_summary();

let bytes = s.save_bytes(id)?;        // incremental: keeps signatures over earlier revisions valid
let bytes = s.save_full_bytes(id)?;   // full rewrite, garbage-collected
s.mark_saved(id, bytes.clone(), path)?;

Edit is one enum of 75 variants — every undoable change in the product, from RotatePages to AddAnnotation, SetFieldValue, ApplyRedactions and AddWatermark. Adding a feature means adding a variant and handling it in apply, which is what keeps undo, the dirty flag, autosave and the automation tools consistent for free.

Session also owns the operations that produce a new document rather than editing one: create_blank, create_from_images, create_from_text, combine, extract, split, split_by_size, reduced_bytes, optimized_bytes, print_pdf, export_data, and the signing family (sign, sign_with_timestamp, timestamp_document, embed_ltv).

Modules

Module What it holds
commands The command registry: id, label, menu, shortcut, and a Needs precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled
catalog Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an Availability of Ready, Planned(milestone) or Provider. The UI renders what is not built yet, honestly
actions PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript)
links Link creation, properties, and finding URLs in page text
js The bridge to pdfcraft-js. forms never depends on js directly; the engine injects it, so a build without JavaScript is possible
xfa, ocr, compare, export Façades over the L3/L4 crates of the same names

Not done here

  • Rendering is still borrowed. pdfcraft-render wraps hayro and inspects through lopdf (ADR-0004). The engine only sees inspect() -> DocInfo, RenderPool and text::PageText, and that API stays frozen while M2 replaces what is behind it.
  • Performance budgets (misc.performance-budgets) are not set or enforced.
  • Some shipped features are still reachable only from the UI, not through an automation tool; cargo xtask parity lists them; closing that gap is tracked in the execution plan (D4).

doc.text_edit_preview(page, block) prepares a read-only page background with the selected paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the working document’s render configuration and never enters the undo history.