135 Commits

Author SHA1 Message Date
Tanish Manku 0a09864833 F1, F2: adapt to main and re-pin the Arabic layout golden
Rebase onto 7163ba3 (396 commits on). Three adaptations, none of them
behavioural:

- `crates/fonts` renamed its `arabic` module to `shaping` and added
  Thai, so `Family::width`'s callers moved; the italic argument is
  re-applied to all six, including one new call site main added in
  `draw_laid_shaped`.
- `Metrics::text_of` now returns `Cow<str>`, so the F2 tests compare
  through `as_deref()`.
- `unicode_map` writes into a struct field now, so `/Differences`
  inserts go to `out.encoding`.

`SMALL_ITEM_FINGERPRINT` in added/arabic_tests.rs is re-pinned, and this
is the part worth reviewing. That golden is the appearance-stream pin
F1's acceptance criteria asked for, which landed from the Arabic work
after F1 was written; it caught the change correctly. The item's Latin
run is "Hi " in Helvetica 12pt. The old character-class guess called H
680, i 260 and space 260 (14.4pt); the published metrics are 722, 222
and 278 (14.664pt), verified against the table. The Arabic clusters that
follow start 0.264pt further right, which is the intended effect: the
glyphs now land where the font says they do. The Arabic path itself is
untouched.

Also dropped from the rebase: a re-added ATTRIBUTION entry for
vendor/hayro-interpret/assets/LAB.icc, which main deleted in favour of
CGATS001Compat-v2-micro.icc. Carrying it would have attributed a file
that no longer exists.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku ce9a1d1014 F2: read glyph names by the naming algorithm, and stop reading Symbol as Latin
Two gaps in code-to-Unicode, both found by probing the current
behaviour rather than by reading the code.

A font with no /Widths and no /ToUnicode fell back to StandardEncoding
whatever it was, so /Symbol code 0x61 -- `alpha` in Symbol's own
built-in encoding -- decoded as "a". ZapfDingbats and MacExpert were
read as Latin the same way. That map is what `redact` matches patterns
against, so a wrong character there is a silent redaction failure, and
a wrong character is worse than no character. Symbol, ZapfDingbats and
MacExpert now yield no mapping at all unless the font names a base
encoding, which is always honoured as the deliberate statement it is.

This keys on the base font *name*, not the /FontDescriptor symbolic
flag. A great many ordinary Latin TrueType subsets set that flag and
still rely on a Latin encoding, so suppressing on the flag would lose
real text across a large share of real documents. A test pins that
restraint so it is not "fixed" into a regression later.

Glyph names now follow the published naming algorithm instead of two
of its cases: a name is cut at its first `.` (`a.sc` is `a`), split on
`_` into components (`f_i` is "fi"), and `uniXXXX` carries any number
of four-digit values (`uni00660069` is "fi"). Names are resolved whole
or not at all, so half a ligature is never returned. `glyph_text`
returns a String for this; `glyph_unicode` stays as the single-character
view. Hex parsing rejects the leading `+` that `from_str_radix` would
otherwise accept, and a lone surrogate half.

Both halves were verified by reverting each fix and watching the
matching tests fail. The parity note on protect.redact-patterns now
says Symbol-encoded text is skipped rather than mis-decoded.

Still open, and an owner decision: Symbol and ZapfDingbats could be
mapped properly with a glyph-name-to-Unicode table, but the vendored
Adobe Glyph List is not on the AGENTS.md 1.1 closed list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku 117430fade F1: measure an added item with the face it is written as
`Family::width` chose its standard-14 face from `bold` alone and passed
`italic: false`, while `Family::base_font` writes the item out as the
italic or oblique face. Every italic item was therefore laid out with
upright metrics: Times-Italic `A` is 611 against Times-Roman's 722, so
boxes, wrapping and centring were all sized from the wrong face.

The measure now takes `italic` and the five call sites pass the item's
own flag, which they already had in scope. The test walks all twelve
style combinations and asserts the written face and the measured face
are the same one, so the two can't drift apart again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku aaa19d87af F1: put the generated table on the AGENTS.md 1.1 closed list
Isolated so it can be reverted on its own.

AGENTS.md 1.1 keeps a closed list of Adobe-authored non-visual technical data,
and adding to it needs the owner's explicit approval, which was given on
2026-10-08. The item itself -- the standard-14 font metrics and encoding tables
from the PDF specification -- was already on that list; what is new is holding
a copy first-party rather than only through hayro, which is what lets generated
appearances measure with it and what survives hayro being retired in M2.8.

The gate only understood the list for dependency-bundled files, and rejected
adobe_data on a repository file outright. It now accepts it for a closed list
of first-party paths, so crates/fonts/src/std14.rs carries an ATTRIBUTION entry
with its SHA-256: regenerating the table fails the gate until the entry is
updated, which makes a change to Adobe-derived data a deliberate act rather
than an ordinary edit.

Worth noting that .rs is not an asset extension, so the table would never have
been scanned at all. Entering it on the list is a choice to be checked, not a
requirement the gate imposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku d0d1ad8192 F1: exact standard-14 metrics
pdfcraft_fonts::helvetica_width was a fifteen-line guess by character class,
and seven crates generated appearance streams through it: annot, edit, forms,
ocr, redact, sign and xfa. Times was Helvetica x 0.9, bold was x 1.05, Courier
a flat 0.6. Against the published metrics a Times capital A is 722, so that
estimate said 600 -- a 17% error on a letter, compounding across a line.

Std14 carries the real advance widths for all fourteen faces, indexed by
character code: WinAnsiEncoding for the twelve text fonts, which is what we
write into the appearance streams we generate, and their own built-in
encodings for Symbol and ZapfDingbats. The tables are generated by joining the
published encodings with the per-glyph widths.

helvetica_width now delegates, so every call site gained exact Helvetica with
no change. The three places that faked another face by scaling it -- Family
in edit::added, the paragraph measure in edit::text, and the redaction overlay
fonts -- now select the right face instead. So does Metrics::width in
fonts::pdf, which measures existing document text when a font carries no
/Widths; that path feeds redaction's glyph removal, where a wrong width means
cutting the wrong glyphs.

Tests spot-check the table against values typed in from the specification by
hand, so it is verified against the spec rather than against whatever produced
it; plus Courier being fixed pitch everywhere, full printable-ASCII and Latin-1
coverage, Symbol and ZapfDingbats not being WinAnsi, /BaseFont aliases and
subset prefixes resolving, and hostile text staying finite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku 681548a47f Add a PR template, and record the review practice and external-PR bar
Replaces this PR's earlier contents. It previously added a committed
`docs/plan/` tree -- a status baseline, an architecture doc and an
execution plan. Since then `main` has grown `docs/gaps.md`,
`docs/target-app-parity.md`, `docs/roadmap.md` and `docs/architecture.md`,
which are actively maintained (gaps.md was reviewed 2026-10-10 and
updated 2026-10-11) and cover the same ground better, because they sit in
the flow contributors already touch. Keeping a parallel plan would have
meant two sources of truth and a document that rots; it had already gone
conflicting twice in three days. So the plan is dropped and only the
parts with no equivalent anywhere remain.

`.github/pull_request_template.md` is new -- the repository had none,
with 167 PRs open and 66 of them conflicting. It leads with the two
things a contributor cannot be expected to know and that cannot be undone
after a merge: provenance (no Adobe asset, no committed font, no copied
GPL/AGPL code, behaviour from the spec rather than from reading another
implementation) and the never-crash rules. It also asks for a test that
has been *seen to fail* before the fix, and says plainly that per-crate
verification is preferred because CI runs the workspace anyway.

CLAUDE.md gains three things it did not record: the four-zoom review
habit (clause, function, file, project) with the tests that mirror it;
the external-PR bar; and a note that builds and tests should be scoped
per crate, because a full workspace build has locked up a development
machine and CI covers it. The commit rule now also says that `main` moves
fast enough for PRs to rot -- 396 commits in three days -- and that
conflict risk tracks how contested the files are rather than how large
the change is.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:56:58 -07:00
Brandon Thomas 7163ba3dee Release 0.7.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:04 -04:00
Brandon Thomas 07df4de63d Release workflow: pass the Azure signing secrets so Windows builds are signed (#1194)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:29 -04:00
Márk Bartalos 87b448b664 M1: write a valid xref when an incremental save has no objects (#1339) (#1424)
An unchanged classic-table file used to append xref immediately followed by trailer, which qpdf treats as a damaged file. A zero-length subsection keeps trailer-only updates without rewriting any object entry.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:57:37 -04:00
tdslot 4bfd3c2729 M14: add Lithuanian interface and date localization (#1401)
* M14: add Lithuanian interface localization

Register Lithuanian UI and date languages with three integer plural forms
and a 2,572-entry contributor-original catalog. Cover dynamic dialog labels,
contextual calendar and certificate wording, commands, tools, and every key
in the other bundled catalogs. Add real-shell calendar, inspector and
password-dialog regressions; preserve PDF values and document state.

Update localization documentation and asset attribution. Native-speaker/VLKK
terminology review remains outstanding.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is not
installed. Five rendered dialogs and contributor manual testing checked UI.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: preserve multilingual localization documentation

Restore the existing language descriptions instead of replacing general
localization guidance with Lithuanian-specific coverage. List every
registered language and control-channel code; keep Lithuanian details
alongside the existing catalog descriptions.

Include the previously omitted Kazakh, Polish, Slovenian, Greek and Turkish
catalogs in the shipped-language table without changing the historical
coverage snapshot or existing assessments. Keep Lithuanian roadmap notes
concise and additive.

Validation: all 22 registered languages appear in documentation; every
existing descriptive paragraph is retained. cargo xtask ci passed, with
cargo-deny skipped because it is not installed.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: record completed Lithuanian native-speaker review

The contributor confirms they are a native Lithuanian speaker and have
reviewed the translation. Remove obsolete pending-review labels from the
asset manifest and documentation; regenerate ATTRIBUTION.md. Remove the
redundant generated-message disclaimer from the Lithuanian description,
keeping the existing general localization documentation unchanged.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is
not installed. Catalog content and its SHA-256 are unchanged.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* Lithuanian: cover main's Copy Image labels, list lt last, check its glyphs

Adds the six Copy Image rows from #1363, moves lt to the end of LANGUAGES
and DATE_LANGUAGES (after tr), adds lt.tsv to the primary-font coverage
test, writes the JavaScript preferences path with '>' as the other
catalogs do (Inter has no ▸), and refreshes counts and hashes.

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

---------

Co-authored-by: OpenAI gpt-6.1-sol <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:54:15 -04:00
Baltsar 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
LabDennis 4a44eeaa39 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>
2026-10-12 08:39:39 -04:00
Pratham Bhanushali eab641ac8f feat(ui): add command palette arrow-key navigation (#1419) 2026-10-12 08:38:47 -04:00
Marco Arnulfo d7590ebfbe 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.
2026-10-12 08:38:01 -04:00
LabDennis 291d738160 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>
2026-10-12 08:37:11 -04:00
Earl Co 2839152af1 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>
2026-10-12 08:20:13 -04:00
krinry e33b66e9b5 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>
2026-10-12 08:18:37 -04:00
Tangu Achilis 5cce1b247f 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>
2026-10-12 08:16:42 -04:00
TheLastRoadRunner a259af95c3 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>
2026-10-12 07:57:03 -04:00
Marco Arnulfo a4c53f706f 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.
2026-10-12 07:55:39 -04:00
umesh429 a9a8ac63b7 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>
2026-10-12 07:54:52 -04:00
Marco Arnulfo 77c3f2003a 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.
2026-10-12 07:54:17 -04:00
Marco Arnulfo 240ea53b8b 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.
2026-10-12 07:53:44 -04:00
Marco Arnulfo 2ee8ce1ac1 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.
2026-10-12 07:53:10 -04:00
Marco Arnulfo 452ab51ab8 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.
2026-10-12 07:52:34 -04:00
umesh429 6f49967459 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>
2026-10-12 07:51:55 -04:00
Francesco Clemeno 0cb311d21c 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>
2026-10-12 07:47:35 -04:00
LabDennis efd68b377e M7: Preserve explicit page ranges across dialog redraws (#1304) (#1382)
* M7: Preserve explicit page ranges across dialog redraws

The shared marks/export range widget treated 1..=1 as uninitialized and
expanded it to the document length on every redraw. A first-page-only
request consequently exported or modified additional pages.

Separate all-pages default handling from the endpoint widgets. Populate
the full document bounds only in all-pages mode; preserve custom ranges
and their odd/even filters.

Add synthetic UI regressions for stable ranges, first-page-only image,
text and embedded-image exports, and pixel comparisons of added/updated
header-footers, watermarks and backgrounds. All three regressions fail
before the fix and pass afterward. Update milestone progress.

Fixes #1304

* M7: Limit page-range documentation to the progress log

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:24:18 -04:00
Ridwan 5bf6d17538 Issue #1368: keep text a dragged paragraph passed over (#1381)
Rewriting a paragraph also removed every line overlapping its box by more
than half, to clear fake-bold and repeated copies. Each drag is its own
edit, so after dropping one paragraph on another and dragging it on, the
covered paragraph was taken for a copy and deleted from the content
stream. A copy now also has to show the same text.

The same check is used when replacing a line, so a distinct line drawn
close enough to overlap (#1316) is no longer removed either.
2026-10-12 07:21:23 -04:00
Marco Arnulfo a0575f2478 fix(fonts): a /Differences code past 255 no longer encodes as its last byte (#1314) (#1394)
A simple font whose /Differences puts Euro at code 300 encoded "€" as
the single byte 0x2C, which the same font decodes as a comma. Simple
fonts show one-byte codes, so a larger code now has no glyph to encode
with: encode returns None and the caller falls back to another font.
The code stays in the decoding table as before.

Adds pdf::tests::differences_codes_past_one_byte_are_not_encoded,
which fails before this change.
2026-10-12 07:20:48 -04:00
Marco Arnulfo 24ccb5f3ae fix(forms): a literal Off value reads as Off outside buttons (#1320) (#1393)
The field reader dropped the value "Off" for every field kind, so a
text field, combo box or single-select list set to "Off" read as empty
and exported an empty value to CSV, text, XML, XFDF and FDF, although
the document stored it. "Off" is now treated as no value only for
check boxes and radio buttons, where it is the off state.

Adds tests::a_literal_off_value_reads_as_off_outside_buttons, which
fails before this change.
2026-10-12 07:20:15 -04:00
Marco Arnulfo 3e2877cbbb fix(forms): a whole look keeps the rest of an indirect /MK (#1322) (#1392)
set_props with a complete look read the widget's /MK without resolving
it, so an indirect one started from an empty dictionary: a check box
lost its Cross mark, its 90-degree rotation and any unknown entry, and
the loss survived a save. The /MK is now resolved first, as the
check_style branch already does; a direct /MK behaves as before.

Adds tests::a_whole_look_keeps_the_rest_of_an_indirect_mk, which fails
before this change.
2026-10-12 07:19:42 -04:00
Marco Arnulfo 0584425e67 fix(automation): content_update refuses text styling on an image (#1331) (#1391)
font, size, bold, italic, color and align were never read for an added
image, so content_update reported success, wrote the same image back,
marked the document dirty and added an "Edit content" undo step. They
are now refused with an error, as rotate, flip, crop and image already
are on text, before anything is applied.

Adds automation::text_styling_on_an_image_is_refused_without_an_edit,
which fails before this change.
2026-10-12 07:19:06 -04:00
Marco Arnulfo b6a1402bf3 fix(ui): the Fields panel lists a field on a missing page as unplaced (#1307) (#1390)
A widget whose /P is a page number past the end of the document (here
/P 99 in a one-page file) kept that page, and the Fields panel indexed
the page list with it to label the heading, which panicked. A page the
document doesn't have now gets the existing "Unplaced" heading.

Adds forms::the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced,
which fails before this change.
2026-10-12 07:18:32 -04:00
Marco Arnulfo af1c997aff fix(fonts): a composite /W run past the last CID stops there (#1313) (#1389)
A /W entry such as [4294967295 [100 200]] added the run index to its
first CID unchecked, so the second width overflowed: a panic with
overflow checks on, and with them off a width given to CID 0, which the
entry does not name. The run now ends at the last CID it can address;
/W [0 [100 200]] reads as before.

Adds pdf::tests::composite_width_run_past_the_last_cid_is_dropped,
which fails before this change.
2026-10-12 07:17:59 -04:00
LabDennis 2a004529dd M14: Preserve recovery snapshots without readable metadata (#1378)
* M14: Preserve recovery snapshots without readable metadata

Recovery writes the PDF before its metadata sidecar. A failed sidecar write
or interrupted save could leave a usable snapshot that the next listing
silently deleted.

List PDF snapshots without deleting recovery files. When metadata is
missing, unreadable, corrupt or refers to another key, retain the snapshot
and recover it without an original path so saving requires a destination.
Keep the existing password flow for encrypted snapshots.

Add regression coverage for failed metadata writes, repeated listing,
missing/corrupt/mismatched metadata and encrypted recovery. Update the M14
progress documentation.

Validation: the failed-write regression fails before the fix; all 11
recovery tests pass after it. Formatting and the 31-crate WASM check pass.
The full workspace and strict Clippy gates remain red; see the PR body
for the failing tests and diagnostics.

* Recovery: replace damaged metadata once its snapshot is recovered; one progress-log entry

Recovering a snapshot whose sidecar was missing, unreadable or for another
key now writes fresh metadata for it (recording whether it is encrypted), so
the damaged file doesn't linger. clear() also removes sidecars whose PDF is
gone; snapshots themselves are never removed by listing. The header and M14
milestone-row edits are replaced by a single progress-log entry.

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

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:35 -04:00
philip-tam 88d96c93a8 M2: snap shrunk interpolated images to whole pixels so scans stay sharp (#1377)
* M2: snap shrunk interpolated images to whole pixels so scans stay sharp

A scanned page (one 300 dpi image with /Interpolate true) was shrunk to a
ceil-sized raster and then sampled a second time at a fractional scale and
offset, which softened every edge next to Acrobat. An axis-aligned shrunk
interpolated image is now shrunk once to its rounded device rectangle and
drawn 1:1 at the rounded corner. Rotated, skewed, flipped, Type 3 and
non-interpolated images are unchanged.

On a synthetic scan page the mean error against an area-average reference
falls from 3.4 to 1.6 grey levels at 96 dpi (3.0 to 1.5 at 144 dpi).

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

* vendor: list the hayro snap patch as (14) in the Patches and Tests columns; correct gaps.md patch counts

The patch description had been appended to the Tests column of one of the
two hayro rows. gaps.md now counts the distinct patches vendor/README.md
lists (26 hayro-interpret, 15 hayro, 3 hayro-jbig2, 17 hayro-syntax).

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:12:02 -04:00
Yousef Mojahid 186c1dc767 M7: preserve Arabic text order in copy, search and editing (#1369)
* M7: preserve Arabic text order when copying and editing

Record per-line ActualText for shaped added text and preserve its logical order in Edit text. Cover mixed scripts, marks, copy/search, retyping and save/reopen; retain the drawing golden.

Assisted-by: Codex

* Edit text: keep only right-to-left ActualText out of line joining and bidi recovery

Word and Chrome wrap Latin ligatures in /ActualText spans mid-word; splitting
lines at every span broke such words into pieces in Edit Text. Only spans
whose ActualText has strong right-to-left characters keep their own line and
typing order now. Regression: a Latin fi ligature span stays on its line.

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>
2026-10-12 07:11:23 -04:00
Marco Arnulfo 2796f3de0d fix(print): refuse huge posters at the sheet limit instead of overflowing (#1335) (#1376)
A poster scale large enough to make both tile counts saturate at
usize::MAX overflowed when they were multiplied for the 1024-sheet
check: a panic with overflow checks, and a product that wraps to 1
without them, which let the sheet loops run. The headless doc_print
tool accepts any finite positive scale, so a script could reach it.

Multiply with checked_mul and treat an overflow as over the limit, so
the request gets the existing "more than 1024 sheets" error.

Adds tests::huge_posters_are_refused_at_the_sheet_limit, which panics
before this change and passes with and without overflow checks after it.
2026-10-12 06:27:50 -04:00
Marco Arnulfo 9593ff7419 fix(redact): removing comments keeps non-comment annotations (#1325) (#1375)
Remove Hidden Information counted and removed as a comment every
annotation that was not a link, widget, pop-up, file attachment or
redaction mark, so a Comments-only cleanup also deleted Watermark,
PrinterMark, TrapNet, Screen, Movie, RichMedia and 3D annotations.

Use pdfcraft_annot::is_comment_subtype, which the comments list already
uses, and keep excluding file attachments (removed with Attachments)
and redaction marks as before.

Adds tests::removing_comments_keeps_annotations_that_are_not_comments,
which fails before this change.
2026-10-12 06:27:04 -04:00
Marco Arnulfo f97f24cfbc fix(sign): a signature field with a null /V can be signed by name (#1328) (#1374)
The signature list counts a field as signed only when its /V resolves
to a dictionary, but signing an existing field refused any field that
had a /V key. A field with `/V null`, or a /V pointing at a null object,
was listed as unsigned and then refused with "the field is already
signed".

Both now ask the field for its signature dictionary through one
predicate. A field with a signature is still refused, and a field
without /V still signs as before.

Adds a_field_with_a_null_value_is_unsigned_and_can_be_signed, which
fails before this change.
2026-10-12 06:26:18 -04:00
Marco Arnulfo 0108a5b07b fix(preflight): find colour operators after any PDF whitespace (#1334) (#1373)
The PDF/A device-colour scan split content streams only on spaces and
line feeds, so `0 0 0 1 k` written with carriage returns, tabs, form
feeds or NULs between the tokens went unseen: verify reported no
DeviceCMYK, and convert gave the CMYK page an sRGB output intent.

Split on all six whitespace characters of ISO 32000-2 §7.2.3, using the
lexer's own `is_whitespace` (now exported from pdfcraft-cos) instead of
a second list.

Adds tests::device_colour_is_found_after_any_whitespace, which fails
before this change; its gray and RGB controls check that those pages
still get the sRGB output intent.
2026-10-12 06:25:42 -04:00
umesh429 27ce6f7584 CLI: audit rejects unknown options before reading the file (fixes #1332) (#1372)
`pdfcraft-cli audit` skipped option validation, so a misspelled
`--passwrod` fell through to a file-read error instead of naming the
bad option. Audit now runs `validate_options` first, like `info`, and
the options test covers it.

Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:25:06 -04:00
Marco Arnulfo ec5d9da36c fix(xfdf): text data from a one-field form keeps its commas (#1321) (#1371)
The tab-delimited export of a single field has no tab, so import read it
as CSV and kept only the text before the first comma. CSV quotes a value
that holds a comma, so a single name over several unquoted cells is now
read as that tab-delimited text. Exported files are unchanged, and CSV
(quoted, multi-line, one or more columns) reads as before.
2026-10-12 06:24:27 -04:00
Earl Co d2d1b16b21 M5: adjust Fill and Sign text size (#1285)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:13:48 -04:00
Omar Elders d57f740003 Refine search match selection and text failure jitter handling (#1291)
* Fix search match selection order and text reload failure recovery

1. rerun_find: Accumulate and sort matches across all loaded pages and pick the first match on or after the currently viewed page before flashing once, avoiding arbitrary viewport jumps to page 0 when viewing subsequent pages. 2. receive_result: Gracefully fall back to the next match at or after the viewed page without flashing when page text extraction fails, avoiding latent None state that caused viewport jitter on subsequent page reloads. 3. Add tests verifying match selection on viewed page, continuous multi-page drawing with active search, and text failure recovery.

* rustfmt

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

* Clippy: RenderRequest is Copy

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>
2026-10-12 05:52:36 -04:00
Master f81e23bd4e test(cli): reject unknown MCP options (#1342)
* test(cli): reject unknown MCP options

* Tests: wait longer for background text and rendering in viewer tests (FreeBSD CI flake); 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>
2026-10-12 05:46:41 -04:00
cyfung1031 bf4b6799f6 ci: keep line tables for workspace debug info (#1298) 2026-10-12 05:45:51 -04:00
cyfung1031 6d9eb9c3ca ci: run dependency layering once (#1286) 2026-10-12 05:45:13 -04:00
Brandon Thomas 78f00e45fe Release prep: 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:35 -04:00
cyfung1031 ef844cefb4 CI: keep craft-fonts configuration stable across Linux gates (#1284) 2026-10-12 05:44:33 -04:00
Paul Spooren 9a8517599b M12: accept literal digits in date formats (dd.mm.26) (#1352)
A date field formatted "dd.mm.26" (a leave form fixing the year) rejected
"15.05.26": parse_date read three numbers but the format only asked for a
day and a month. Digit runs in the format are now literal tokens that may
be typed or left out; a different number in their place is still rejected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:43:55 -04:00
Jules Kreuer 47d612817a M8: Clean indirect and form tag references (#1351) 2026-10-12 05:43:12 -04:00
nevicia2010-hub 51982db121 M3: match Greek final sigma in case-insensitive search (#1349)
* M3: use the macOS Finder label in the tab-menu regression

The UI already uses Reveal in Finder on macOS, but the regression test
queried Reveal in File Explorer on every platform. Select the platform's
label while preserving the disabled-action and inactive-tab assertions.

Co-Authored-By: Codex <noreply@openai.com>

* M3: match Greek final sigma in case-insensitive search

Fold capital, normal and final sigma to the same character so Greek words
match in either case. Cover exact search, whole words, glyph ranges and
bounded matches without changing the existing Turkish-I behavior.

Record the search fix and the macOS test portability follow-up in the
roadmap. Workspace tests, all-target Clippy, formatting, wasm, layering
and a synthetic CLI before/after comparison pass on macOS.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: nevicia2010-hub <259836477+nevicia2010-hub@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 05:42:27 -04:00
Joel Tony 9d6552291b Issue #1296: hayro-syntax applies SASLprep to AES-256 passwords (PdfCraft patch 16) (#1345)
Revisions 5-6 of the standard security handler key the file on the
password processed with SASLprep (ISO 32000-2 7.6.4.3.3, Algorithm 2.A
step a). pdfcraft-crypt writes and reads it so, but the vendored
hayro-syntax skipped the step ("TODO: Do the above.") and used the UTF-8
bytes as typed. A password that SASLprep changes (a decomposed "cafe" +
U+0301, a no-break space, fullwidth letters typed by an IME in full-width
mode) was wrong for the renderer: Protect with such an open password was
rolled back ("the password is incorrect"), and an AES-256 file with such a
password did not open in a session with it, although pdfcraft-cos
accepted it.

- hayro-syntax (vendored patch 16) tries an R5/R6 password in its SASLprep
  form (as pdfcraft-crypt computes it: as given if SASLprep refuses it,
  UTF-8, at most 127 bytes), then, when that differs, as given.
  stringprep, already in the tree through pdfcraft-crypt, is an optional
  dependency enabled by the std feature; without std nothing changes.
- Tests: hayro-syntax crypto::pdfcraft_password_tests (candidate list and
  R5/R6 dictionaries built from prepared passwords) and two engine tests
  (Protect and reopen, and pdfcraft-cos-encrypted files) for the three
  cases, with precomposed "caf\u{E9}" as the control. The dictionary test
  and both engine tests fail without the fix.

Fixes #1296.
2026-10-12 05:41:38 -04:00
MarekSpodBiedry 549fa7ae04 xtask: stop headless Chrome leaving macOS code-sign clones (#1196) (#1292) 2026-10-12 05:40:50 -04:00
ysrdevs 4b45fc9cb4 XFA: one script thread per event, and the live form kept up to date in place (#1355)
* XFA: one script thread per event, and the live form kept up to date in place

A form with hundreds of calculate scripts used to spend its open-time
budget on set-up: every script got a thread of its own, and the engine
dropped its live form after every value a script settled, rebuilding
the whole tree for the next script.

The js crate gains xfa_session::XfaSession: the scripts of one event
run on one thread (with the script stack), each in a fresh JavaScript
context or FormCalc interpreter on a copy of the form as the engine
holds it then, so scripts share nothing, a script that fails halfway
leaves nothing behind, and what a script changed reaches the next one
only through the engine. A script past its time limit is abandoned on
the thread and the session is dead from then on; a fault in the engine
(a panic) loses only that script, the thread is replaced. On wasm the
scripts run inline, as before.

The engine's runner keeps one session per event and, for a value, now
refreshes the one field in its live form in place (xfa::refresh_value,
computing the value as the tree builder would: saved values, template
defaults) instead of rebuilding the tree; rows, presence, access,
resets, relayouts and data rewrites still rebuild it. Under test, the
live form is compared with a fresh build before every script and at
the end of every event, and a difference ends the test binary (a panic
would be caught by the engine's guard and turned into a script error).

210 calculate scripts (one per row of 10 sections x 20 rows, plus a sum
per section), debug build, instrumented inside the engine: the script
event on open 495 ms -> 204 ms (of which live-form rebuilds 217 ms ->
0), on a change 300 ms -> 172 ms; through the tools, layout included,
open 1.27 s -> 0.97 s and a change 0.44 s -> 0.22 s. The rebuild cost
removed grew with form size times script count.

* CI: the GoToR tests' case lists are only grown on Unix, so Windows must not warn on their mut
2026-10-12 05:39:49 -04:00
Bryan Mark 0e55d07b5f M12: say when a form's scripts were turned off (#1251)
* M12: say when a form's scripts were turned off

When an XFA script runs past its time limit it is abandoned and the
form's scripts are turned off for the document. The only trace was an
error line in the JavaScript console, so calculations and buttons just
stopped working.

- Engine: Document::xfa_scripts_off() reports the state (set on open and
  on an edit), so a viewer shows it without reading error text. doc_info
  reports it to agents as xfa_scripts_off.
- App: when it happens (at open or after a click) a toast says so once,
  and the notice bar says so from then on, ahead of its other notices,
  with a JavaScript Console button. A notice bar the user had closed
  comes back for it; closed again, it stays closed.
- The sentence is translated in the 17 catalogs that are complete
  (hashes in ATTRIBUTION.toml updated).

Tests: two kittest UI tests (runaway at open; runaway click after the
bar was closed), which fail without the app change; engine assertions
for both states; a doc_info tool test.

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

* Catalogs: the scripts-off notice in Greek, Polish and Telugu; refresh hashes

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:42:56 -04:00
Krzysztof Cieślik 7d419a6c73 feat(edit): remove all links command and context menu (#539) (#1213)
* feat(edit): remove all links command and context menu (#539)

- Expose 'edit.remove_links' and 'edit.remove_all_links' commands to remove all links across the document or on specified pages.
- Add right-click context menu options for links: Properties, Delete, and Remove all links in both Link tool and Select tool modes.
- Extend automation tool 'links_remove' with optional 'pages' parameter and removal count.
- Add notification feedback in UI for link deletion.
- Mark 'edit.remove-all-links' as shipped in Parity with evidence tests.
- Add unit and integration tests covering automation, engine, and UI undo/redo.

* Remove all links: one command id, catalog rows, link menu only on a link

- Drop the duplicate edit.remove_all_links command (same label as the
  existing edit.remove_links, so it showed twice); edit.remove_links now
  runs remove_all_links with its toast.
- Add the toast strings (No links to remove, Removed 1 link, Removed {n}
  links) to all 20 catalogs.
- The canvas context menu shows the link items only when the right-click
  hit a link (LinkView::context, set per right-click), so a link left
  selected no longer takes over the comments menu, and the Link tool no
  longer replaces the comments menu away from links. Regression test.

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

* Restore the PR's links_remove pages test (lost with the workdir conflict)

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>
2026-10-12 03:31:39 -04:00
git-monchiant e8e275cbd4 Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable (#1217)
* Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable

Chrome (Skia) writes /ActualText around each Thai, Indic or Arabic cluster
whose shaped glyphs (a tone mark moved clear of a vowel, a ligature) have no
Unicode of their own. Text extraction ignored it, so those marks were lost:
"น้ำหนัก" came out as "นาหนัก", "เป็น" as "เปน", and searching for any
such word found nothing.

- hayro-interpret patch (25): Device::actual_text hands the device a
  marked content sequence's /ActualText, inline or named in /Properties.
- TextDevice turns the glyphs of a replacement into one glyph carrying the
  replacement text, boxed around them. The outermost replacement wins, an
  empty one stands for no text, and one spanning several lines or writing
  directions keeps per-glyph text so selection and lines still work.
- Canva draws text twice (outline or shadow) and marks only one drawing:
  the unmarked redraw at the same places takes the same replacement, so the
  existing redraw detection still drops the copy.

Checked on 1,114 real-world PDFs against the previous build, with pdftotext
as the reference: 957 extract byte-identical text, 157 change and all of
them read closer to the reference (exact matches 3 -> 122), none got worse,
none newly fail, and total extraction time is unchanged.

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

* Text extraction: bound the work of the ActualText redraw dedupe

`TextDevice::finish()` compared every replacement with every unmatched glyph
in up to 4096 grid cells, so a hostile page stacking ~10^5 glyphs and ~10^5
replacements at one spot meant ~10^10 comparisons (a hang). It now charges
each visited cell and each candidate run against a per-page budget
(`MAX_REDRAW_WORK`, 2^22) and stops deduplicating once it is spent; redraws
found so far are still merged. A regression test feeds 100,000 replacements and
100,000 non-matching glyphs at one place.

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

---------

Co-authored-by: git-monchiant <4667243+git-monchiant@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:28:27 -04:00
cyfung1031 fcf6e39d46 M14: tune Windows low-DPI JetBrains Mono hinting (#1264)
* M14: tune Windows low-DPI mono hinting

* Theme: ignore the raster-sharpness probe test

`windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage`
asserts exact egui rasterisation deltas that move with egui upgrades; it is now
`#[ignore]` (run with --ignored when retuning). The tweak and its policy test
stay.

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>
2026-10-12 03:25:25 -04:00
srt8hellcat2-ui 06480e395d M3: add PDF tab context menu (fixes #1233) (#1266)
* feat(ui): add PDF tab context menu (#1233)

* Tab menu: reveal through D-Bus off the UI thread

`dbus-send --print-reply --reply-timeout=2000` (and the xdg-open fallback)
could block the frame for 2 s or more; on Linux and FreeBSD the reveal now runs
on a `pdfcraft-reveal` thread and a failure is logged. The existence check stays
on the UI thread and is still reported; Windows and macOS launches are
unchanged. Refreshes the catalog hashes after the main merge.

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

---------

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:15:02 -04:00
ngrafton2006 7183bf4264 Comments: "Other colour…" picker and recent custom colours under the swatches (#1193) (#1261)
* Comments: "Other colour…" picker and recent custom colours under the swatches (#1193)

The annotation tools offered only the ten fixed swatches. The shared swatch
grid (comments' quick bar, right-click menu and Comment Properties, and the
other colour choosers that use it) now ends with "Other colour…", a full
colour picker that starts from the current colour; Apply commits it once.
Applied custom colours are kept as a "Recent colours" row (newest first,
five at most, swatches not repeated) for the session.

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

* Comments: import kittest's Queryable in the Other colour test (#1193)

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

* Comments: "Other colour…" is a "+" swatch so the grid keeps its height (#1193)

A row under every swatch grid made dense dialogs (Redaction Tool
Properties has two grids) taller, pushing their lower controls out of
reach. The picker now opens from a swatch-sized "+" at the end of the
first row; the Recent colours row appears only once a custom colour has
been applied.

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

* Comments: each colour grid keeps its own "Other colour…" state

Two swatch grids drawn in one parent (Redaction properties, Prepare a form) shared the
picker's open state, so "+" opened both. The state and the swatch grid are now keyed on
each grid's own place in its parent.

* Comments: test that two colour grids in one parent open separately

* ATTRIBUTION.toml: catalog hashes after merging main

---------

Co-authored-by: ngrafton2006 <ngrafton2006@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:11:33 -04:00
Earl Co 4e53e5ffbd Tests: normalize generated streams in Arabic output golden (#1275)
* Make Arabic output golden independent of Flate backend

* Arabic golden: keep /Filter and /DecodeParms in the hashed bytes

Normalise only the raw stream bytes (and so /Length, which the writer
takes from them), so a change that stops compressing generated streams or
switches filters still fails the golden. Fingerprint recomputed (same with
and without craft-fonts).

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>
2026-10-12 02:54:21 -04:00
Earl Co 51359beaea Keep manual review visible in accessibility summaries (#1224)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:52:35 -04:00
Eduardo Soares 4541974ef9 Reading colours: replace document colours for reading comfort (#897) (#1270)
* test: give faux-redaction audit its own workdir (parallel-test flake)

* hayro: ColorReplacement type and luminance mapping (reading colours, #897)

* fmt: engine dates (upstream Greek localization miss)

* hayro: apply ColorReplacement to solid text and line-art paints (#897)

* render: ReadingColors view setting flows into RenderSettings (#897)

* engine: per-document reading colours state (#897)

* automation: view_reading_colors tool; page_render honours it (#897)

* ui: Reading colours preference (Replace Document Colors, #897)

* ui: seed reading colours on revision and signed-version opens (#897)

* parity: a11y.replace-page-colors shipped (#897)

* render: map stencil-mask paints through reading colours (#897)

An ImageMask stencil's solid paint was baked into the raster bytes raw,
bypassing the reading-colours hook every other solid fill goes through:
under white-on-black, CCITT scans, JBIG2 text and stencil logos drew
black ink on the black mapped background and vanished. Route it through
replaced(paint, false) — stencils are line art in disguise — and cover
it with an ImageMask fixture test sampling mapped ink on a mapped
background.

* render: print previews render with reading colours off (#897)

Print previews went through the document's render pool, whose config now
carries the reading colours: a white-on-black user saw black sheets in
the Print dialog while the actual print stayed uncoloured. The request
tag is the per-request channel that already crosses the render boundary,
so PRINT_TAG now lives in pdfcraft_render and the renderer honours it by
rendering previews with reading colours off — a preview's job is
fidelity to the printout. The main view stays recoloured. Covered on
both render paths (inline PageRenderer and pool worker).

* ui: seed reading colours on created-document opens (#897)

open_created (comment summaries) and open_created_bytes (images, text,
blank pages, clipboard) pushed new DocViews without seeding the reading
colours, unlike every other open path. A created document now starts
with the app-wide preference too; the seed-on-open test drives both
paths.

* docs: reading-colours flag echo and display-fidelity parity notes (#897)

The view_reading_colors description now says a flag that differs from
the mode's preset default makes the echoed mode read custom, and the
a11y.replace-page-colors parity notes record that print previews and
view snapshots render with the document's true colours: reading colours
apply to the reading view only.

* Merge follow-ups: reading colours beside preview renders and the new catalogs

- Renderer::new carries both color_replacement and the new preview flag
- our raster tests construct RenderRequest with preview: false
- the preference dialog grew past the window: scroll the combo into view
  and give the fixture a valid xref so no repair banner covers the dialog
- checkboxes use the crate's square-box widget (clippy disallows egui's)
- Kazakh and Turkish catalogs gain the ten reading-colours strings

* Reading colours: record the hayro patch in vendor/README.md; add missing translations

vendor/README.md gains hayro patch (13): `RenderSettings::color_replacement` /
`ColorReplacement`, the `render_pdf` signature change and the removed
`[dev-dependencies.log]`, with its tests. Adds the ten reading-colours labels
to el, es, pl, te and zh-hant.

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>
2026-10-12 02:49:31 -04:00
Jeremy Yao 8cacb44728 M3: follow Windows' Animation effects setting; add an Animation effects preference (#1235)
* M3: follow Windows' Animation effects setting; Smooth scrolling preference

With Settings > Accessibility > Visual effects > Animation effects turned
off, the wheel still eased: egui spreads a coarse wheel step over about a
quarter of a second, for scrolling and for Ctrl-wheel zooming, and has no
switch for it.

- crates/ui-egui/src/motion.rs reads each frame's wheel input the way egui
  does, minus the easing, and hands it to the scroll areas and the views
  (zoom_delta). A test sums what egui eases and compares it with what is
  read at once, and fails if egui stops easing.
- With the system asking for no animations, egui's own animations take no
  time and the progress card's bar steps without its sweep.
- The desktop app asks WinRT's UISettings.AnimationsEnabled at launch and
  every two seconds of use, so a change applies without a restart.
- Preferences has Smooth scrolling: Use system setting (default), On, Off.
  On or Off decides the wheel's scrolling and zooming, and scrolling to a
  place, whatever the system says. Saved, settable as smooth_scrolling on
  the control channel, translated in all 20 catalogs.

How far a notch scrolls or zooms is unchanged. macOS, Linux and the
browser aren't asked for their setting yet; Off works everywhere.

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

* Credit Jeremy Yao by name in About (contributors/people.toml)

My own entry: real name and display name "Jeremy Yao".

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

* M3: rename Smooth scrolling to Animation effects; it decides every animation

The preference also governs Ctrl-wheel zooming, so "Smooth scrolling"
described only part of it. As "Animation effects" (Use system setting, On,
Off) it now decides every animation in the window, the way the Windows
setting of the same name does:

- the wheel's scrolling and zooming, and scrolling to a place (as before);
- egui's own animations, the progress card's sweep, and the chrome's hover
  and press washes (new on main, with durations of their own), which used
  to follow the system alone or not at all.

Saved and set on the control channel as animation_effects (system, on,
off); the unreleased smooth_scrolling name is gone. The three labels are
retranslated in all 20 catalogs.

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

* Tests: the app's two real-GPU tests take turns (FreeBSD SIGSEGV)

FreeBSD CI died with SIGSEGV in the pdfcraft binary's tests on this branch
(d4b928f1). The log shows a_gpu_error_while_a_window_is_set_up... (started
first, still running) and pipeline_creation_error... (started shortly
before) both in flight when the process died: two test threads inside the
graphics libraries at once. Nothing else still running was native code.

Both now take one lock for the whole test, as the UI tests already do for
the same reason (crates/ui-egui/tests/view.rs). Each takes about two
seconds with a device, which is why they can overlap.

The FreeBSD job has been failing at the same step with the same exit code
on main (84a75eae, 4a1e205f, 3d2bdfe7: every completed run since #1042
added the second real-GPU test) and on unrelated PRs. Their logs weren't
available to confirm it is the same crash.

Not reproduced locally (no FreeBSD machine here); the next FreeBSD run is
the check.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:46:40 -04:00
cyfung1031 c1d98946a9 M7: open local GoToR PDF links natively (#1265)
* M7: open local GoToR PDF links natively

* GoToR links: read only regular .pdf files under a size cap; add missing translations

A GoToR link in an untrusted PDF could name /dev/zero (unbounded read), a FIFO
(UI thread hangs) or a directory. Both the UI path and the `link_open` tool now
go through `pdfcraft_engine::read_remote_document`, which requires a `.pdf`
extension and a regular file (metadata checked before opening) of at most
1 GiB, and stops reading if the file grows past the cap. Regression tests cover
directories, a /dev/zero symlink and a FIFO (unix) in the engine, the tool and
the UI.

Adds the four link notifications to all 20 catalogs and "The existing action
will be preserved." to el, es, pl, te and zh-hant.

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>
2026-10-12 02:34:19 -04:00
Raj Patel 2aea7dcfeb M10: print from the web build through the browser's print dialog (gap 4) (#1243)
* Gates: fix two clippy failures on main

- ocr_ui: "Also recognize pages that already have text" used egui's own
  checkbox, which clippy.toml disallows (#1184 landed beside #1173);
  use widgets::checkbox like every other box.
- camera_edges: Rust 1.97's manual_range_contains; the rewrite keeps the
  old answer for a NaN angle (false) with an is_finite check.

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

* M10: print from the web build through the browser's print dialog (gap 4)

The web build had no printer: spool::submit refuses on wasm, so Print
could only download the print-ready PDF. Now its Print dialog offers
"Browser's print dialog" (the default there), which reaches every
printer the operating system knows.

- web_print: PdfCraft draws the print-ready PDF's sheets itself
  (engine Exporter), one sheet a frame since wasm has no threads:
  300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most
  64 MP a sheet and 1 GiB of PNGs a job. The PNGs become blob URLs
  and print from a hidden srcdoc iframe, one sheet per page, margin 0.
- Chromium prints every page at the default @page size, so a sheet of
  the paper's size turned sideways is rotated 90 degrees CCW onto it
  (as drivers do); other sizes get named @page rules.
- Copies, two-sided and colour are greyed out (the browser's dialog
  sets them). If print() is refused (e.g. a sandboxed frame), the
  print-ready PDF downloads instead, with the reason.
- pdfcraft-print stays free of web APIs; six new strings in all 20
  catalogs (ATTRIBUTION hashes refreshed); docs and parity updated,
  gap 4 closed.

Verified in headless Chromium against a trunk build: three sheets at
300 dpi, the landscape sheet turned, no blank trailing page, and the
download fallback. Firefox and Safari not yet tried.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:31:36 -04:00
Earl Co b1784fc2d9 Accessibility: add visual tag properties panel (#1216)
* Expose tag parent and depth for hierarchy view

* Accessibility: add visual tag properties panel

* Show collapsible tag hierarchy in accessibility panel

* Open checker findings in the matching structure tag

* Translate Tags panel across supported languages

* Tags panel: walk the structure tree once per edit, not every frame

The Tags panel and checker findings that open a tag share a tag list cached per
document and edit generation.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:28:56 -04:00
Earl Co e4527a855f Accessibility: persist human review decisions for an exact PDF revision (#1239)
* Save accessibility review decisions against PDF revision

* Accessibility review sidecars: read them with a 1 MiB cap

The sidecar path is the caller's and may name any file, so reading it
whole was an unbounded allocation. Stop past 1 MiB and refuse an
oversized file as not a sidecar (load fails, save leaves it alone), like
malformed content. Tests cover malformed, binary and oversized files.

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>
2026-10-12 02:10:50 -04:00
Krzysztof Cieślik bde9dc52fe honour the /Lab colour space /WhitePoint (#1203) (#1237)
* fix: recognize paragraph drafts as unsaved work (Issue #811)

* fix(color): honour the /Lab colour space /WhitePoint (#1203)

The old path fed every Lab value to the bundled ICC transform as if the
space's white point were D50, so a /Lab space with any other /WhitePoint
(e.g. D65 [0.9505 1 1.089]) rendered off by several levels per channel
and a D65 blue came out 45 levels too red.

Map L*a*b* to XYZ in the space's own white coordinates (ISO 32000-2
§8.6.5.4), Bradford-adapt it to D65 and convert to sRGB directly, as
Poppler does. Missing or degenerate white points fall back to a flat
white and hostile components are clamped without panicking.

Add a render regression that checks the five reported flat patches of
the D65 white-point fixture against their CIE sRGB values.

* Lab: map normalised Indexed palette components onto L*/Range; drop unused LAB.icc

An /Indexed palette over Lab passes its entries as byte/255 (manual_scale),
which the new Lab conversion read as raw L*a*b* and drew near black. Map
them onto L* 0..100 and the a*/b* /Range, with a regression test. Record the
patch as hayro-interpret (25) in vendor/README.md, and remove LAB.icc (no
longer referenced) with its ATTRIBUTION, NOTICE and assets README entries.

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

* Main gates: rustfmt tests/viewer.rs; a type alias for the refresh-steps array (clippy type_complexity)

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>
2026-10-12 02:08:21 -04:00
komo1iddin 13637b8440 M7: transform private image crops across nested states and streams (#1269)
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:38:32 -04:00
Γιῶργος (George) 5a91d1b7d0 fix(i18n): refine Greek translations and May abbreviation (#1262)
Co-authored-by: George Athenakis <2944375+Camthalion77@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:35:56 -04:00
Earl Co 8256744331 Export accessibility review comparison as HTML (#1234) 2026-10-12 01:34:08 -04:00
Earl Co 829afbc985 Move sibling structure tags in reading order (#1229) 2026-10-12 01:33:55 -04:00
jcl-bit 26a68d9765 Units: read the Windows measurement system from the registry, not reg.exe (#1227)
The desktop app started `reg.exe query "HKCU\Control Panel\International" /v iMeasure` at every launch to pick the default Units (#463). On managed PCs reg.exe is a process EDR and WDAC/AppLocker setups watch, and a Sandbox process-creation audit showed it as the one child process left at startup. Read the value in-process with winsafe's safe RegGetValue (advapi feature) instead. Behaviour is unchanged: "1" is inches, "0" is millimetres, anything missing or unreadable falls through to the locale check. The value-to-unit mapping is a pure function with a unit test, plus a Windows test that the registry read yields a known value.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:33:41 -04:00
srt8hellcat2-ui 4812e49992 M10.5: load driver options for CUPS printer instances (#1226)
Use the queue component of a validated CUPS destination to locate its PPD,
while preserving the full queue/instance name when querying lpoptions.
Cover instance defaults and command arguments with synthetic PPDs and a
stand-in process, retaining path validation and the existing public API.

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
2026-10-12 01:33:29 -04:00
SteamSmith 486b1bbcb1 control: close the connection on malformed input before auth (#1259)
control::serve documents that the first request on a connection must be
`auth` and that anything else closes the connection. A first line that
wasn't valid JSON got a parse error but left the connection open, unlike a
wrong token or a non-auth request. Close it in that case too; after a
successful auth a malformed line still just gets the parse error.

Adds control::transport_tests::malformed_input_before_auth_closes_the_connection,
which fails before this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:33:06 -04:00
Earl Co 9c8cf1a701 Add case and whole-word options to redaction search (#1258) 2026-10-12 01:32:53 -04:00
Raj Patel d0975b974d M5: quick-bar colour recolours the selected comment (#1191) (#1255)
* Fix selected comment quick-bar color (#1191)

* M5: skip quick-bar recolour when commenting is forbidden (#1191)

The quick-bar colour swatch now checks allows_annotation() before
restyling the selected comment, matching the canvas and context-menu
gating. Without it, a restricted document showed a "Change comment
properties failed" toast on every swatch pick; the engine already
rejected the edit. Adds a regression test on an encrypted, print-only
fixture that fails without the guard.

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

* M5: quick-bar colour also recolours a selected arrow (#1191)

Arrows are saved as Line annotations with an ending, so tool_for()
reports them as the Line tool and the Arrow tool's swatch never matched
the selected arrow. comments::made_by() treats the Line and Arrow tools
as the same kind. Adds a regression test that fails without the fix.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 01:32:40 -04:00
Earl Co f9bec964cb Accessibility: compare checker results after each recheck (#1250)
* Compare accessibility checks after document fixes

* Assert both pre-fix title findings in comparison test
2026-10-12 01:32:26 -04:00
Diego Ospina ef49fad861 M5: Insert text snaps its caret to the text (#345) (#1249)
On upright horizontal text of unrotated pages the caret's tip goes to the
nearer character edge on the baseline, sized to the text; elsewhere it stays
at the click. The size is mapped to user space, so /UserUnit pages match.
Parity stays partial: snapping is UI-only.

AI-assisted (Claude Opus 5.5), reviewed by GPT-5.6-terra (Codex CLI).
Native review welcome.

Co-authored-by: Diego Ospina <diegozpy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:32:12 -04:00
cyfung1031 5cccbd6ba5 M7: report unsupported link actions without a JavaScript claim (#1242)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:31:50 -04:00
srt8hellcat2-ui b939becb06 M0: validate the committed parity manifest in xtask tests (#1214)
Parse the committed parity manifest in the existing xtask unit-test suite
so duplicate TOML keys fail cargo test --workspace.

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
2026-10-12 01:30:08 -04:00
cyfung1031 b84ab4507a M13: isolate automation workdirs by call site (#1223) 2026-10-12 01:29:56 -04:00
Omar Elders 31938de20a Fix viewport jitter when annotating with search active (#1254) (#1283)
When an annotation was added or edited, page_changed cleared search matches for the page and reset f.current to None. When the background text reload finished, refresh_find_page saw f.current.is_none() and invoked flash_match(), setting view.goto and causing the canvas viewport to jump after every drawn element.

- Preserve search state in page_changed instead of prematurely clearing matches and current match index.
- In refresh_find_page, gate flash_match() so it only jumps when no match was previously selected (e.g. initial search query), avoiding viewport jumps during background text reloads.
- Add regression test marking_while_searching_does_not_jitter_or_reset_search_state.

Fixes #1254
2026-10-12 01:29:39 -04:00
Earl Co 05a862995d Accessibility: show figure findings on the page (#1280)
* Highlight locatable accessibility figure findings

* M12: locate annotation and form accessibility findings

* M12: keep one contributor profile entry

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:29:25 -04:00
Raj Patel 1970935587 M14: edits let go of the previous working file before writing the next (gap 13) (#1279)
* M0: keep the camera edge test NaN-safe under clippy's manual_range_contains

Clippy 1.97 flags `d < 0.35 || d > PI - 0.35` in the camera edge hit test. Its suggested
`!(0.35..=PI - 0.35).contains(&d)` would count a NaN direction as a hit, which the two
comparisons did not, so the NaN check is kept explicitly. Behaviour is unchanged.

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

* M14: edits let go of the previous working file before writing the next (gap 13)

Every edit writes a new working file: the original plus one appended revision. The previous
one stayed alive in the working bytes, the display bytes and the renderer while the next was
written, so after a few edits a 1 GiB file peaked at three copies.

Each refresh path (full, comments/form, content, undo, redo, after save) now releases them
first: the working and display bytes fall back to the original (already held by the editor)
and the renderer is replaced by an empty one until the refresh rebuilds it. A refresh that
still fails after rollback draws the original instead of nothing. Save still writes from the
editor, never from these.

Measured on a synthetic 1 GiB file (release build, Linux): five edits peak at 2.1 GiB instead
of 3.1 GiB, in 8.5 s instead of 16 s. Saving still peaks at three copies; reading from disk on
demand stays blocked on the renderer (hayro parses one contiguous buffer, gap 2). gaps.md,
the parity notes and the roadmap record both.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 01:28:01 -04:00
DWAA1660 c52072b678 Add editable page-range selection to Organize Pages (#1092) (#1174)
* #1092: Add editable Organize page-range selection

Generated with Codex.

* #1092: Leave overlapping main CI cleanup to PR #1167

Generated with Codex.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:25:38 -04:00
Krzysztof Cieślik 84a75eae1b M10: Presentation (.pptx) export (#1137) (#1162)
* feat: presentation (.pptx) export (#1137)

- Implement OpenXML PresentationML (.pptx) generator in crates/export/src/pptx.rs:
  - Generate [Content_Types].xml, _rels/.rels, core properties, presentation.xml
  - Build slide master, blank slide layout, and DrawingML theme
  - Render slides per page: largest text block as title shape, remaining paragraphs as body text box with font size, bold, italic, and color formatting
  - Embed images with proper DrawingML picture shapes and relationships
  - Generate DrawingML table shapes with column grid and span merging
- Wire OfficeFormat::Pptx into crates/engine/src/compare.rs and export_office
- Register export.pptx command and mark Presentation (.pptx) Ready in catalog
- Support pptx in automation doc_export_office tool
- Wire export.pptx into UI commands and file save dialog
- Add unit and integration tests across crates/export, engine, automation, and ui-egui
- Update create.export-pptx parity feature evidence and docs

* pptx export: saturating slide positions, matching cell-border tags, theme ea/cs fonts, package test

- Heights and the running vertical position use clamped/saturating arithmetic, so a hostile
  text size or image rectangle (1e300, NaN, inf) can't overflow i64; the item loop uses get().
- The table cells' a:lnR and a:lnT borders were closed with </a:lnL>, which made every slide
  with a table malformed XML; they now close with their own tags.
- The theme's major/minor fonts carry the schema-required a:ea and a:cs children.
- New test walks the produced zip entry by entry (sizes, inflation, central directory), checks
  every XML part's tags balance, the required parts, content-type overrides, relationship
  targets and that no placeholders are used; a hostile-sizes test covers the overflow.
- Test literals gain main's Block/Image rotation and size fields.
- Parity notes say what is still unverified.

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

---------

Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:25 -04:00
Fabio Lussi 0a447ee1ab Comments panel: show Persian and Arabic comments in reading order (#1079) (#1138)
* Comments panel: show Persian and Arabic comments in reading order

Comment bodies and replies with right-to-left script were laid out with
their words in logical order, left to right. They now wrap through
bidi::wrap: each paragraph takes its direction from its first strong
character, lines break in reading order, each line is reordered (UAX #9)
and right-to-left lines sit against the right edge. Author names get the
same treatment on one line. Persian and Arabic-Indic digits, which
egui's shaper drew reversed, now read left to right, file names
included.

Fixes #1079

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

* RTL comments: cache wrapped lines between frames

wrapped_text measured every candidate line of every right-to-left comment on every frame.
The wrapped lines are now kept in egui's temp data, keyed by a hash of (text, font, width,
pixels per point, collapsed); the cache starts over at 256 entries, so it stays bounded.
A collapsed comment wraps only its first 1024 characters (it shows one line), still with
the ellipsis when the text goes on. Test: second frame reuses the wrap, the cache stays
bounded, a very long collapsed comment is one line.

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

---------

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:22:12 -04:00
Earl Co a8940f0003 M10: Create separate PDFs from multiple files (#1183)
* M10: Create separate PDFs from multiple files

* Separate PDFs: translatable notices

"Left out: {names}" and the per-file reasons (already a PDF, no unused output name, no
output folder was chosen) go through tl!; the browser's download notice is a plural
("Downloaded {n} PDF" / "Downloaded {n} PDFs") via trn. Rows in all 20 catalogs, with
each language's plural forms; catalog hashes refreshed.

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>
2026-10-11 21:20:13 -04:00
Eduardo Soares 012b34a2a5 M4: rename colliding form field names when combining (organize.field-name-conflicts) (#1155)
* M4: rename colliding form field names when pages are imported or combined

Insert Pages from File, Combine Files and the grouped combine registered
incoming form fields under names the destination already used, silently
blurring them into one field (fields of one fully-qualified name are one
field, so their values mix). Importing now walks both sides' fully
qualified names (parent.T.child) — including names that hang only off
page widgets, which /AcroForm never lists — and renames a colliding
field's own /T to the first free variant (Text1-1, Text1-2, …), so its
children keep their place in the tree and the second of two colliding
incoming fields is renamed too.

A field_conflicts policy (rename by default; merge keeps the older
behaviour) and reporting — ImportOutcome::renamed_fields, the combine
outcomes, and a field_name_conflicts(dst, src) pre-scan — let callers
choose and prepare; engine, tool and Combine-tab wiring follow.

* M4: engine and tool wiring for renamed field names

Insert Pages from File carries a field_conflicts policy (rename by
default) and reports what it renamed: a repair-log note names each
old → new pair and warns that scripts referring to the old names were
not rewritten, and the undo step's label counts the renames, so nothing
is slipped in silently. Undo restores the original names, as ever.

The session's combine paths gain reporting variants —
combine_unlocked_reporting, combine_grouped_reporting — returning the
bytes and the renames, and doc_combine takes field_conflicts
("rename" | "merge") and lists renamed_fields [[old, new], …] in its
result when fields were renamed, so agents can find what their scripts
must call the field now.

* M4: the Combine tab warns before combining that field names collide

The Warnings column previews, per file, the form fields whose names
collide with earlier files' and will be renamed (Text1 → Text1-1, …),
computed once per list by a new in-order pre-scan (organize
combine_field_conflicts over the whole list, engine wrapper over the
tab's byte sources; a file that cannot be opened previews as nothing,
as its row already says why). The message is plural-aware and
translated in all sixteen catalogs.

* M4: parity for renamed field names on combine and insert

organize.field-name-conflicts ships: rename by fully-qualified name
(or merge, on request), reported everywhere it happens — repair log
and undo label on insert, doc_combine's result, the Combine tab's
warning column. The notes say what is still missing: the tab's preview
ignores narrowed page ranges, the UI offers no merge choice,
Create ▸ Multiple files renames silently, and scripts and XFA bindings
that reference old names are never rewritten.

* M4: review fixes for the field-name conflict work

Four findings from the PR's code review:

- Clippy: the -D warnings gate failed on the new engine tests (a
  format! with no interpolation, two drop(doc) no-ops on a
  reference); the branch could not pass its own CI.

- The Combine tab's preview warned that a file split into parts
  collides with itself: the preview made one source per row, while
  the combine groups parts of one file and imports it once. The
  preview now dedupes by group (each group's first entry stands for
  the file, as the combine does), and every part shows the file's
  preview.

- Collision detection compared /T bytes, so one name written in two
  encodings (PDFDocEncoding, UTF-16BE) did not collide — the exact
  silent blur this work exists to prevent — and renaming a UTF-16BE
  name rewrote it as from_utf8_lossy mojibake. Names are now compared,
  suffixed and written as decoded text (PdfString::to_text /
  PdfString::text), which is also how the forms crate reads them.

- The engine's combine_unlocked/combine_grouped duplicated their
  _reporting twins line for line; they now delegate with the Rename
  policy, as organize's own wrappers already did.

* Field-name conflicts: only Combine and Insert from File rename

import_pages (Duplicate, Replace) and extract_pages (Extract, Split) go back to Merge, so
copies of a document's own fields keep their names and a source with two same-named field
roots isn't split apart. Pasting pages into the document they were copied from also merges;
pasting into another document renames as Insert from File does. Regression tests for
duplicate, replace, extract/split (organize), duplicate (engine) and paste (ui-egui); the
PR's rename tests call import_pages_with(Rename) explicitly.

Merge fix-up: main made edit labels a translatable Label, so the rename suffix is now two
wordings in EDIT_LABELS ("Insert pages from {name} — 1 field renamed" / "— fields
renamed"), translated in all 20 catalogs. The Combine warning's @plural row is added to
el, kk, sl and tr. Catalog hashes refreshed.

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>
2026-10-11 21:18:21 -04:00
Bjorneseth 09287e547f M5.7: Fill & Sign Scan tab for photographed signatures (#1110) (#1111)
Add a Scan tab to Fill & Sign ▸ Add signature. A phone photo of a
signature on paper is framed automatically (all words; table, sheet edge
and dust ignored; a sideways signature turned upright). The frame can be
moved, resized or redrawn, the photo turned in quarter turns, and an Ink
sensitivity slider picks up faint ink. A morphological paper estimate
(van Herk/Gil-Werman closing) removes shadows and uneven light; the
trimmed ink in its own colour on transparency is saved and reused like an
image signature.

- engine: new signature_scan module (SignaturePhoto, PhotoRect, Turn,
  ScanError), bounded decoding, no new dependencies.
- Image tab: accepts full-resolution photos (up to 40 MiB, 50 MP) and
  scales them down to the saved-signature limits instead of refusing them.
- Photos and images can be dropped on the Image and Scan tabs.
- fill_sign_add: `scan` and `sensitivity` with `path`.
- Translations for all catalogs, parity entry, roadmap log.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:16:34 -04:00
lollinoo 7caaa35a81 Fix Word invoice layout and pagination (#773) (#1126)
* M10: keep invoice overlays out of Word body flow (#773)

Export inferred tables without visible borders. Keep text and images
positioned on pages with artwork, rotated or large text, and retain
each source page's size, crop rotation and units.

Cover invoice conversion through the automation tools, and handle
unavailable Windows symlink setup in the confinement test.

Co-authored-by: Codex <noreply@openai.com>

* M10: add @lollinoo's chosen contributor credit (#773)

The contributor requested their public GitHub profile name while preparing the #773 PR.

Co-authored-by: Codex <noreply@openai.com>

* Word export: float a page only when text overlaps an image or watermark

positioned() turned every page with any image, or with text twice the body
size, into absolutely positioned VML text boxes, so a page with a logo or a
big title lost its flowing paragraphs, headings and tables. Now a page floats
only when it has rotated text, or when an image or a watermark-sized text
block actually overlaps other text (bounded pairwise test). Other pages flow
with images inline. Adds a regression test for a page whose image and title
sit clear of the text, and updates the docs and parity notes.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:10:32 -04:00
krinry 4701a00831 Edit text: support Devanagari system font fallback and Indic pre-base matra reordering (#1033)
- UI font fallback: add Devanagari probe (U+0915) and candidate font paths (Noto Sans Devanagari, Lohit, Nirmala, Mangal, Devanagari MT, Kohinoor) in system_fonts.rs, and register SYSTEM_DEVANAGARI_FALLBACK across all family stacks in theme.rs so Hindi text renders clearly in the inline editor and chrome.
- Text editing & extraction: reorder visual-order Indic pre-base vowel signs (e.g. Devanagari 'ि' U+093F) to standard Unicode logical order upon extraction in text_lines and reading_lines, and provide a visual-order fallback during font reuse in replace_line and rewrite_block.
- Add unit tests for Devanagari font fallback coverage in crates/ui-egui/tests/fonts.rs and Indic visual/logical reordering round-trip in crates/edit/src/tests.rs.
- Credit krinry with sync_github_name in contributors/people.toml.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:03:40 -04:00
Márk Bartalos a786c8fb02 Polish chrome hover, press, and selection (#457)
* Ease hover and press feedback on chrome controls.

* Polish chrome alignment, selection, and borders.

* Drop the unused mut on page-thumbnail responses.

The context menu borrows the response immutably, so the hand-cursor binding does not need mut.

* Format the icon button wash

* Test the shared icon button for pointer parity only

Its painting now differs from the legacy path on purpose (eased wash, selected
outline, sinking glyph), so the frozen-shapes comparison no longer applies.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:01:36 -04:00
komo1iddin 6d843a8e8b Preserve source fonts in text editing and resize clipped artwork (#1116)
* M0: remove upstream trademark artwork from contributor fork

Use the existing document icon and a plain project label. Remove trademark
asset entries and regenerate attribution as required for modified forks.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: group compatible PDF words and hide source text during editing

Join compatible words across text objects and subset-font spaces, recognize
component colours, and reuse subset fonts with numeric space positioning.
Render a read-only background with the selected glyphs removed while the
inline editor is open. Keep one frame and source line breaks, and reset
the layout width when switching paragraphs. Expose the same preview through page_render.

Add synthetic extraction, save/reopen, preview and UI regressions. Record
the remaining mixed-style paragraph and installed-font limitations.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: preserve rich PDF regions and preview text edits in place

Group mixed source runs into complete visual regions, retain run styles and source encodings, and use validated embedded source faces in the inline editor. Preserve source baselines and show a single live frame as text grows or shrinks, backed by a read-only draft geometry tool.

Validation: strict workspace Clippy, workspace tests, all 30 WASM crates, asset and parity gates, licensed synthetic edit/save/reopen and desktop checks, and an opt-in four-page private-document regression.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: keep cropped artwork from blocking text editing

Track page/path clipping separately from full image placement, use visible selection bounds, and prioritize text hit testing over overlapping artwork. Preserve fixed clips during image transformations and expose visible bounds through the headless tools.

Validation: strict workspace Clippy, workspace tests, 30 WASM crates, asset/parity gates, synthetic engine/tool/UI and native desktop checks, both private-document UI regressions, and all-page local edit/save/reopen/undo on a four-page cropped-artwork PDF.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: resize cropped images with their clipping paths

Keep the visible frame and image pixels under the same transform. Preserve the
opposite corner and respond to diagonal, horizontal-only and vertical-only
corner drags without jumping from an off-centre grip. Refuse shared clipping
scopes that would move unrelated content.

Add synthetic engine/tool/UI regressions and opt-in private-file coverage
for 80 corner gestures and 10 grow/shrink save/reopen/undo render checks.

Co-authored-by: OpenAI Codex <codex@openai.com>

* M7: complete source font subsets and preserve clipped editing

Add explicit document fonts, CFF embedding, range formatting and bounded contextual shaping; isolate shared image clips and verify private Chinese edits on all pages.

Co-authored-by: OpenAI Codex <codex@openai.com>

* Revert "M0: remove upstream trademark artwork from contributor fork"

This reverts commit 3aa539b5b69e97419bbab83e6cca3d38835b8de8.

* Test: give the text layer up to 4 s before copying (it flaked on a loaded machine)

---------

Co-authored-by: Kamoliddin Fatkhullaev <komoliddinfatxullayev@MacBook-Pro-Komoliddin.local>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:27:55 -04:00
skylar taylor-barrick f3571c591f Merge pull request #1015 from storytold/codex/craft-print
M10.5: Adopt craft-print spooler with Windows printing fixes
2026-10-11 18:12:40 -06:00
Earl Co 4a1e205f81 Accessibility: export before-and-after review record (#1204)
* Add accessibility before-and-after review record tool

* Parity: leave out the review record (the parity list tracks Acrobat features; this is a PdfCraft extension)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:37:00 -04:00
Earl Co b65b7adb01 Accessibility: edit structure tag properties through tools (#1210)
* Accessibility: edit structure tag properties through tools

* Change tag properties: listed in EDIT_LABELS and translated in every catalog; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:36:00 -04:00
skylar taylor-barrick 78f7ee0454 M10.5: Keep the adoption current with main progress and viewer fixes 2026-10-12 01:28:15 +02:00
Earl Co fe2eadb211 Accessibility: identify structure objects in checker findings (#1207)
* Accessibility: identify structure objects in checker findings

* Include object generation in accessibility findings
2026-10-11 19:24:30 -04:00
Earl Co 0824f44daf docs: sync Earl Co contributor name from GitHub (#1199)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:24:13 -04:00
cyfung1031 eb32e3adf7 M2: keep doubled short text runs from interleaving (#1198)
* M2: split short whole-run redraw resets before placement

* M2: reject non-finite redraw prefix positions

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:21:24 -04:00
skylar taylor-barrick 00184a1006 M10.5: Preserve main duplex-driver warnings through craft-print 2026-10-12 01:20:52 +02:00
gyurix f5452cf126 Linux: prefer server DPI over panel physical DPI in vendored winit (#1150)
* fix(x11): prefer server DPI over panel physical DPI in winit

Without Xft DPI, use the X server screen dimensions before the output physical density. Xorg can run a dense panel at 96 DPI; EDID sizes alone make only winit apps appear zoomed.

Supersedes #908: preserves Xft.dpi priority, configured HiDPI screens, and WINIT_X11_SCALE_FACTOR override (including randr). Falls back to RandR output density when server dimensions are missing.

Adds default_dpi_factor() with 4 unit tests plus vendored-copy guard test in apps/pdfcraft/tests/winit_x11_dpi.rs.

* vendor/README: record the winit X11 DPI patch as (11)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:12:32 -04:00
Diego Ospina 492207ab2c i18n(es): add 84 missing rows, consistent tú register (#1134)
* i18n(es): add 84 missing rows, consistent tú register

Spanish lacked 84 labels that every other full catalog has (About window,
field alignment, redaction marks, certificate details, address warnings…),
so they showed in English. Five strings used formal usted; the field mask
hint was garbled; per-page rotate now matches the other rotate commands.

AI-assisted, native review welcome.

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

* Spanish: one row each for Run form script and Mark differences

---------

Co-authored-by: Diego Ospina <diegozpy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:09:32 -04:00
Earl Co f94c4a6ffd Show annotation actions beside selected text (#1133)
* M5: show actions for selected page text

* Selection actions: literal labels (so catalogs can cover them), no unwrap or unreachable

* Catalogs: Add comment in every interface language; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:04:58 -04:00
Earl Co 377f8da36b M14: choose Tools panel side in Preferences (#1130)
* M14: choose Tools panel side in Preferences

* Catalogs: Tools panel side in every interface language; refresh hashes

* OCR dialog: the shared outlined checkbox (raw ui.checkbox is linted since #1173)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:54:06 -04:00
Raj Patel 634f3d1144 Issue #1028: OCR can read pictures on pages that already have text (#1184)
* Issue #1028: OCR can read pictures on pages that already have text

Recognize Text skipped every page with text, so a picture of text on a
page that also has real text never became searchable. The engine and
the ocr_recognize tool already had skip_text_pages, but the desktop
dialog never offered it, and turning it off read the page's own text
again and added it a second time, so search found every word twice.

- Engine: always collect the page's own glyph boxes; with
  skip_text_pages false, a recognised word at least half covered by
  them (an earlier OCR layer included) is left out. OcrPage counts
  those words as already_on_page. The default still skips pages with
  text.
- Dialog: "Also recognize pages that already have text", kept with the
  other choices and used by Recognize text in multiple files too.
- Tools: ocr_recognize reports already_on_page; ocr_recognize_files
  accepts skip_text_pages.
- The two new labels in the 15 catalogs that translate the OCR dialog.
- Synthetic tests (a picture of a sentence plus real text) for the
  engine, the tool and the dialog; the engine test fails with the
  filter turned off. Unit tests for the coverage check, including
  NaN, infinite, inverted and overlapping boxes.
- parity, docs/roadmap.md M10 row and the ROADMAP.md progress log.

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

* Issue #1028: Czech labels for the OCR text-pages option

The Czech catalog was completed (#1066) while #1028 was in review, and
czech_covers_ui_literals requires every tl! literal: the two new
Recognize Text labels were missing, so the test failed after the merge.

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

* Contributors: credit rp6186030 as Raj Patel in the About window

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rp6186030 <25706807+rp6186030@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:45:36 -04:00
Dimitar fb97ce958d M10: Windows two-sided binds on the chosen edge; note drivers that ignore Two-sided (#1154)
The print script mapped "Flip on long edge" to System.Drawing's Duplex.Horizontal and
"short edge" to Vertical. Those are DEVMODE's DMDUP_HORIZONTAL (short-edge binding) and
DMDUP_VERTICAL (long-edge binding), so every Windows two-sided job bound on the other edge:
a PCL driver received @PJL SET BINDING = TOP for long edge. They are swapped back.

Win32_Printer's Capabilities say whether a driver prints two-sided (3, Duplex Printing). The
printer list already comes from Win32_Printer, so it now carries that as Printer::two_sided
(None on CUPS), the printers tool reports it, and the Print dialog notes when the selected
printer's driver doesn't take two-sided settings and points to Properties…. Such drivers keep
their own setting even with Two-sided Off. No vendor-specific names are used.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:41:02 -04:00
ymote 287fce8f9c M2: bound the total size of page-label prefixes (#1146)
Both page-label readers decode each range's prefix into a string of its
own: render's inspection for its range table, Organize for the ranges it
returns. Ranges may share one prefix object, so a small file could ask
for a copy per range: within the 200,000-entry tree limit, about 200 MiB
of copies of one 1,024-byte prefix. Decoded prefixes now take at most
4 MiB in all (MAX_PREFIX_TOTAL_BYTES, beside MAX_LABEL_TOTAL_BYTES). Past
that the tree is refused before any label is made, as the other tree
limits refuse it.

Tests: 5,000 ranges sharing a 1,000-byte prefix are refused in both
readers (relabelling such a tree changes nothing), and exactly 4 MiB of
prefixes is still read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:38:48 -04:00
jcl-bit f61a1e5465 M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682) (#1180)
* M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682)

The Windows MSI adds a classic shell verb under
HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit that
runs "pdfcraft.exe" "%1" with the app's icon. It appears whichever app is
the default for .pdf and never changes that default. On Windows 11 it is
under "Show more options": a first-level entry needs an IExplorerCommand
handler and package identity, which an MSI doesn't have.

MultiSelectModel is Document: several selected PDFs (up to 15) start one
launch each, and the existing single-instance hand-off opens them as tabs
of one window.

The verb has its own component, conditioned on a new secure public
property INSTALLPDFCONTEXTMENU (default 1, a welcome-dialog checkbox,
INSTALLPDFCONTEXTMENU=0 for unattended installs), following
INSTALLDESKTOPSHORTCUT. Its menu value is the key path, so uninstall
removes every value. The portable zip still registers nothing.

test-msi.ps1 checks the compiled Registry, Component, Property and
CheckBox rows, and that the .pdf default is never written. Docs: README,
docs/releasing.md, parity notes, ROADMAP log.

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

* Leave ROADMAP.md to main (its log moved on); the change is described in the PR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:35:02 -04:00
Todd Pederson 0ef75d294c UI: checkboxes and radio buttons are outlined, and checkboxes are square (#1173)
* UI: checkboxes draw a rounded-square box, not a radio-button circle

The theme's 6 px widget radius made egui's 14 px checkbox box a circle,
so View > Page display's Continuous scrolling and Show cover page looked
like two more layout radio buttons. On the light theme an unticked box
had no outline and the panel's fill, so it couldn't be seen.

Every checkbox (81 call sites) now goes through widgets::checkbox /
checkbox_without_text, which draws the box with a 3 px radius and a 1 px
border outline, keeping an outline the caller already set (dialogs).
clippy.toml disallows egui::Ui::checkbox and egui::Checkbox::{new,
without_text} so new checkboxes use the helper too.

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

* UI: radio buttons are outlined and filled, so they show on the light theme

On the light theme the field colour is the panels' white and widgets
have no outline, so an unselected radio button (and an unticked
checkbox) was invisible outside dialogs: View > Page display showed only
the selected dot.

Every radio button (79 call sites) now goes through widgets::radio /
radio_value, sharing the checkbox helper's styling: a 1 px border
outline, text_muted on hover, and on the light theme the plain fill
(hover) as dialogs already had, keeping what the caller set. The dark
theme keeps its fills. clippy.toml also disallows egui::Ui::radio,
egui::Ui::radio_value and egui::RadioButton::new.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:34:16 -04:00
Krzysztof Cieślik 38df401687 M4: replace selected pages from organize toolbar and context menus (#786) (#1149)
Add 'Replace pages…' action to the Organize Pages toolbar, organize grid thumbnail context menu, and Pages panel context menu to replace selected pages.

- Add ViewAction::ReplacePages triggering replace_pages_dialog()
- Expose 'Replace pages…' button with 'replace' icon on the organize toolbar
- Expose 'Replace pages…' on the organize grid and Pages panel thumbnail context menus
- Update parity evidence for organize.replace-pages
- Add integration tests for toolbar and context menu replace actions in crates/ui-egui/tests/editing.rs

Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
2026-10-11 18:32:29 -04:00
Earl Co 036e00f2a1 Test PDF viewer trackpad pinch zoom (#1148) 2026-10-11 18:32:16 -04:00
Mex 9622f29d04 M5: preserve comment identities during partial XFDF and FDF imports (#1144)
Reuse a replaced named comment's object reference so omitted replies and
review statuses remain attached. Keep popup replacement behavior and let
the final duplicate record control its deferred reply relationship.
Resolve indirect names in existing PDF comments so updates match their
original objects instead of creating duplicate parents.

Cover existing-PDF updates, incremental and full saves, subsequent FDF
exchange, page-scoped names, unnamed additions, duplicate records and
indirect names with surviving reply and review-status references.

Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-11 18:32:03 -04:00
MarekSpodBiedry 75b113ac5b Raise workspace rust-version to 1.95 to match the UI crate (#1169) (#1188) 2026-10-11 18:30:25 -04:00
cyfung1031 3fa7a3a3e8 M14: keep structural page order through inspection (#1182)
* M14: keep structural page IDs in traversal order

* M14: reuse structural page order during inspection
2026-10-11 18:30:13 -04:00
Joel Tony 931dfa0d7d Issue #1185: Protect refuses passwords that encryption would make empty or equal (#1186)
Encryption derives its keys from a prepared password, not the typed one:
AES-256 (R6) applies SASLprep, falls back to the password as given if
SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128
(R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the
padding starts with "(". Protection::validate checked only the typed
strings, so a permissions password of U+00AD wrote a file that opens with
owner rights and no password, and an open password "A" with a permissions
password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a
file whose open password lifts the restrictions.

pdfcraft-crypt gains effective_password(algorithm, password), the bytes the
keys are derived from (it and create share the private prepared_password
helper), re-exported from pdfcraft-cos. validate refuses a password whose
effective bytes are those of "", and an open and a permissions password
whose effective bytes are equal. Opening files is unchanged.

Fixes #1185.
2026-10-11 18:30:00 -04:00
Ufuk Efe 826941dcfc docs(contributors): add Ufuk Efe to people.toml (#1177) 2026-10-11 18:29:48 -04:00
ysrdevs 82b709d516 XFA: import and export data files, and pictures for image fields (#1175)
Export Data on an XFA form writes its data as Acrobat does: XML (the
data alone, rooted at the form's root data node, its namespaces
declared) or an XDP holding the whole datasets and a reference to the
PDF (.xdp, a new format). Import Data takes an XDP, a datasets or data element, or a
data document whose root is the form's root data node, and makes it
the form's whole data, keeping data descriptions; a laid-out form is
laid out again from it (rows added and removed) and its calculations
run; XFDF, FDF and tables still go the AcroForm way. Undoable.

Image fields are now buttons marked /PCXfaImage (no PDF action): the
forms layer treats one as an image field, so clicking it (or
form_set_image) picks a picture, which is kept in the data as base64
with its xfa:contentType, as Designer saves it, and the laid-out form
shows it from there.

xfa: replace_data, write_data_image (hostile content types cleaned,
32 MB cap), export_data, import_data (64 MB cap, the parser's node
limit, self-closing data elements). engine: export routing by
document kind, import routing by the file's root element, re-layout
and calculation after the data changes outside the fields, the
picture's content type from its bytes. UI: XML and XDP in the export
dialog of an XFA form, .xdp in the import filter. Tool texts, README
and parity (form.xfa-data) updated.

Hardened after an adversarial review pass: the file's data is held as
a tree and written with the packet's own namespace prefix (never its
markup), every packet rewrite is parsed back before it is written,
files capped at 16 MB and 64 levels (a tag scan before the recursive
parser, also guarding the datasets parser), pictures at 8 MB and PNG/
JPEG/GIF only, the file's shape and root name checked, UTF-16 and
ISO-8859-1 decoded, pictures into data groups refused, cleaned names
for pictures, static forms empty the fields the file doesn't hold,
more rows than the layout cap reported, one datasets stream per edit,
and only image fields take pictures into the data.
2026-10-11 18:29:35 -04:00
Raj Patel 29dca46089 Bound text search result allocation (#1164)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:27:48 -04:00
FredApps 6091deb829 M7: preserve overlapping text when dragging paragraphs (#594) (#1052)
* M7 #594: preserve ordinary paragraph drags (source-only GGEO candidate) [skip ci]

Route body drags through source-preserving MoveObjects, bind gestures to
the document generation, and keep added-content input ownership with
the Edit panel open or closed. Add nine real-shell source regressions.

Source-only transfer to the owner's fork, explicitly authorized before
validation. Independently source-reviewed; formatting, builds, tests,
rendering and all gates remain pending on ggeo-ai02. No upstream PR.

AI-assisted with OpenAI.

* M7 #594: record validated ordinary paragraph drags

Format the source-reviewed candidate and record successful GGEO validation:
focused UI/engine tests, exact-base source loss, native drag/history/save/reopen,
independent Poppler checks, and all eight repository CI gates.

Credit: @FredApps

* M7: use generic validation wording

* Tests: pin the clock where two saves or combines are compared byte for byte

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

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:25:25 -04:00
skylar taylor-barrick c5f1cb2d3f M10.5: Reconcile hosted CI and keep the Arabic golden in both font builds 2026-10-12 00:23:30 +02:00
Tanapat Eiam-arj 8ee3673bd4 M7: support shaped Thai text in Add Text and UI fallback fonts (#767)
* M7: support shaped Thai text in Add Text and UI fallback fonts

Share the bounded shaping and Type 3 writer with Thai, preserving vowel and tone clusters and original Unicode through save/reopen. Keep the Thai face optional and update UI notices and their translations.

Constraint: Thai font assets remain in the companion craft-fonts repository.

Confidence: high

Scope-risk: moderate

Co-authored-by: Codex <noreply@openai.com>

* M7: show Thai Add Text before and after the fix

Document the rejected input on the base commit and the saved Thai text on the fixed build using genuine UI captures.

Constraint: Synthetic content and licensed fonts only; system font fallback disabled.

Co-authored-by: Codex <noreply@openai.com>

* M7: demonstrate Thai stress cases in the comparison screenshot

Show nine lines of stacked marks, both Sara Am encodings, Thai digits and mixed Latin text. Verify all 198 Unicode code points survive saving and extraction with both PdfCraft and Poppler.

Co-authored-by: Codex <noreply@openai.com>

* CI and releases: craft-fonts pin to 3d9ab3a (adds Noto Sans Thai)

ci.yml, freebsd.yml and the craft-fonts-ref release.yml passes to craft-common-actions move
from 8dcdacd to storytold/craft-fonts 3d9ab3a, the revision with Noto Sans Thai (#18), so
Thai Add Text is built in and tested.

* ATTRIBUTION: drop the screenshot entry again (the image isn't committed)

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:07:36 -04:00
Zeyu Li b91d38c073 M14: text the fonts can't draw is kept and reported, never written as ? silently (#1112)
* M14: text the fonts can't draw is kept and reported, never written as ? silently

Form values and captions, text boxes, callouts, Fill & Sign text, measurement labels and stamp by-lines keep text the standard fonts can't draw, draw ? for it, mark the annotation (PCUndrawn) and report the edit (interface notice, tools' warnings); forms set /NeedAppearances (not in PDF/A). Flattening, redaction overlay text, typed and visible signatures and create-from-text refuse instead. Reports escape such characters as [U+XXXX].

* Catalogs: the two new can't-draw messages in Czech, Slovene, Kazakh, Turkish and Greek; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:53:55 -04:00
Maksim Popov d415749556 Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues (#1117)
* Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues

A large CAD sheet (page 24 of the benchmark file: ~5050 pt wide, 2104
images, 9 MB of content) cost a full page interpret per tile: 4.3 s for a
1024 px tile at 1000%, 10.5 s for the whole page, and a storm of renders on
every pan frame. Tiles now cost 0.6 s, the sheet is covered by a handful of
large tiles, and gestures no longer queue work faster than workers finish it.

- Vendored hayro patch (10): skip images whose stream-size box misses the
  canvas before decoding them, so a tile no longer decodes, resamples and
  rasterizes every image on its page. Pixel-identical; the skip regression
  test now covers images too.
- Vendored hayro-interpret patch (23): decoded image pixels shared across
  renders through the interpreter cache, keyed by stream and decode target
  size within a 64 MiB budget.
- Preview renders (hayro patch 11, RenderRequest::preview through to the
  device): backdrops, thumbnails, grid cells and signature-drag backgrounds
  skip images under 16 device px. Tiles, whole pages, print and export always
  draw everything. Previews may differ by those pixels; finals don't.
- Viewer: zoom/pan settle holds apply only to pages whose raster proved slow
  (slow_pages, 500 ms), so fast pages render with no added latency; tile sides
  grow with zoom and page size (giant sheets need ~8 tiles, not ~28), queued
  center-first and keyed by exact coordinates; Edit-text outlines capped.
- vendor/README.md records the new patches with their tests.

Co-Authored-By: Muse Spark <noreply@meta.com>

* Decoded-image cache: key every decode input, survive a poisoned lock

The key was the dictionary's bytes and the target size, so two images with byte-identical
dictionaries (uncompressed, one size) shared pixels, and an image drawn under a transfer
function reused pixels decoded without it. DecodedImageKey now holds the dictionary's place
in the document (its data follows it), its bytes and object, mask/stencil/raster output,
interpolation, the target size and preview or final quality; images under a transfer
function and inline images (named colour spaces, dictionaries in content streams) are not
cached, and an image over the budget is never kept. A poisoned lock decodes uncached
instead of panicking. Soft masks: the per-renderer cache can't carry a preview mask into a
final render (documented). Regression tests for identical dictionaries, preview then final
through one cache, and transfer functions.

---------

Co-authored-by: Muse Spark <noreply@meta.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:42:48 -04:00
tzagim e609722b36 M7: Hebrew in Edit text (reading order, writing order, Hebrew glyphs) (#521)
* Hebrew fix

* Tests: the faux-redaction audit gets its own work folder (it shared "audit" with the space audit, so the two raced)

* Arabic golden test: compare bytes only in a build without an Arabic face (CI's Linux job builds with craft-fonts)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:41:06 -04:00
skylar taylor-barrick 981a2e57c8 M10.5: Integrate main and adopt the validated craft-print spooler 2026-10-11 22:29:54 +02:00
skylar taylor-barrick 95434e9115 M10.5: Preserve composed sheets with the shared scaling and duplex API 2026-10-11 22:05:01 +02:00
skylar taylor-barrick 6614b370b1 M10.5: Preserve current main printing workers during spooler extraction 2026-10-11 07:36:51 +02:00
skylar taylor-barrick c20f3aff60 M10.5: Adopt shared craft-print system spooler 2026-10-11 07:29:54 +02:00
302 changed files with 35600 additions and 3218 deletions
+76
View File
@@ -0,0 +1,76 @@
<!--
Thanks for contributing to PdfCraft.
Base this on `main` — it is the only integration branch. `main` moves fast (it took
396 commits in the three days to 2026-10-12), so a PR that sits for a day usually needs
a rebase. Rebasing the same day you are asked keeps the work from dying of conflicts.
Delete any section below that does not apply.
-->
## What this changes
<!-- What behaviour is different afterwards, and why it is worth doing. If it fixes an
issue, say `Fixes #123`. -->
## How you verified it
<!-- The commands you ran and what they said. Per-crate is fine and preferred:
`cargo test -p <crate>`. CI runs the full workspace on every PR, so you do not
need to locally. -->
- [ ] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed
- [ ] tests pass on the crates I changed
- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset)
**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a
crate integration test or an end-to-end one. For a bug fix, **please check that the test
fails before your fix** by reverting the fix and watching it go red. A test nobody has seen
fail is not evidence that it pins anything.
## Never-crash
Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4).
- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is
provably infallible with a comment saying why
- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char
boundaries); checked or saturating arithmetic; allocations sized by input are capped;
recursion is bounded
- [ ] No `unsafe`
- [ ] Any crash this fixes has a small synthetic regression test
## Provenance
This is the part that cannot be undone after a merge, and the part a new contributor is
least likely to know about. Please read `AGENTS.md` §1 before adding any asset.
- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated
- [ ] No font files committed here (they live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts))
- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be
run as external oracle processes
- [ ] Behaviour comes from the specification or black-box observation, **not** from reading
another implementation's source
- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes
## Security
- [ ] `--root` confinement still holds on every path, including ones reached by nested
tool dispatch
- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated
- [ ] No new outbound network access (that decision is the owner's)
## Parity and reach
- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still*
missing — `shipped` means "exists and tested", not "as good as Acrobat"
- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3),
so agents can drive it and not just the UI
- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README)
## Anything a reviewer should look at first
<!-- The judgement call you are least sure about, a deliberate restraint you want pinned,
or a behaviour change existing users will notice. Say so here rather than leaving it
to be found. -->
+11 -4
View File
@@ -20,6 +20,9 @@ jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
strategy:
fail-fast: false
matrix:
@@ -36,9 +39,15 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Configure craft-fonts (Linux)
if: runner.os == 'Linux'
run: |
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
run: |
@@ -72,12 +81,10 @@ jobs:
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace
- name: Layering
if: runner.os == 'Linux'
run: cargo xtask layers
- name: Assets (licences and attribution)
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+7 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: pdfcraft
app-title: PdfCraft
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+1 -1
View File
@@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14.rs` (approved by the owner, 2026-10-08), which is what generated appearances measure with and what survives hayro being retired.
Adding anything to this list needs the user's explicit approval.
+3 -2
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (269)
## In this repository (270)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -27,6 +27,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -221,7 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
+39 -27
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "23a9402d3ebbcff69aed133706e6e428a804ae06a7dba4923892cca5c98e92d1"
sha256 = "e04ab5246125efae2bad439e62c150fc10649c4b6796b30565615f5bd7509ec6"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1863437c886142b2bccce3f44a538ae80d0ceea81074b84dfdfe8d9a4a4227ab"
sha256 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6c8604d5cd71412c6a673b75001c7ef629727839eca1449147c7d6ec4bf0be99"
sha256 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5ae2e315a9b99b4c0dfaf7be019c4d430ace17b4fbbfea5a2779b3e972846a21"
sha256 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8b9e22fa25339e4aedb0542b8bf6f68dfd97bc3634105199e1951475e30eed53"
sha256 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6d81592677f2b4eb3b97ba5810de9752da969545d91897e27033f37310696ffc"
sha256 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "fb9c91a98c6975bc24249e8ae750fd46b9245ac5d2d7381f1e21319b9adbcaac"
sha256 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f36aefae1a1ba6d576a4647fbfdd65b9b5086bfb266ea260e513244adc7af05a"
sha256 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "656940b456a6c2401f8bd3107a71cedcae5e52cd446e86e806285b6322d8ed8e"
sha256 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "14884c08a550600d310a794ceb0b2bb864987a074178ee49b657578b66625bef"
sha256 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "be960bdf9b6eeac2d95db91325076ee79c9b2efdcc4e459c7400e7ddb05fab3a"
sha256 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bada9b6f0b3775e4dab1213d9759070f7551d8a6142de10159268c88a04d3f0c"
sha256 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "efb75d8575df9e1dd4610602f20545b3a14a3a0814060efc27eb7cab591ccccb"
sha256 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ca305b188abc8d9c261901dde10f7cd8518520983fdf88d2358374b352ad3d5a"
sha256 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "77afe7d7fa215c99e8e627a758178d79c51e08e33564e00b97b8840aeb91a9dd"
sha256 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f004b06f4e7eb8f4a99e22e1abfd97c95795b2215080e4008574522e54eda74c"
sha256 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cd9ecaf661533cbd3b055cfd3994602f6739c83061d1e225ba43ee6f229068cf"
sha256 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a8a66390b2d80b4708c4f854318b923eef7f0f5932439fb5ef25f2651980f24e"
sha256 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5688538845a80c154feabd096badbf097485dea8a7402fede4ef7d71466e9d6a"
sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -2354,15 +2365,16 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)"
sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0"
[[asset]]
path = "vendor/hayro-interpret/assets/LAB.icc"
title = "Lab abstract ICC profile"
author = "The Hayro Authors (generated with LittleCMS 2)"
source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)"
path = "crates/fonts/src/std14.rs"
title = "Standard-14 font advance widths and encodings (as Rust tables)"
author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors"
source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs."
licence = "MIT OR Apache-2.0"
licence_file = "vendor/hayro-interpret/LICENSE-MIT"
licence_file = "LICENSE-MIT"
kind = "data"
usage = "Lab colour space conversion (vendored hayro-interpret)"
sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59"
adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "4ce47db7e65467d4ea1d1642ff76dfcaeb9669c1811a93819d7e00e63fa9fcf8"
[[bundled]]
crate = "epaint_default_fonts"
@@ -3339,4 +3351,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8590f5acce1da70d6a3ce58de073bf4f86815651ff74a59625980c6c52e051b7"
sha256 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086"
+9 -2
View File
@@ -31,8 +31,15 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0).
- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment.
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
## Running and looking at the app
- `cargo run -p pdfcraft -- <file.pdf>` opens the desktop app.
Generated
+57 -39
View File
@@ -1090,6 +1090,14 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1741,7 +1749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2364,6 +2372,7 @@ dependencies = [
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -3552,7 +3561,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
@@ -3566,13 +3575,14 @@ dependencies = [
"ureq",
"wayland-client",
"wayland-protocols",
"windows",
"winresource",
"winsafe",
]
[[package]]
name = "pdfcraft-a11y"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3583,7 +3593,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3594,7 +3604,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3605,12 +3615,14 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"flate2",
"image",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
@@ -3618,11 +3630,12 @@ dependencies = [
"pdfcraft-xfa",
"png 0.18.1",
"serde_json",
"sha2",
]
[[package]]
name = "pdfcraft-cli"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -3637,18 +3650,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.6.0"
version = "0.7.1"
[[package]]
name = "pdfcraft-content"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -3660,7 +3673,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -3675,7 +3688,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"aes",
"md-5",
@@ -3687,9 +3700,10 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
@@ -3701,7 +3715,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"chrono",
"icu_properties",
@@ -3738,14 +3752,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"flate2",
"proptest",
@@ -3755,16 +3769,17 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
"skrifa 0.44.0",
"unicode-bidi",
]
[[package]]
name = "pdfcraft-forms"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -3776,18 +3791,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.6.0"
version = "0.7.1"
[[package]]
name = "pdfcraft-js"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -3799,14 +3814,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -3816,7 +3831,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -3829,7 +3844,7 @@ dependencies = [
[[package]]
name = "pdfcraft-organize"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -3839,7 +3854,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -3848,7 +3863,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-cos",
"pdfcraft-model",
@@ -3856,8 +3871,9 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"craft-print",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
@@ -3866,7 +3882,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -3880,7 +3896,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"bytemuck",
"flate2",
@@ -3892,13 +3908,14 @@ dependencies = [
"lopdf",
"pdfcraft-cos",
"pdfcraft-fonts",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-sign"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -3932,7 +3949,7 @@ dependencies = [
[[package]]
name = "pdfcraft-ui-egui"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -3968,11 +3985,12 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winsafe",
]
[[package]]
name = "pdfcraft-web"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"eframe",
"egui",
@@ -3984,7 +4002,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -3998,7 +4016,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -4898,7 +4916,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5524,7 +5542,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5792,7 +5810,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6484,7 +6502,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
+35 -33
View File
@@ -5,47 +5,49 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.6.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.6.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.6.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.6.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.6.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.6.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.6.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.6.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.6.0" }
pdfcraft-model = { path = "crates/model", version = "0.6.0" }
pdfcraft-content = { path = "crates/content", version = "0.6.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.6.0" }
pdfcraft-print = { path = "crates/print", version = "0.6.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.6.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.6.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.6.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.6.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.6.0" }
pdfcraft-js = { path = "crates/js", version = "0.6.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.6.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.6.0" }
pdfcraft-export = { path = "crates/export", version = "0.6.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.6.0" }
pdfcraft-create = { path = "crates/create", version = "0.6.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.6.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.6.0" }
pdfcraft-render = { path = "crates/render", version = "0.6.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.6.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.6.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.6.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.6.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.7.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.7.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.7.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.7.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.7.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.7.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" }
pdfcraft-model = { path = "crates/model", version = "0.7.0" }
pdfcraft-content = { path = "crates/content", version = "0.7.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.7.0" }
pdfcraft-print = { path = "crates/print", version = "0.7.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.7.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" }
pdfcraft-js = { path = "crates/js", version = "0.7.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.7.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" }
pdfcraft-export = { path = "crates/export", version = "0.7.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.7.0" }
pdfcraft-create = { path = "crates/create", version = "0.7.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.7.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" }
pdfcraft-render = { path = "crates/render", version = "0.7.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.7.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.7.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.7.0", default-features = false }
# pdf
hayro = "0.7.1"
+6 -4
View File
@@ -20,20 +20,22 @@ list (author, source, license) is in ATTRIBUTION.md.
- Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt.
- Japanese fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
- Fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
(CRAFT_FONTS_DIR) that is not part of this repository. Builds made with it, which includes every
official release, embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho; the web build only BIZ UDPGothic Regular) under the SIL Open Font License 1.1. Those
packages carry each font's licence as OFL-<family>.txt.
packages carry each font's licence as OFL-<family>.txt. Builds made with the craft-fonts revision
pinned in .github/workflows/ also include Noto Sans Thai (in the web build too), Copyright 2022
The Noto Project Authors (https://github.com/notofonts/thai), under SIL OFL 1.1; its licence is
fonts/noto-sans-thai/OFL.txt in the craft-fonts build input.
- hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit
standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles
Adobe CMap resources under BSD-3-Clause.
- lopdf (bootstrap document inspection, via crates.io) — MIT.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and
LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
Required notices:
+13 -5
View File
@@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
<table>
@@ -369,6 +371,8 @@ PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000
## Get started
Building needs Rust 1.95 or later.
```sh
git clone https://github.com/storytold/pdfcraft
cd pdfcraft
@@ -417,10 +421,10 @@ PdfCraft is young and moving fast. The aim is a workbench where you can view, or
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, and full XFA forms.
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
@@ -446,14 +450,18 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt`
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
The MSI installs for all users and requires administrator privileges. For unattended deployment
without a desktop shortcut, run from an elevated terminal:
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"),
without changing which app opens PDFs by default. For unattended deployment without a desktop
shortcut, run from an elevated terminal:
```powershell
msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
```
Use the MSI for your architecture. Per-user installation overrides are not supported.
Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too,
since each install applies its own choices. Use the MSI for your architecture. Per-user
installation overrides are not supported.
### macOS
+69 -3
View File
@@ -2,8 +2,14 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
@@ -44,7 +50,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert
and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in
Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer,
no printing on the web and 245 open issues keep it far from beta.
and 245 open issues keep it far from beta.
### Readiness by audience
@@ -124,7 +130,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| Rank | Milestone | Estimate (h) |
|---|---|---|
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
| 1 | Backlog and platform: crash and wrong-result reports, web printing, Chinese UI face (#826) | 65–120 |
| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
@@ -135,17 +141,66 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged.
- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged.
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open.
- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged.
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged.
- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged.
- **2026-10-11 (M5, #1191):** Quick-bar colour changes now restyle the selected comment when it matches the active comment tool, while continuing to update that tool's default; added an accessible label to the swatch controls and a UI regression proving the saved style and undo step. Estimates unchanged.
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged.
- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged.
- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged.
- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged.
- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged.
- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open.
Newest first: the date, what moved, and the new overall estimate.
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged.
- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged.
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged.
- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged.
- **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged.
- **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open.
- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged.
- **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged.
- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged.
- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc<Object>` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged.
- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged.
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged.
- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged.
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
@@ -161,6 +216,8 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change.
- **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged.
- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged.
- **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %.
- **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged.
@@ -209,6 +266,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (community, M7, Hebrew in Edit text):** Editing existing Hebrew text showed it reversed (`בדיוק` read back letter-reversed) and wrote replacement boxes on save. Content streams draw right-to-left lines in visual order; Edit text now reads lines and paragraphs in typing order and writes them back in drawing order (`pdfcraft-edit::bidi`, UAX #9 through `unicode-bidi`, with numbers, Latin and mirrored brackets; where visual order allows several readings, the one that draws back exactly and pairs its brackets wins). Right-to-left paragraphs are right-aligned unless an alignment is chosen, and a single line keeps its right edge as it grows. Letters the page's font lacks are drawn with a craft-fonts `Hebr` face (Noto Sans Hebrew, storytold/craft-fonts PR) as Type 3 glyphs with ToUnicode, so the result is searchable; the interface uses that face too, and the web build keeps it. Without it, a clear error and no change. `edit.rtl-cjk-editing` moves to partial. Not yet: niqqud in replacement text (refused, not misdrawn), Arabic in Edit text (shaping; Add text has it since #403), typing right to left in the box (#437), lines drawn word by word from the right; the release craft-fonts pin must move to a revision with the Hebrew face. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
@@ -345,7 +403,13 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, no other animations), and Preferences has Animation effects (Use system setting, On, Off). Estimates unchanged. |
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
@@ -353,3 +417,5 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.
+17 -4
View File
@@ -151,6 +151,7 @@ fn version() -> Result<(), CliError> {
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
fn audit(args: &[String]) -> Result<(), CliError> {
validate_options("audit", args, AUDIT_OPTIONS)?;
let path = *positional(args).first().ok_or("audit: missing file")?;
let bytes = read(path)?;
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
@@ -213,6 +214,7 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
}
const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
const EXTRACT_OPTIONS: &[OptionSpec] = &[
@@ -340,7 +342,7 @@ fn text(args: &[String]) -> Result<(), CliError> {
};
let mut failed: Vec<usize> = Vec::new();
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
if let Some(e) = out.error {
let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
failed.push(p + 1);
@@ -426,6 +428,17 @@ fn edit(args: &[String]) -> Result<(), CliError> {
let mut session = Session::new();
let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
for e in edits {
// A destination past the end must fail like `page_move` / `page_insert_blank` do, not
// silently become an append (#1330). Checked per edit: earlier edits change the count.
let n = session.get(id).map_or(0, |d| d.info.pages.len());
let destination = match &e {
Edit::MovePages { to, .. } => Some(("--move target", *to)),
Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
_ => None,
};
if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
}
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
@@ -503,7 +516,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
read(path)?,
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
);
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
}
@@ -541,8 +554,8 @@ fn check_one(args: &[String]) -> Result<(), CliError> {
let mut r = PageRenderer::new(bytes, RenderConfig::default());
let mut failed = Vec::new();
for i in 0..info.pages.len().min(500) {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
failed.push(format!("p{} text: {e}", i + 1));
}
if let Some(e) = p.error {
+45
View File
@@ -179,3 +179,48 @@ mod one_based_arguments {
assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
}
}
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
let input = tmp("past-end", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
let output = tmp("past-end", "out.pdf");
for (option, value, diagnostic) in [
("--insert-blank", "3", "--insert-blank 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--insert-blank", "99", "--insert-blank 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:3", "--move target 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:99", "--move target 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
] {
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{option} {value}");
assert_eq!(String::from_utf8_lossy(&out.stderr), format!("pdfcraft-cli: {diagnostic}\n"), "{option} {value}");
assert!(!output.exists(), "{option} {value}: no output may be written");
}
}
/// One past the last page appends, and each destination is checked against the page count at that point.
#[test]
fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
let input = tmp("append", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
for (name, args) in [
("insert.pdf", vec!["--insert-blank", "2"]),
("move.pdf", vec!["--insert-blank", "1", "--move", "1:3"]),
("grown.pdf", vec!["--insert-blank", "2", "--insert-blank", "3"]),
] {
let output = tmp("append", name);
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
all.extend(args);
let out = Command::new(BIN).args(&all).output().unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
let sizes: Vec<_> = info.pages.iter().map(|p| (p.width, p.height)).collect();
let expected: &[(f32, f32)] = match name {
"insert.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
"move.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
_ => &[(200.0, 300.0), (612.0, 792.0), (612.0, 792.0)],
};
assert_eq!(sizes, expected, "{name}");
}
}
+12
View File
@@ -37,6 +37,18 @@ fn listed_tools(flags: &[&str]) -> Vec<String> {
reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn mcp_rejects_unknown_options_before_starting_the_server() {
for option in ["--totally-bogus-flag", "--rot", "--root=."] {
let out = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["mcp", option]).output().expect("run CLI");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "mcp {option} unexpectedly succeeded");
assert!(stderr.contains("unknown option") && stderr.contains(option), "mcp {option}: {stderr}");
assert!(!stderr.contains("MCP server on stdio"), "server started despite invalid option: {stderr}");
}
}
#[test]
fn compact_flag_shortens_the_tool_list() {
let full = listed_tools(&[]);
+2
View File
@@ -15,6 +15,8 @@ fn non_edit_commands_reject_unknown_options_before_io() {
vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
vec!["check-one", "missing.pdf", "--edti"],
vec!["check", "missing.pdf", "--timout", "2"],
vec!["audit", "missing.pdf", "--passwrod", "x"],
vec!["audit", "missing.pdf", "--nonsense", "1"],
vec!["run", "doc_open", "--roto", "/tmp"],
vec!["ui", "--contro", "control.json", "state"],
] {
+4
View File
@@ -42,6 +42,10 @@ pollster = "1.0"
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
windows = { version = "0.62", default-features = false, features = ["std", "UI_ViewManagement"] }
# Single instance (#282): later launches hand their files to the running app over a Unix domain
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
+23
View File
@@ -33,6 +33,8 @@ use pdfcraft_ui_egui::PdfCraftApp;
mod apple_events;
mod compositor_gpu;
mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
mod single_instance;
mod text_scale;
@@ -312,6 +314,12 @@ fn app_creator<'a>(
}
app.integrated_titlebar = integrated;
app.system_text_scale = text_scale::system();
// Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
app.motion.source = Some(motion::source());
}
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
@@ -672,6 +680,19 @@ mod tests {
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
}
/// Tests that open a real GPU device take this first, so only one of them is inside the
/// graphics libraries at a time. The test threads otherwise run them together, and on
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
/// the first device was still being set up when the second test began its own. The UI
/// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
/// test (declared before the instance, so it's released after the device is dropped).
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn gpu() -> std::sync::MutexGuard<'static, ()> {
// A test that panicked while holding it leaves nothing to clean up.
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn a_control_channel_that_cant_be_published_stops_the_app() {
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
@@ -878,6 +899,7 @@ mod tests {
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
// than the device allows.
use eframe::wgpu;
let _gpu = gpu();
let native = super::native_options(false, eframe::Renderer::Wgpu);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
panic!("default setup creates its own instance")
@@ -944,6 +966,7 @@ mod tests {
#[test]
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
use eframe::{egui_wgpu, wgpu};
let _gpu = gpu();
let mut native = super::native_options(false, eframe::Renderer::Wgpu);
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
+75
View File
@@ -0,0 +1,75 @@
//! The system's "no animations" setting, which Preferences ▸ Animation effects follows unless it is
//! set to On or Off (`pdfcraft_ui_egui::motion`).
//!
//! Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (in the Control Panel,
//! "Show animations in Windows"), read through WinRT's `UISettings.AnimationsEnabled`. Other
//! systems aren't asked yet: there the window animates as before.
use pdfcraft_ui_egui::motion::MotionSource;
/// Asks the system each time, and logs the answer when it changes.
pub fn source() -> MotionSource {
let last = std::cell::Cell::new(None);
Box::new(move || {
let reduced = reduced();
if reduced.is_some() && last.replace(reduced) != reduced {
log::info!("system animation effects: {}", if reduced == Some(true) { "off" } else { "on" });
}
reduced
})
}
/// Whether the system asks for no animations; `None` when it can't be asked.
pub fn reduced() -> Option<bool> {
match animations_enabled() {
Ok(enabled) => Some(!enabled),
Err(e) => {
log::debug!("system animation effects: {e}");
None
}
}
}
fn animations_enabled() -> windows::core::Result<bool> {
windows::UI::ViewManagement::UISettings::new()?.AnimationsEnabled()
}
#[cfg(test)]
mod tests {
/// The user's saved Animation effects choice, or `None` when `reg` can't read it. Windows
/// keeps its on/off system parameters as the bits of `UserPreferencesMask`: bit n is the
/// parameter `SPI_GET…` 0x1000 + 2n, and client-area animation is 0x1042, so bit 33.
fn saved_choice() -> Option<bool> {
let out = std::process::Command::new("reg").args(["query", r"HKCU\Control Panel\Desktop", "/v", "UserPreferencesMask"]).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let hex = text.split_whitespace().skip_while(|word| *word != "REG_BINARY").nth(1)?;
let byte = u8::from_str_radix(hex.get(8..10)?, 16).ok()?;
Some(byte & 0b10 != 0)
}
#[test]
fn the_answer_is_the_systems_animation_effects_setting() {
let answer = super::reduced();
// The first answer is logged and later ones only when they change; the answers don't.
let source = super::source();
assert_eq!((source(), source()), (answer, answer));
match super::animations_enabled() {
// With Animation effects turned off, the answer must be "reduced". Only this way
// round: the session's own value may be off for other reasons (a policy, a remote
// session) while the saved choice is on.
Ok(enabled) => {
assert_eq!(answer, Some(!enabled));
if saved_choice() == Some(false) {
assert!(!enabled, "Animation effects are off in Settings, but the answer was that animations are on");
}
}
Err(e) => {
eprintln!("UISettings isn't available here ({e}); not compared with the saved choice");
assert_eq!(answer, None);
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
#[test]
fn winit_carries_the_x11_server_dpi_fallback() {
// The behavior tests live beside the policy in winit (util::randr::tests).
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let randr = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/linux/x11/util/randr.rs")).unwrap();
assert!(randr.contains("let screen = self.default_root();"));
assert!(randr.contains("default_dpi_factor(\n self.get_xft_dpi(),"));
assert!(randr.contains("screen.width_in_millimeters.into()"));
assert!(randr.contains("calc_dpi_factor(screen_pixels, screen_mm)"));
assert!(randr.contains("server_dpi_wins_over_the_panels_physical_density"));
let desktop = std::fs::read_to_string(root.join("packaging/linux/ai.storyteller.pdfcraft.desktop")).unwrap();
assert!(!desktop.contains("WINIT_X11_SCALE_FACTOR"));
}
+3
View File
@@ -20,6 +20,9 @@ environment variable if you set one.
Inter and JetBrains Mono fonts).
2. **Chrome.** Headless Chrome prints the page to PDF with
`--headless=new --no-pdf-header-footer --generate-pdf-document-outline --print-to-pdf`.
It runs with a throwaway `--user-data-dir` and
`--disable-features=MacAppCodeSignClone`, so no code-sign clones are left
behind on macOS.
Skia produces 12 tagged pages with embedded font subsets. Variable
system-ui weights come out as Type 3 fonts and emoji as Type 3 bitmap
glyphs. The pages also get shadings, blend modes, soft masks, tiling
+11
View File
@@ -2,3 +2,14 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
# With the theme's widget visuals egui draws a checkbox's box as a circle, like a radio button,
# and on the light theme neither has an outline or a fill that shows on a white panel.
disallowed-methods = [
{ path = "egui::Ui::checkbox", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Checkbox::new", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Checkbox::without_text", reason = "use checkbox_without_text from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Ui::radio", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
]
+23
View File
@@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.YouseifUS]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -103,3 +106,23 @@ sync_github_name = true # use the name on your public GitHub profile as displa
[people.Losminek]
sync_github_name = true
[people.earlvanze]
sync_github_name = true
[people.rp6186030]
real_name = "Raj Patel"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.lollinoo]
sync_github_name = true
[people.JeremyYao]
real_name = "Jeremy Yao"
display_name = "Jeremy Yao"
sync_github_name = false
+25 -15
View File
@@ -47,18 +47,28 @@ pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
t
}
/// Indirect structure elements in document order, with their page.
fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
/// An indirect structure element and its nearest indirect parent in tree order.
pub(crate) struct Element {
pub object: ObjRef,
pub dict: Dict,
pub page: Option<ObjRef>,
pub parent: Option<ObjRef>,
pub depth: usize,
}
/// Indirect structure elements in document order, with their page and hierarchy.
pub(crate) fn elements(doc: &Document) -> Vec<Element> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
while let Some((k, pg)) = stack.pop() {
let mut stack: Vec<(Object, Option<ObjRef>, Option<ObjRef>, usize)> =
root.get(b"K").map(|k| vec![(k.clone(), None, None, 0)]).unwrap_or_default();
while let Some((k, pg, parent, depth)) = stack.pop() {
if out.len() > 1_000_000 {
break;
}
match k {
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg))),
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg, parent, depth))),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
@@ -67,16 +77,16 @@ fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
stack.push((kids.clone(), pg, Some(r), depth.saturating_add(1)));
}
out.push((r, d, pg));
out.push(Element { object: r, dict: d, page: pg, parent, depth });
}
}
Object::Dict(d) if d.contains(b"S") => {
// Direct elements can't be addressed; their indirect children still can.
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
stack.push((kids.clone(), pg, parent, depth));
}
}
_ => {}
@@ -218,13 +228,13 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
.into_iter()
.filter(|(_, d, _)| d.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|(obj, d, pg)| {
let page = pg.and_then(|p| index.get(&p).copied());
let alt = d.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
Figure { obj, page, alt, bbox }
Figure { obj: e.object, page, alt, bbox }
})
.collect()
}
@@ -258,7 +268,7 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let d = check_figure(doc, obj)?;
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let pages = pdfcraft_model::pages(doc);
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|(r, _, _)| *r == obj).and_then(|(_, _, p)| p));
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
let mut changed = false;
+21 -3
View File
@@ -218,6 +218,20 @@ fn subtype_name(d: &Dict) -> String {
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
}
/// An annotation's rectangle is already in page user space. Ignore missing, malformed and
/// degenerate rectangles rather than sending the checker to an arbitrary place on the page.
fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
let rect = annot.get(b"Rect").map(|r| doc.resolve(r))?;
let values = rect.as_array()?;
let [x0, y0, x1, y1] = values.as_slice() else { return None };
let [x0, y0, x1, y1] = [x0, y0, x1, y1].map(|v| doc.resolve(v).as_f64());
let (x0, y0, x1, y1) = (x0?, y0?, x1?, y1?);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) || x0 == x1 || y0 == y1 {
return None;
}
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
@@ -260,7 +274,9 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
values.push(("{}", subtype_name(&a)));
"{} annotation on page {p} isn't tagged"
};
out.push(Finding::worded(Some(p), pattern, values));
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
@@ -313,11 +329,13 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
}
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
if text_of(doc, &fd, b"TU").is_none() {
out.push(Finding::worded(
let mut finding = Finding::worded(
Some(p),
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
));
);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
+29 -4
View File
@@ -8,7 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use pdfcraft_cos::Document;
@@ -17,10 +17,12 @@ mod content;
mod headings;
mod report;
mod structure;
mod tags;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
pub use headings::{Heading, headings};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Category {
@@ -422,6 +424,10 @@ impl Status {
pub struct Finding {
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
/// Bounds in PDF user space when the affected content can be located safely.
pub bbox: Option<[f64; 4]>,
pub message: String,
/// The English sentence with a hole, such as `{p}` or `{what}`, where each value went.
pub pattern: &'static str,
@@ -432,13 +438,13 @@ pub struct Finding {
impl Finding {
/// A finding whose sentence says the same thing however the document is put together.
pub fn plain(page: Option<usize>, sentence: &'static str) -> Self {
Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
Finding { page, object: None, bbox: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
}
/// A finding built from a wording and what goes in its holes. The English `message` is filled
/// in here, so it and `pattern` can never drift apart.
pub fn worded(page: Option<usize>, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self {
Finding { page, message: say(pattern, &values), pattern, values }
Finding { page, object: None, bbox: None, message: say(pattern, &values), pattern, values }
}
/// A finding that says where the problem is and, in `{what}`, what is wrong, each worded
@@ -450,7 +456,7 @@ impl Finding {
pub fn nested(page: Option<usize>, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self {
let message = say(&pattern.replace("{what}", what), &values);
values.push(("{what}", what.to_owned()));
Finding { page, message, pattern, values }
Finding { page, object: None, bbox: None, message, pattern, values }
}
/// This finding as one sentence, said in the language `words` translates into.
@@ -612,6 +618,7 @@ pub fn check(doc: &Document, options: &Options) -> Report {
let tagged = structure::has_struct_tree(doc);
let scan = content::Scan::run(doc, &pages, &page_list);
let mut tree_rules = structure::run(doc, &pages, &options.rules);
let mut figure_bounds: Option<HashMap<pdfcraft_cos::ObjRef, (usize, [f64; 4])>> = None;
let mut results = Vec::with_capacity(32);
for rule in Rule::ALL {
if !options.rules.contains(&rule) {
@@ -636,6 +643,24 @@ pub fn check(doc: &Document, options: &Options) -> Report {
}
_ => tree_rules.remove(&rule).unwrap_or_default(),
};
if rule.category() == Category::AlternateText && findings.iter().any(|f| f.object.is_some()) {
let bounds = figure_bounds.get_or_insert_with(|| {
figures(doc)
.into_iter()
.filter_map(|figure| {
let (page, bbox) = (figure.page?, figure.bbox?);
(bbox.iter().all(|v| v.is_finite()) && bbox[0] < bbox[2] && bbox[1] < bbox[3]).then_some((figure.obj, (page, bbox)))
})
.collect()
});
for finding in &mut findings {
if let Some((page, bbox)) = finding.object.and_then(|obj| bounds.get(&obj))
&& finding.page == Some(*page)
{
finding.bbox = Some(*bbox);
}
}
}
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);
+4
View File
@@ -48,6 +48,10 @@ pub fn report_html(report: &Report, file_name: &str, date: &str, lang: &str, wor
// `REPORT_WORDS` can be read off this file.
h.push_str(&if report.count(Status::Failed) > 0 {
esc(&words("The checker found problems which may prevent the document from being fully accessible."))
} else if report.count(Status::Manual) > 0 {
format!("{}: {}", esc(&words(Status::Manual.label())), report.count(Status::Manual))
} else if report.count(Status::Skipped) > 0 {
format!("{}: {}", esc(&words(Status::Skipped.label())), report.count(Status::Skipped))
} else {
esc(&words("The checker found no problems in this document."))
});
+10 -5
View File
@@ -154,6 +154,8 @@ fn kind_of(ty: &[u8]) -> Kind {
/// is filled in as the walk goes on.
#[derive(Debug)]
pub(crate) struct Frame {
/// Indirect object holding this tag, if it is not an inline dictionary.
object: Option<ObjRef>,
/// The standard type after the role map, or the type as written when it maps to nothing.
ty: Vec<u8>,
kind: Kind,
@@ -314,7 +316,7 @@ impl Walker<'_> {
}
Object::Int(_) => self.mark_content(),
Object::Ref(r) => self.reference(*r, depth, page, v),
Object::Dict(_) => self.dict(loc, depth, page, v),
Object::Dict(_) => self.dict(loc, depth, page, None, v),
_ => {}
}
}
@@ -326,13 +328,13 @@ impl Walker<'_> {
}
let target = self.doc.get(r);
if target.as_dict().is_some() {
self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, v);
self.dict(&Loc { owner: target, path: Vec::new() }, depth, page, Some(r), v);
}
}
/// A dictionary. Marked content and object references only mark the open element; a
/// dictionary with /S is an element, and its /K kids are read inside it.
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, v: &mut V) {
fn dict<V: Visitor>(&mut self, loc: &Loc, depth: usize, page: Option<usize>, object: Option<ObjRef>, v: &mut V) {
let Some(d) = loc.value().and_then(|o| o.as_dict()) else { return };
match d.name(b"Type") {
Some(b"MCR") => return self.mark_content(),
@@ -356,6 +358,7 @@ impl Walker<'_> {
let kind = kind_of(&ty);
let attrs = if matches!(kind, Kind::Table | Kind::Td | Kind::Th) { self.attr_dicts(d) } else { Vec::new() };
let frame = Frame {
object,
ty,
kind,
index: self.opened,
@@ -463,13 +466,15 @@ fn row_owner(path: &[Frame]) -> Option<usize> {
fn worded(e: &Frame, what: &'static str, extra: Vec<(&'static str, String)>) -> Finding {
let mut values = extra;
values.push(("{ty}", String::from_utf8_lossy(&e.ty).into_owned()));
match e.page {
let mut finding = match e.page {
Some(p) => {
values.push(("{p}", p.saturating_add(1).to_string()));
Finding::nested(e.page, "{ty} element on page {p}: {what}", what, values)
}
None => Finding::nested(None, "{ty} element: {what}", what, values),
}
};
finding.object = e.object;
finding
}
/// A finding whose `{what}` wording has no holes of its own.
+166
View File
@@ -0,0 +1,166 @@
//! Properties of reachable indirect structure elements.
use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString};
use crate::alt;
/// A tag that can be addressed by an edit. Inline dictionaries have no stable object reference.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagInfo {
pub object: ObjRef,
/// Nearest reachable indirect parent, if any. Inline ancestors are skipped.
pub parent: Option<ObjRef>,
/// Indentation level among reachable indirect tags.
pub depth: usize,
pub kind: String,
pub title: Option<String>,
pub actual_text: Option<String>,
pub alt: Option<String>,
pub language: Option<String>,
/// Zero-based page when `/Pg` is known.
pub page: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagProperty {
Type,
Title,
ActualText,
AlternateText,
Language,
}
/// Where to place a structure tag relative to one of its direct siblings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagPlacement {
Before,
After,
}
impl TagProperty {
pub fn from_id(id: &str) -> Option<Self> {
match id {
"type" => Some(Self::Type),
"title" => Some(Self::Title),
"actual_text" => Some(Self::ActualText),
"alternate_text" => Some(Self::AlternateText),
"language" => Some(Self::Language),
_ => None,
}
}
fn key(self) -> &'static [u8] {
match self {
Self::Type => b"S",
Self::Title => b"T",
Self::ActualText => b"ActualText",
Self::AlternateText => b"Alt",
Self::Language => b"Lang",
}
}
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum TagError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("a tag type must be a nonempty PDF name using letters, digits, colon, period, underscore or hyphen")]
InvalidType,
#[error("tag property text is too long (maximum 65536 bytes)")]
TooLong,
#[error("both tags must be distinct, reachable, direct children of the same structure parent")]
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error(transparent)]
Cos(#[from] CosError),
}
/// Reorder two direct sibling tags in their parent's `/K` array. Content and page appearance
/// stay untouched; tagged reading order follows the new array order.
pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: TagPlacement) -> Result<(), TagError> {
if object == anchor {
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
let Some(parent) = parent_of(object) else { return Err(TagError::NotSiblings) };
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
let mut matches = children.iter().enumerate().filter(|(_, item)| item.as_ref() == Some(child));
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
if destination == from {
return Err(TagError::AlreadyThere);
}
reordered.insert(destination, moved);
doc.update_dict(parent, |d| d.set(b"K".to_vec(), Object::Array(reordered)))?;
Ok(())
}
fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
alt::elements(doc)
.into_iter()
.filter_map(|e| {
Some(TagInfo {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
language: text(doc, &e.dict, b"Lang"),
page: e.page.and_then(|p| page_index.get(&p).copied()),
})
})
.collect()
}
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|e| e.object == object) {
return Err(TagError::NotATag(object));
}
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
let value = value.filter(|s| !s.is_empty());
if value.is_some_and(|s| s.len() > 65_536) {
return Err(TagError::TooLong);
}
if property == TagProperty::Type
&& !value.is_some_and(|s| s.len() <= 127 && s.as_bytes().iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'_' | b'-')))
{
return Err(TagError::InvalidType);
}
doc.update_dict(object, |d| match value {
Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)),
Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))),
None => {
d.remove(property.key());
}
})?;
Ok(())
}
+115
View File
@@ -164,6 +164,48 @@ fn structure_doc(objects: &[String]) -> Document {
pdf(&objs, "")
}
#[test]
fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
let mut doc =
structure_doc(&["<< /S /Document /K [4 0 R] >>".into(), "<< /S /Figure /T (Old) /Alt (A chart) >>".into(), "<< /S /Figure >>".into()]);
let tag = pdfcraft_cos::ObjRef::new(4, 0);
let listed = tags(&doc);
assert_eq!(listed.len(), 2);
assert_eq!(listed[1].object, tag);
assert_eq!(listed[1].title.as_deref(), Some("Old"));
for (property, value) in [
(TagProperty::Type, "P"),
(TagProperty::Title, "Figure title"),
(TagProperty::ActualText, " Revenue increased "),
(TagProperty::AlternateText, "A rising chart"),
(TagProperty::Language, "en-GB"),
] {
set_tag_property(&mut doc, tag, property, Some(value)).unwrap();
}
let edited = &tags(&doc)[1];
assert_eq!(edited.kind, "P");
assert_eq!(edited.title.as_deref(), Some("Figure title"));
assert_eq!(edited.actual_text.as_deref(), Some(" Revenue increased "));
assert_eq!(edited.alt.as_deref(), Some("A rising chart"));
assert_eq!(edited.language.as_deref(), Some("en-GB"));
set_tag_property(&mut doc, tag, TagProperty::Title, None).unwrap();
assert_eq!(tags(&doc)[1].title, None);
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, None), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, Some("bad type")), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(5, 0), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
}
#[test]
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
let listed = tags(&doc);
assert_eq!(
listed.iter().map(|t| (t.object.num, t.parent.map(|p| p.num), t.depth)).collect::<Vec<_>>(),
[(3, None, 0), (4, Some(3), 1), (5, Some(4), 2)]
);
}
fn checked(doc: &Document, rules: &[Rule]) -> Report {
check(doc, &Options { rules: rules.iter().copied().collect(), pages: None })
}
@@ -216,6 +258,16 @@ fn cycles_shared_kids_and_missing_kids_are_read_once() {
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
}
#[test]
fn structure_findings_identify_indirect_elements_without_guessing_inline_objects() {
let doc = structure_doc(&["<< /S /Document /K [4 0 R << /S /Figure >>] >>".into(), "<< /S /Figure >>".into()]);
let r = checked(&doc, &[Rule::FiguresAltText]);
let found = &r.result(Rule::FiguresAltText).unwrap().findings;
assert_eq!(found.len(), 2);
assert_eq!(found[0].object.map(|r| r.num), Some(4));
assert_eq!(found[1].object, None);
}
#[test]
fn nesting_is_read_to_the_depth_limit_and_no_further() {
// Sections, each the kid of the one before, ending in a figure without alternate text. The
@@ -300,6 +352,19 @@ fn page_content_and_annotation_problems_are_found() {
assert_eq!(got, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TabOrder, Rule::FieldDescriptions]);
assert_eq!(r.result(Rule::TaggedContent).unwrap().findings[0].message, "Page 1: 1 untagged content item");
assert!(r.result(Rule::FieldDescriptions).unwrap().findings[0].message.contains("\"name\""));
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, Some([20.0, 20.0, 80.0, 40.0]));
assert_eq!(r.result(Rule::FieldDescriptions).unwrap().findings[0].bbox, Some([20.0, 60.0, 120.0, 80.0]));
}
#[test]
fn annotation_findings_only_locate_valid_rectangles() {
let cases = [("[80 40 20 20]", Some([20.0, 20.0, 80.0, 40.0])), ("[20 20 20 40]", None), ("[20 20 80]", None), ("[20 20 80 (bad)]", None)];
for (rect, expected) in cases {
let mut objs = good();
objs[8] = format!("<< /Type /Annot /Subtype /Link /Rect {rect} >>");
let r = checked(&pdf(&objs, "/Info 8 0 R"), &[Rule::TaggedAnnotations]);
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, expected, "{rect}");
}
}
#[test]
@@ -356,6 +421,13 @@ fn rules_have_ids_and_the_report_lists_them() {
let other = report_html(&report, "a<b>.pdf", "2026-10-02", "es", &|w| format!("«{w}»"));
assert!(other.contains("<html lang=\"es\">") && other.contains("<h3>«Headings»</h3>") && other.contains("a&lt;b&gt;.pdf"));
assert!(!other.contains(">Headings<"), "an English heading is left in the report: {other}");
let manual_only = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Manual, findings: Vec::new() }] };
let manual_html = report_html(&manual_only, "review.pdf", "2026-10-02", "en", &in_english);
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
assert!(!manual_html.contains("no problems"));
let all_passed = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Passed, findings: Vec::new() }] };
let passed_html = report_html(&all_passed, "review.pdf", "2026-10-02", "en", &in_english);
assert!(passed_html.contains("<p>The checker found no problems in this document.</p>"), "{passed_html}");
}
fn figures_doc() -> Document {
@@ -388,6 +460,49 @@ fn figures_are_listed_with_their_alt_text_and_place() {
assert_eq!(f.len(), 2, "role-mapped Photo counts");
assert_eq!((f[0].page, f[0].alt.as_deref(), f[0].bbox), (Some(0), None, Some([10.0, 150.0, 50.0, 170.0])));
assert_eq!((f[1].alt.as_deref(), f[1].bbox), (Some("A bar"), Some([100.0, 100.0, 150.0, 130.0])));
let report = check(&doc, &Options { rules: [Rule::FiguresAltText].into(), pages: None });
let finding = &report.result(Rule::FiguresAltText).unwrap().findings[0];
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
}
#[test]
fn sibling_tags_move_in_reading_order_without_changing_content() {
let mut doc = figures_doc();
let before: Vec<_> = tags(&doc).into_iter().map(|tag| tag.object.num).collect();
assert_eq!(before, [6, 7, 8]);
let content = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 8, 7]);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(4, 0)), content, "page content stays unchanged");
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::AlreadyThere)
);
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 7, 8]);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(6, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 1), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
d.set(
b"K".to_vec(),
pdfcraft_cos::Object::Array(vec![
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
]),
);
})
.unwrap();
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After),
Err(TagError::AmbiguousChildren)
);
}
#[test]
+8 -1
View File
@@ -10,7 +10,14 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled).
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line;
`appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction
mark's overlay text, which becomes page content when applied; and a restyle or resize of a
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).
+21 -1
View File
@@ -11,8 +11,8 @@
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::{literal, win_ansi};
use crate::{NOTE_SIZE, Rgb, n};
@@ -264,7 +264,27 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
};
Some((what, first_undrawable(&text)?))
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
+53 -10
View File
@@ -34,6 +34,11 @@ pub enum AnnotError {
Invalid(String),
#[error("{0} comments can't be restyled yet (their appearance can't be regenerated)")]
Unsupported(String),
/// Text the standard fonts can't draw where `?` would be permanent or replace a good drawing:
/// a redaction's overlay text, or an edit that would redraw a comment another program drew
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -1202,8 +1207,16 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
// Survives a later subject edit, so flatten-on-save still finds the mark.
d.set(b"PCFillSign".to_vec(), Object::Bool(true));
}
// A redaction's overlay text becomes page content when it is applied, where `?` would be there
// for good: refused before anything is added. A comment's own text is stored as typed and
// drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`).
if d.name(b"Subtype") == Some(b"Redact")
&& let Some((what, c)) = appearance::undrawable_text(&d)
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
let r = doc.add(Object::Dict(d.clone()));
set_appearance(doc, r)?;
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
@@ -1250,9 +1263,33 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
/// Regenerate an annotation's normal appearance from its dictionary.
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
/// text (a restyle, a new size or colour).
///
/// A comment whose text the standard fonts can't draw, with an appearance another program drew
/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in
/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?`
/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn.
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
draw_appearance(doc, r)
}
/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard
/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`,
/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that
/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font
/// can't show, and for display-only copies.
fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
@@ -1280,6 +1317,8 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
@@ -1290,6 +1329,7 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
Ok(())
}
@@ -1489,10 +1529,12 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
})?;
if free_text {
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
// text editable (as in Acrobat), so the re-fit ignores the lock.
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
// where the standard fonts can't draw some of it (marked and reported, see
// `draw_appearance`).
match fitted_box(doc, r, text) {
Some(rect) => apply_text_box(doc, r, rect, meta)?,
None => set_appearance(doc, r)?,
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
None => draw_appearance(doc, r)?,
}
}
Ok(())
@@ -1700,7 +1742,7 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
apply_text_box(doc, r, rect, meta, false)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
@@ -1750,8 +1792,9 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
@@ -1782,7 +1825,7 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) ->
}
touch(d, meta);
})?;
set_appearance(doc, r)
if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) }
}
fn border_width_of(d: &Dict) -> f64 {
+87
View File
@@ -1159,6 +1159,93 @@ fn drawings_resize_by_scaling_their_strokes() {
assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_))));
}
/// Comment text the standard fonts can't draw is kept, as Acrobat keeps it: a text box, Fill & Sign
/// text or a dynamic stamp's by-line is added with its text as typed, its appearance draws `?` for
/// those characters, and the comment is marked with the first one (cleared once all of it can be
/// drawn). A comment another program drew with a font that has those characters keeps that
/// appearance: a restyle is refused rather than replace it with `?`, while new text is taken.
#[test]
fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
let mut doc = fixture();
let mark = |doc: &Document, i: usize| pdfcraft_fonts::undrawn_mark(&list(doc, 0)[i]);
let mut boxed = new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 });
boxed.contents = "签名 Ada".into();
let t = add_annotation(&mut doc, &boxed, &meta("t")).unwrap();
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名 Ada");
assert!(ap_content(&doc, &list(&doc, 0)[t]).contains("(?? Ada)"), "{}", ap_content(&doc, &list(&doc, 0)[t]));
assert_eq!(mark(&doc, t), Some('签'));
let typed = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
let f = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&typed), shape: typed, contents: "张三".into(), author: "Ada".into() },
&meta("x"),
)
.unwrap();
assert_eq!(mark(&doc, f), Some('张'));
let stamp =
Shape::Stamp { rect: [100.0, 100.0, 260.0, 142.0], stamp: StampKind::DynApproved, by: Some("By 张三 at 2:14 pm, Oct 02, 2026".into()) };
let s = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&stamp), shape: stamp, contents: String::new(), author: "张三".into() },
&meta("s"),
)
.unwrap();
assert_eq!(mark(&doc, s), Some('张'));
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
assert_eq!(mark(&doc, t), Some('签'));
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
assert_eq!(mark(&doc, t), None);
set_contents(&mut doc, 0, t, "签名", &meta("")).unwrap();
assert_eq!((text(&list(&doc, 0)[t], b"Contents").as_str(), mark(&doc, t)), ("签名", Some('签')));
// A text box another program drew (with a font that has 欢): a restyle keeps that appearance.
let mut foreign = Dict::new();
foreign.set(b"Type".to_vec(), Object::name("Annot"));
foreign.set(b"Subtype".to_vec(), Object::name("FreeText"));
foreign.set(b"Rect".to_vec(), Object::Array([100.0, 400.0, 300.0, 430.0].iter().map(|v| Object::Real(*v)).collect()));
foreign.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
foreign.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let mut drawn = Dict::new();
drawn.set(b"Type".to_vec(), Object::name("XObject"));
drawn.set(b"Subtype".to_vec(), Object::name("Form"));
drawn.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 30.0].iter().map(|v| Object::Real(*v)).collect()));
let drawn = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(drawn, b"BT /F1 12 Tf (\x01\x02) Tj ET".to_vec())));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(drawn));
foreign.set(b"AP".to_vec(), Object::Dict(ap));
let r = doc.add(Object::Dict(foreign));
let page = page_ref(&doc, 0).unwrap();
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(r));
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept");
// New text from the user is taken (and marked), as for any comment.
set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap();
assert_eq!(mark(&doc, k), Some('欢'));
// One without an appearance is shown in the display copy only; drawing it marks it.
let mut bare = Dict::new();
bare.set(b"Type".to_vec(), Object::name("Annot"));
bare.set(b"Subtype".to_vec(), Object::name("FreeText"));
bare.set(b"Rect".to_vec(), Object::Array([100.0, 300.0, 300.0, 330.0].iter().map(|v| Object::Real(*v)).collect()));
bare.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
bare.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let b = doc.add(Object::Dict(bare));
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(b));
set_annots(&mut doc, page, on_page).unwrap();
let shown = with_missing_appearances(&doc).expect("a display copy");
assert!(shown.get(b).as_dict().unwrap().contains(b"AP"), "displayed anyway");
assert!(!doc.get(b).as_dict().unwrap().contains(b"AP"), "the document itself is unchanged");
set_appearance(&mut doc, b).unwrap();
assert_eq!(doc.get(b).as_dict().and_then(pdfcraft_fonts::undrawn_mark), Some('欢'));
// Kept through a save.
let doc = reopen(&doc);
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名");
}
/// Content edits rebuild the display copy on every commit but leave annotations alone: the
/// cached appearance is drawn once and reused after that, byte-identical.
#[test]
+6 -1
View File
@@ -98,12 +98,17 @@ impl<'a> Walker<'a> {
pending = None;
}
b"cm" => {
let before = gs.ctm;
if let Some(m) = Matrix::from_operands(&op.operands)
&& m.0.iter().all(|v| v.is_finite())
{
gs.ctm = m.then(&gs.ctm);
}
pending = None;
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending rectangle, so keep it for the fill (#1333).
if gs.ctm != before {
pending = None;
}
}
b"g" => {
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
+14
View File
@@ -113,6 +113,20 @@ fn faux_redaction_is_found() {
assert!(f.covered_text.contains("SECRET"), "covered_text was {:?}", f.covered_text);
}
#[test]
fn identity_matrix_between_rectangle_and_fill_still_covers_text() {
// A no-op `cm` inside the path must not drop the pending rectangle (#1333).
for cover in [&b"0 g\n100 688 220 32 re 1 0 0 1 0 0 cm f\n"[..], b"0 g\n1 0 0 1 0 0 cm 100 688 220 32 re f\n"] {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn clean_document_has_no_findings() {
let content = b"BT /F1 24 Tf 100 700 Td (nothing hidden here) Tj ET\n";
+4 -1
View File
@@ -10,10 +10,12 @@ workspace = true
[dependencies]
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
png = "0.18"
base64 = { version = "0.22", optional = true }
@@ -24,7 +26,8 @@ default = ["mcp"]
mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
+25
View File
@@ -85,6 +85,15 @@ moves 1–1000 references atomically in displayed points (right/down), preservin
and fonts. A refusal changes nothing; success is one undo step. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
Open the original and edited PDFs as separate documents, then call
`accessibility_review_record {before, after, path}`. The tool writes a JSON record atomically within
`--root`: SHA-256 hashes of both working PDFs, rule statuses and finding counts before and after,
changed rules, and rules still failed or requiring manual review. `all: true` also includes the
colour-contrast rule. It does not change either PDF or certify accessibility. Keep the original open
while making edits to the second document so the comparison has a stable baseline.
## Common command tools
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
@@ -109,3 +118,19 @@ Exports complete synchronously. `progressToken` and cancellation notifications a
ignored; no background-job or cancellation behavior is added.
Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md).
For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based index from
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
working document unchanged.
## Existing text and document fonts
`text_font_files` discovers eligible installed files on request (or specified confined folders),
`text_font_import` registers a full document face, and `text_fonts` lists source/full resources.
Use `text_edit.font_resource` to choose a resource, or keep source faces and let matching full
faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 byte ranges;
`text_edit_layout` measures the same resource/range draft without committing. All changes are
undoable and survive saving/reopening. `image_hit` shares the UI's curve and hole hit testing.
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
+425 -3
View File
@@ -1,7 +1,10 @@
//! Accessibility tools: the full check, the accessibility report, and the automatic fixes.
use pdfcraft_engine::DocId;
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
@@ -31,7 +34,19 @@ pub(crate) fn report_json(r: &Report) -> Value {
.results
.iter()
.map(|x| {
let findings: Vec<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
let findings: Vec<Value> = x
.findings
.iter()
.map(|f| {
json!({
"page": f.page.map(|p| p + 1),
"object": f.object.map(|r| r.num),
"object_generation": f.object.map(|r| r.generation),
"bbox_user_space": f.bbox,
"message": f.message,
})
})
.collect();
json!({ "rule": x.rule.id(), "name": x.rule.name(), "category": category_id(x.rule.category()), "status": status_id(x.status), "findings": findings })
})
.collect();
@@ -44,7 +59,173 @@ pub(crate) fn report_json(r: &Report) -> Value {
})
}
fn review_hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
fn review_decisions(value: &Value, pages: usize) -> Result<Vec<Value>> {
let rows = value.as_array().ok_or_else(|| ToolError::InvalidArgs("decisions must be an array".into()))?;
let mut seen = BTreeSet::new();
rows.iter()
.map(|row| {
let fields = row.as_object().ok_or_else(|| ToolError::InvalidArgs("each decision must be an object".into()))?;
if fields.keys().any(|key| !matches!(key.as_str(), "rule" | "decision" | "page" | "note")) {
return Err(ToolError::InvalidArgs("unknown decision property".into()));
}
let rule = row["rule"].as_str().and_then(Rule::from_id).ok_or_else(|| ToolError::InvalidArgs("decision has an unknown rule".into()))?;
let decision = row["decision"].as_str().ok_or_else(|| ToolError::InvalidArgs("decision status is required".into()))?;
if !matches!(decision, "accepted" | "needs_work" | "skipped") {
return Err(ToolError::InvalidArgs("decision must be accepted, needs_work or skipped".into()));
}
let page = match row.get("page").filter(|value| !value.is_null()) {
Some(value) => {
let page = value.as_u64().ok_or_else(|| ToolError::InvalidArgs("decision page must be positive".into()))?;
if page == 0 || page > pages as u64 {
return Err(ToolError::InvalidArgs("decision page is outside the document".into()));
}
Some(page)
}
None => None,
};
let note = match row.get("note").filter(|value| !value.is_null()) {
Some(value) => {
let note = value.as_str().ok_or_else(|| ToolError::InvalidArgs("decision note must be text".into()))?;
if note.len() > 4096 {
return Err(ToolError::InvalidArgs("decision note exceeds 4096 bytes".into()));
}
Some(note)
}
None => None,
};
if !seen.insert((rule.id(), page)) {
return Err(ToolError::InvalidArgs("duplicate decision for a rule and page".into()));
}
Ok(json!({ "rule": rule.id(), "decision": decision, "page": page, "note": note }))
})
.collect()
}
/// A review sidecar is a few KiB of JSON; anything larger is not one.
const MAX_REVIEW_SIDECAR_BYTES: u64 = 1 << 20;
/// Read a review sidecar without trusting its size: the path is the caller's
/// and may name any file, so the read stops past `MAX_REVIEW_SIDECAR_BYTES` and
/// an oversized file is refused as not being a sidecar.
fn read_review_sidecar(path: &std::path::Path) -> Result<Vec<u8>> {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|file| file.take(MAX_REVIEW_SIDECAR_BYTES.saturating_add(1)).read_to_end(&mut bytes)).map_err(failed)?;
if bytes.len() as u64 > MAX_REVIEW_SIDECAR_BYTES {
return Err(failed("file is not an accessibility review sidecar"));
}
Ok(bytes)
}
fn parse_review_sidecar(bytes: &[u8]) -> Result<Value> {
let value: Value = serde_json::from_slice(bytes).map_err(|_| failed("file is not an accessibility review sidecar"))?;
let hash = value["sha256"].as_str().ok_or_else(|| failed("review sidecar has no document hash"))?;
let Some(pages) = value["pages"].as_u64().and_then(|n| usize::try_from(n).ok()).filter(|n| *n > 0) else {
return Err(failed("unsupported or invalid accessibility review sidecar"));
};
if value["schema"] != 1 || hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(failed("unsupported or invalid accessibility review sidecar"));
}
review_decisions(&value["decisions"], pages).map_err(failed)?;
Ok(value)
}
fn html_escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// A self-contained, human-readable view of the same evidence as the JSON review record.
fn review_html(record: &Value) -> Result<String> {
let str_at = |value: &Value, key: &str| -> Result<String> {
value.get(key).and_then(Value::as_str).map(html_escape).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let count = |value: &Value, key: &str| -> Result<u64> {
value.get(key).and_then(Value::as_u64).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let before = &record["before"];
let after = &record["after"];
let mut html = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility review</title><style>\
body{{font:15px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#1d1d1f}}\
table{{border-collapse:collapse;width:100%;margin:1rem 0}}th,td{{border:1px solid #aaa;padding:.45rem;text-align:left;vertical-align:top}}\
th{{background:#f2f2f2}}.hash{{overflow-wrap:anywhere;font-family:monospace}}\
</style></head><body><main><h1>Accessibility review</h1><p>Record created: {}</p>\n",
str_at(record, "date")?
);
html.push_str("<h2>Before and after</h2><table><caption>Accessibility checker outcomes</caption><thead><tr><th scope=\"col\">Version</th><th scope=\"col\">Document</th><th scope=\"col\">Passed</th><th scope=\"col\">Failed</th><th scope=\"col\">Needs manual check</th><th scope=\"col\">Skipped</th></tr></thead><tbody>");
for (label, version) in [("Before", before), ("After", after)] {
let summary = &version["summary"];
html.push_str(&format!(
"<tr><th scope=\"row\">{label}</th><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
str_at(version, "name")?,
count(summary, "passed")?,
count(summary, "failed")?,
count(summary, "manual")?,
count(summary, "skipped")?
));
}
html.push_str("</tbody></table>");
html.push_str(&format!(
"<p>Before SHA-256: <span class=\"hash\">{}</span><br>After SHA-256: <span class=\"hash\">{}</span></p>",
str_at(before, "sha256")?,
str_at(after, "sha256")?
));
html.push_str("<h2>Rule comparison</h2><table><caption>Before and after checker results</caption><thead><tr><th scope=\"col\">Rule</th><th scope=\"col\">Before</th><th scope=\"col\">Before findings</th><th scope=\"col\">After</th><th scope=\"col\">After findings</th><th scope=\"col\">Change</th></tr></thead><tbody>");
let rules = record["rules"].as_array().ok_or_else(|| failed("review record is missing rules"))?;
for row in rules {
let id = row["rule"].as_str().ok_or_else(|| failed("review record has a rule without an id"))?;
let name = Rule::from_id(id).map(Rule::name).unwrap_or(id);
let old = &row["before"];
let new = &row["after"];
let old_status = str_at(old, "status")?;
let new_status = str_at(new, "status")?;
let old_findings = count(old, "findings")?;
let new_findings = count(new, "findings")?;
let changed = old_status != new_status || old_findings != new_findings;
html.push_str(&format!(
"<tr><th scope=\"row\">{}</th><td>{old_status}</td><td>{old_findings}</td><td>{new_status}</td><td>{new_findings}</td><td>{}</td></tr>",
html_escape(name),
if changed { "Changed" } else { "Unchanged" }
));
}
html.push_str("</tbody></table><h2>Still requiring review</h2><ul>");
for id in record["review_required"].as_array().ok_or_else(|| failed("review record is missing review_required"))? {
let id = id.as_str().ok_or_else(|| failed("review record has an invalid review_required entry"))?;
html.push_str(&format!("<li>{}</li>", html_escape(Rule::from_id(id).map(Rule::name).unwrap_or(id))));
}
html.push_str("</ul>");
html.push_str(&format!("<p>{}</p></main></body></html>\n", str_at(record, "note")?));
Ok(html)
}
impl Automation {
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
let candidates: Vec<Value> = preview
.candidates
.into_iter()
.map(|c| {
json!({
"type": c.kind, "reason": c.reason, "text": c.text,
"text_truncated": c.text_truncated, "bbox_user_space": c.rect,
"font_size": c.size, "bold": c.bold, "needs_review": true,
})
})
.collect();
Ok(json!({
"page": page + 1, "candidates": candidates,
"unclassified_images": preview.unclassified_images,
"unclassified_text_blocks": preview.unclassified_text_blocks,
"existing_structure": preview.existing_structure,
"note": "Text roles are proposals only. Review images, tables, lists, annotations and existing tags before applying structure.",
}))
}
fn a11y_options(&self, a: &Args) -> Result<Options> {
let mut o = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
@@ -93,6 +274,120 @@ impl Automation {
Ok(out)
}
fn review_record_value(&self, a: &Args) -> Result<Value> {
let id = |key| -> Result<DocId> {
let n = u64::try_from(a.int(key)?).map_err(|_| ToolError::InvalidArgs(format!("{key} must be positive")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{key} must be positive")));
}
Ok(DocId(n))
};
let before_id = id("before")?;
let after_id = id("after")?;
if before_id == after_id {
return Err(ToolError::InvalidArgs("before and after must be separate open documents".into()));
}
let before = self.session.get(before_id).ok_or_else(|| failed(format!("no open document with id {}", before_id.0)))?;
let after = self.session.get(after_id).ok_or_else(|| failed(format!("no open document with id {}", after_id.0)))?;
let mut options = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
options.rules = Rule::ALL.into_iter().collect();
}
let before_report = before.accessibility_check(&options).ok_or_else(|| failed("the original document can't be read for checking"))?;
let after_report = after.accessibility_check(&options).ok_or_else(|| failed("the edited document can't be read for checking"))?;
if before_report.results.len() != after_report.results.len() {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let mut rules = Vec::new();
let mut changed = Vec::new();
let mut review_required = Vec::new();
for (prior, current) in before_report.results.iter().zip(&after_report.results) {
if prior.rule != current.rule {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let rule = prior.rule.id();
let before_status = status_id(prior.status);
let after_status = status_id(current.status);
let before_findings = prior.findings.len();
let after_findings = current.findings.len();
if before_status != after_status || before_findings != after_findings {
changed.push(rule);
}
if matches!(current.status, Status::Failed | Status::Manual) {
review_required.push(rule);
}
rules.push(json!({
"rule": rule,
"before": { "status": before_status, "findings": before_findings },
"after": { "status": after_status, "findings": after_findings },
}));
}
let (y, m, d) = self.session.today();
let hash = |bytes: &[u8]| Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect::<String>();
let record = json!({
"schema": 1,
"date": format!("{y}-{m:02}-{d:02}"),
"before": { "name": before.name, "sha256": hash(before.bytes.as_slice()), "summary": {
"passed": before_report.count(Status::Passed), "failed": before_report.count(Status::Failed),
"manual": before_report.count(Status::Manual), "skipped": before_report.count(Status::Skipped),
} },
"after": { "name": after.name, "sha256": hash(after.bytes.as_slice()), "summary": {
"passed": after_report.count(Status::Passed), "failed": after_report.count(Status::Failed),
"manual": after_report.count(Status::Manual), "skipped": after_report.count(Status::Skipped),
} },
"rules": rules,
"changed": changed,
"review_required": review_required,
"note": "Automated checks cover only part of accessibility. Review remaining findings manually and validate the final PDF independently.",
});
Ok(record)
}
pub(crate) fn a11y_review_record(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let path = self.resolve(a.str("path")?, true)?;
let bytes = serde_json::to_vec_pretty(&record).map_err(failed)?;
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_review_save(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let decisions = review_decisions(a.get("decisions").ok_or_else(|| Args::missing("decisions"))?, doc.info.pages.len())?;
let hash = review_hash(doc.bytes.as_slice());
let path = self.resolve(a.str("path")?, true)?;
if path.exists() {
let prior = read_review_sidecar(&path)?;
let prior = parse_review_sidecar(&prior)?;
if prior["sha256"] != hash && !a.opt_bool("replace")?.unwrap_or(false) {
return Err(failed("review sidecar belongs to a different PDF revision; pass replace: true to replace it"));
}
}
let (y, m, d) = self.session.today();
let sidecar =
json!({ "schema": 1, "date": format!("{y}-{m:02}-{d:02}"), "sha256": hash, "pages": doc.info.pages.len(), "decisions": decisions });
write_atomic(&path, &serde_json::to_vec_pretty(&sidecar).map_err(failed)?)?;
Ok(json!({ "path": path.to_string_lossy(), "review": sidecar }))
}
pub(crate) fn a11y_review_load(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let path = self.resolve(a.str("path")?, false)?;
let review = parse_review_sidecar(&read_review_sidecar(&path)?)?;
if review["sha256"] != review_hash(doc.bytes.as_slice()) {
return Err(failed("review sidecar belongs to a different PDF revision"));
}
Ok(json!({ "path": path.to_string_lossy(), "review": review }))
}
pub(crate) fn a11y_review_html(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let html = review_html(&record)?;
let path = self.resolve(a.str("path")?, true)?;
write_atomic(&path, html.as_bytes())?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result<Value> {
let id = a.str("rule")?;
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
@@ -135,6 +430,63 @@ impl Automation {
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
}
pub(crate) fn accessibility_tags(&self, a: &Args) -> Result<Value> {
let tags: Vec<Value> = self
.doc(a)?
.tags()
.into_iter()
.map(|t| {
json!({
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
"language": t.language, "page": t.page.map(|p| p + 1),
})
})
.collect();
Ok(json!({ "count": tags.len(), "tags": tags }))
}
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
}
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let property_id = a.str("property")?;
let property = pdfcraft_engine::a11y::TagProperty::from_id(property_id)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown tag property {property_id:?}")))?;
let value = a.opt_str("value")?.map(str::to_owned);
let id = self.doc(a)?.id;
self.session
.apply(id, pdfcraft_engine::Edit::SetTagProperty { object: pdfcraft_cos::ObjRef::new(object, generation), property, value })
.map_err(failed)?;
self.accessibility_tags(a)
}
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{number} must be a positive tag object number")));
}
let g = u16::try_from(a.int(generation)?)
.map_err(|_| ToolError::InvalidArgs(format!("{generation} must be a PDF object generation from accessibility_tags")))?;
Ok(pdfcraft_cos::ObjRef::new(n, g))
};
let object = reference("object", "generation")?;
let anchor = reference("anchor", "anchor_generation")?;
let placement = match a.str("placement")? {
"before" => pdfcraft_engine::a11y::TagPlacement::Before,
"after" => pdfcraft_engine::a11y::TagPlacement::After,
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
}
}
impl Automation {
@@ -195,7 +547,7 @@ impl Automation {
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "text": p.text() }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
})
.collect();
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
@@ -272,7 +624,7 @@ impl Automation {
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?;
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
let bytes = self.doc_to_copy(a)?.export_office(format);
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
@@ -300,6 +652,56 @@ impl Automation {
}))
}
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
let report = self.doc(a)?.print_readiness();
let findings: Vec<Value> =
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
let pages: Vec<Value> = report
.pages
.iter()
.map(|p| {
json!({
"page": p.page,
"media_mm": p.media_mm,
"crop_mm": p.crop_mm,
"trim_mm": p.trim_mm,
"bleed_mm": p.bleed_mm,
"trim_set": p.trim_set,
"bleed_set": p.bleed_set,
"bleed_margin_mm": p.bleed_margin_mm,
})
})
.collect();
let images: Vec<Value> = report
.images
.iter()
.map(|image| {
json!({
"page": image.page,
"name": image.name,
"ppi": image.ppi,
"pixels": image.pixels,
"size_mm": image.size_mm,
})
})
.collect();
let pdfa: Vec<Value> = report
.pdfa_issues
.iter()
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
.collect();
Ok(json!({
"text": report.text(),
"findings": findings,
"pages": pages,
"images": images,
"output_intents": report.output_intents,
"fonts_not_embedded": report.fonts_not_embedded,
"pdfa_issues": pdfa,
"resolution_unknown": report.resolution_unknown,
}))
}
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
@@ -516,3 +918,23 @@ impl Automation {
Ok(json!({ "enabled": self.session.javascript() }))
}
}
#[cfg(test)]
mod review_html_tests {
use super::*;
#[test]
fn document_names_are_escaped_in_review_html() {
let record = json!({
"date": "2026-10-12",
"before": { "name": "<script>alert('x')</script>.pdf", "sha256": "a", "summary": { "passed": 0, "failed": 1, "manual": 0, "skipped": 0 } },
"after": { "name": "safe.pdf", "sha256": "b", "summary": { "passed": 1, "failed": 0, "manual": 0, "skipped": 0 } },
"rules": [{ "rule": "primary-language", "before": { "status": "failed", "findings": 1 }, "after": { "status": "passed", "findings": 0 } }],
"review_required": [],
"note": "Review independently.",
});
let html = review_html(&record).unwrap();
assert!(html.contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;.pdf"));
assert!(!html.contains("<script>"));
}
}
+38 -2
View File
@@ -469,17 +469,45 @@ impl Automation {
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let kind = a.str("type")?;
let scan = a.opt_bool("scan")?.unwrap_or(false);
let sensitivity = a.opt_int("sensitivity")?;
if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() {
return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into()));
}
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
}
let size = requested_size.unwrap_or(10.0);
if !(4.0..=300.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
}
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
}
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let image = if scan {
let sensitivity = match sensitivity {
None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY,
Some(s) => u8::try_from(s)
.ok()
.filter(|s| *s <= 100)
.ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?,
};
let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let frame = photo.auto_frame();
photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
} else {
pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
};
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
let size = 10.0;
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;
@@ -501,6 +529,14 @@ impl Automation {
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
// Nobody sees a preview here: refuse what the script font would leave out.
if let Some(c) = pdfcraft_engine::script_missing(t) {
return Err(ToolError::InvalidArgs(format!(
"the signature script font can't draw \"{}\" (U+{:04X}); type the {kind} in Latin, Cyrillic or Greek letters, or pass `path` to a PNG or JPEG image of it instead of `text`",
c.escape_debug(),
u32::from(c)
)));
}
let h = if kind == "initials" { 24.0 } else { 32.0 };
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
+3
View File
@@ -164,6 +164,9 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color and align apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
return Err(bad("rotate must be a multiple of 90"));
+1 -1
View File
@@ -114,7 +114,7 @@ impl Automation {
return Err(failed("page has invalid dimensions"));
}
let scale = (max as f32 / side).min(1.0);
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 });
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
if let Some(error) = out.error {
return Err(failed(error));
}
+2 -1
View File
@@ -520,7 +520,8 @@ impl Automation {
let id = self.doc(a)?.id;
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .csv or .txt"))?;
let format =
pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?;
let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") {
"all" => (true, true),
"comments" => (true, false),
+218 -17
View File
@@ -21,6 +21,7 @@ mod links;
pub mod mcp;
mod measure;
mod printing;
mod reading;
mod redact;
mod signing;
mod tools;
@@ -390,6 +391,21 @@ impl Automation {
.collect();
json!({ "page": page + 1, "count": lines.len(), "lines": lines })
}
"image_hit" => {
let page = self.page(&a)?;
let point = a
.get("point")
.and_then(Value::as_array)
.filter(|p| p.len() == 2)
.and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9))
.ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?;
let doc = self.doc(&a)?;
let p = &doc.info.pages[page];
let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from);
let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect();
json!({"page":page+1,"images":images})
}
"page_images" => {
let page = self.page(&a)?;
let doc = self.doc(&a)?;
@@ -401,7 +417,11 @@ impl Automation {
.enumerate()
.map(|(i, im)| {
let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32));
json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
let visible = im.visible_rect.map(|rect| {
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
});
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
})
.collect();
json!({ "page": page + 1, "count": list.len(), "images": list })
@@ -470,6 +490,8 @@ impl Automation {
json!({
"paragraph": i + 1,
"text": b.text,
"editor_text": b.editor_text,
"rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::<Vec<_>>()).collect::<Vec<_>>(),
"lines": b.lines.iter().map(|l| l + 1).collect::<Vec<_>>(),
"rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))],
"font": b.base_font,
@@ -479,6 +501,78 @@ impl Automation {
.collect();
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
}
"text_font_files" => {
let roots = if let Some(paths) = a.get("directories") {
let paths = paths
.as_array()
.filter(|p| p.len() <= 16)
.ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?;
paths
.iter()
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
.collect::<Result<Vec<_>>>()?
} else if let Some(root) = &self.root {
vec![root.clone()]
} else {
pdfcraft_engine::text_fonts::installed_directories()
};
json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::<Vec<_>>()})
}
"text_font_import" => {
let page = self.page(&a)?;
let path = self.resolve(a.str("path")?, false)?;
let file = std::fs::File::open(&path).map_err(failed)?;
use std::io::Read;
let mut bytes = Vec::new();
file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?;
if bytes.len() > 16 << 20 {
return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into()));
}
let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect();
let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?;
let added = self
.doc(&a)?
.text_editor_fonts(page)
.into_iter()
.find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved"));
if let Some(f) = added {
out["resource"] = json!(f.resource);
out["font"] = json!(f.face);
}
out
}
"text_fonts" => {
let page = self.page(&a)?;
json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::<Vec<_>>()})
}
"text_edit_layout" => {
let page = self.page(&a)?;
let doc = self.doc(&a)?;
let k = a.int("paragraph")?;
let blocks = doc.text_blocks(page);
if k < 1 || k as usize > blocks.len() {
return Err(ToolError::InvalidArgs("paragraph is out of range".into()));
}
let source = &blocks[k as usize - 1];
let text = a.opt_str("text")?.unwrap_or(&source.editor_text);
let style = pdfcraft_engine::BlockStyle {
size: a.opt_num("size")?,
width: a.opt_num("width")?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
ranges: parse_style_ranges(a.get("ranges"))?,
..Default::default()
};
let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?;
let info = &doc.info.pages[page];
let view_rect = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
};
let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect();
json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect),
"draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts })
}
"text_edit" if a.get("paragraph").is_some() => {
let page = self.page(&a)?;
let n = self.doc(&a)?.text_blocks(page).len();
@@ -489,6 +583,8 @@ impl Automation {
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
bold: a.opt_bool("bold")?,
size: a.opt_num("size")?,
underline: a.opt_bool("underline")?,
@@ -587,6 +683,7 @@ impl Automation {
"printers" => self.printers()?,
"printer_options" => self.printer_options(&a)?,
"link_list" => self.link_list(&a)?,
"link_open" => self.link_open(&a)?,
"link_add" => self.link_add(&a)?,
"link_edit" => self.link_edit(&a)?,
"link_delete" => self.link_delete(&a)?,
@@ -619,6 +716,7 @@ impl Automation {
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
"js_run" => self.js_run(&a)?,
"js_document_scripts" => self.js_document_scripts(&a)?,
@@ -631,6 +729,7 @@ impl Automation {
"doc_compare" => self.doc_compare(&a)?,
"action_list" => self.action_list()?,
"pdfa_verify" => self.pdfa_verify(&a)?,
"print_readiness" => self.print_readiness(&a)?,
"doc_export_office" => self.doc_export_office(&a)?,
"pdfa_convert" => self.pdfa_convert(&a)?,
"action_run" => self.action_run(&a)?,
@@ -640,9 +739,16 @@ impl Automation {
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
"accessibility_review_record" => self.a11y_review_record(&a)?,
"accessibility_review_save" => self.a11y_review_save(&a)?,
"accessibility_review_load" => self.a11y_review_load(&a)?,
"accessibility_review_html" => self.a11y_review_html(&a)?,
"accessibility_fix" => self.a11y_fix(&a)?,
"accessibility_figures" => self.accessibility_figures(&a)?,
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
"accessibility_tags" => self.accessibility_tags(&a)?,
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
"sign_keychain_ids" => {
@@ -680,6 +786,7 @@ impl Automation {
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
"comments_summarize" => self.comments_summarize(&a)?,
"comment_edit" => self.comment_edit(&a)?,
"comment_delete" => self.comment_delete(&a)?,
@@ -758,10 +865,13 @@ impl Automation {
self.apply(a, Edit::FlattenFillSign)?;
}
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?;
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
// redaction), is rewritten in full even when an incremental save was asked for.
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
write_atomic(&target, &bytes)?;
let path = target.to_string_lossy().into_owned();
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": !full, "document": summary(self.doc(a)?) }))
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
}
fn doc_revisions(&mut self, a: &Args) -> Result<Value> {
@@ -969,7 +1079,13 @@ impl Automation {
fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
Ok(summary(self.doc(a)?))
let doc = self.doc(a)?;
let mut out = summary(doc);
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
if !doc.edit_notices.is_empty() {
out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::<Vec<_>>());
}
Ok(out)
}
fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
@@ -1136,11 +1252,13 @@ impl Automation {
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
// The page font is Helvetica/WinAnsi: refuse what it would silently turn into `?`.
if let Some(c) = pdfcraft_engine::first_undrawable_plain(&text) {
return Err(ToolError::InvalidArgs(pdfcraft_engine::undrawable_message(c)));
}
(format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?)
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
// its line) by `pdfcraft_create::from_text`, as for every other caller.
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
e => failed(e),
})?;
(format!("{title}.pdf"), bytes)
}
other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))),
};
@@ -1255,7 +1373,7 @@ impl Automation {
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at })
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
}
/// Write freshly created bytes to `out` and/or open them as a new document.
@@ -1392,6 +1510,13 @@ impl Automation {
}
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
};
let field_conflicts = match a.opt_str("field_conflicts")? {
None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename,
Some("merge") => pdfcraft_organize::FieldConflicts::Merge,
Some(other) => {
return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}")));
}
};
let mut sources: Vec<CombineSource> = Vec::new();
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
@@ -1419,8 +1544,8 @@ impl Automation {
sources.push((name, bytes, range));
}
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let bytes = match groups {
None => self.session.combine_unlocked(&sources, &passwords),
let combined = match groups {
None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts),
Some(groups) => {
// A path without a group is a file of its own: give it a key no group uses.
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
@@ -1435,11 +1560,15 @@ impl Automation {
}
});
}
self.session.combine_grouped(&sources, &keys, &passwords)
self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts)
}
}
.map_err(failed)?;
self.deliver(a, "Combined", bytes)
let mut result = self.deliver(a, "Combined", combined.bytes)?;
if !combined.renamed_fields.is_empty() {
result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());
}
Ok(result)
}
fn doc_split(&mut self, a: &Args) -> Result<Value> {
@@ -1526,8 +1655,9 @@ impl Automation {
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let colors = self.session.reading_colors(id).unwrap_or_default();
let fresh = || {
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
};
let entry = self.renderers.entry(id).or_insert_with(fresh);
@@ -1544,7 +1674,16 @@ impl Automation {
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (dpi / 72.0) as f32, tag: 0 });
let scale = (dpi / 72.0) as f32;
let out = if let Some(block) = a.opt_int("hide_text_block")? {
let index = usize::try_from(block)
.ok()
.and_then(|n| n.checked_sub(1))
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
} else {
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
};
if let Some(e) = out.error {
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
}
@@ -1624,7 +1763,10 @@ impl Automation {
let texts = self.page_texts(id, &pages)?;
for (p, text) in pages.iter().zip(&texts) {
let skip_here = if *p == start { skip } else { 0 };
for (k, r) in text.find(query).into_iter().enumerate().skip(skip_here) {
// One extra match proves that the caller needs a continuation. Include skipped
// matches on the starting page when bounding the temporary per-page result.
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) {
if matches.len() == limit {
next = Some(json!({ "start_page": p + 1, "skip": k }));
break 'search;
@@ -1763,6 +1905,53 @@ fn foreign_share(path: &Path, root: &Path) -> bool {
// ---- JSON helpers ----------------------------------------------------------------------------
fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
let Some(value) = value else { return Ok(Vec::new()) };
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
values
.iter()
.map(|v| {
let a = Args(v);
let bounds = a
.get("range")
.and_then(Value::as_array)
.filter(|v| v.len() == 2)
.ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?;
let start = bounds
.first()
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
let end = bounds
.get(1)
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
let family = a
.opt_str("font")?
.map(|f| match f {
"helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica),
"times" => Ok(pdfcraft_engine::FontFamily::Times),
"courier" => Ok(pdfcraft_engine::FontFamily::Courier),
_ => Err(Args::wrong("font", "helvetica, times or courier")),
})
.transpose()?
.map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) })
.transpose()?;
Ok(pdfcraft_engine::RangeStyle {
range: start..end,
family,
resource: a.opt_str("font_resource")?.map(str::to_owned),
size: a.opt_num("size")?,
color: a.opt_str("color")?.map(comments::parse_color).transpose()?,
underline: a.opt_bool("underline")?,
char_spacing: a.opt_num("char_spacing")?,
scale: a.opt_num("scale")?,
})
})
.collect()
}
/// Typed access to validated arguments.
struct Args<'a>(&'a Value);
@@ -1913,6 +2102,9 @@ fn info(d: &Document) -> Value {
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })),
// A script of the form ran too long and was abandoned: its scripts no longer run in this
// document (calculations, buttons) until it is opened again.
"xfa_scripts_off": d.xfa_scripts_off(),
// What was rewritten from, or could not be written to, the XFA data.
"xfa_warnings": d.xfa_warnings,
"security": security,
@@ -1948,6 +2140,15 @@ fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
})).collect::<Vec<_>>(),
@@ -1994,7 +2195,7 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>>
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
let extract = |r: &mut PageRenderer, p: usize| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
match out.error {
Some(e) => Err(e),
None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()),
+83 -3
View File
@@ -1,8 +1,10 @@
//! Link tools: list, add, edit and delete links; create links from URLs in the text; remove
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle};
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, resolve_remote_document_path};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use serde_json::{Value, json};
use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
@@ -80,6 +82,66 @@ impl Automation {
Ok(json!({ "count": items.len(), "links": items }))
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
let page_number = a.int("page")?;
let index_number = a.int("index")?;
let page = usize::try_from(page_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("page must be 1 or more"))?)
.map_err(|_| bad("page is out of range"))?;
let index = usize::try_from(index_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("index must be 1 or more"))?)
.map_err(|_| bad("index is out of range"))?;
let (source_path, file, destination, new_window) = {
let source = self.doc(a)?;
if page >= source.info.pages.len() {
return Err(bad(format!("page {page_number} is out of range: the document has {} pages", source.info.pages.len())));
}
let link = source
.info
.links
.iter()
.find(|link| link.page == page && link.index == index)
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
return Err(bad("the selected link is not a supported local PDF GoToR link"));
};
(source.path.clone(), file.clone(), destination.clone(), *new_window)
};
let path = resolve_remote_document_path(source_path.as_deref().map(Path::new), &file).map_err(failed)?;
let path = self.resolve(&path.to_string_lossy(), false)?;
let bytes = read_remote_document(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
{
display_path = relative.to_string_lossy().into_owned();
}
Ok(json!({
"document": crate::summary(opened),
"path": display_path,
"destination_applied": destination_applied,
"destination": destination_value,
"warning": warning,
"new_window_hint": new_window,
}))
}
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
@@ -126,9 +188,27 @@ impl Automation {
}
pub(crate) fn links_remove(&mut self, a: &Args) -> Result<Value> {
let before = self.doc(a)?.links.len();
let mut out = self.apply(a, Edit::RemoveLinks { pages: None })?;
let pages = match a.opt_ints("pages")? {
Some(_) => Some(self.pages(a, "pages")?),
None => None,
};
let before = match &pages {
Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(),
None => self.doc(a)?.links.len(),
};
let mut out = self.apply(a, Edit::RemoveLinks { pages })?;
out["removed"] = json!(before);
Ok(out)
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
DestView::Xyz { left, top, zoom } => json!({ "kind": "xyz", "left": left, "top": top, "zoom": zoom }),
DestView::Fit => json!({ "kind": "fit" }),
DestView::FitH { top } => json!({ "kind": "fit_h", "top": top }),
DestView::FitV { left } => json!({ "kind": "fit_v", "left": left }),
DestView::FitR { rect } => json!({ "kind": "fit_r", "rect": rect }),
}
}
+13 -3
View File
@@ -12,7 +12,16 @@ fn bad(m: impl Into<String>) -> ToolError {
impl Automation {
pub(crate) fn printers(&self) -> Result<Value> {
let list: Vec<Value> = spool::printers().into_iter().map(|p| json!({ "name": p.name, "default": p.default })).collect();
let list: Vec<Value> = spool::printers()
.into_iter()
.map(|p| {
let mut v = json!({ "name": p.name, "default": p.default });
if let Some(two_sided) = p.two_sided {
v["two_sided"] = json!(two_sided);
}
v
})
.collect();
Ok(json!({ "count": list.len(), "printers": list }))
}
@@ -132,12 +141,13 @@ impl Automation {
printer: if printer == "default" { None } else { Some(printer.to_string()) },
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
collate: a.opt_bool("collate")?.unwrap_or(true),
duplex: match a.opt_str("duplex")?.unwrap_or("off") {
duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") {
"off" => spool::Duplex::Off,
"long-edge" => spool::Duplex::LongEdge,
"short-edge" => spool::Duplex::ShortEdge,
d => return Err(bad(format!("unknown duplex {d:?}"))),
},
}),
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
title: name,
options: match a.get("options") {
+36
View File
@@ -0,0 +1,36 @@
//! Reading colours: Acrobat's Replace Document Colors as a tool (#897, AGENTS.md §3).
//!
//! A per-document view setting: solid text and line-art paints remap for readability and the
//! page clears to the setting's background when rendering. The file itself never changes.
use pdfcraft_render::ReadingColors;
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
/// The modes `ReadingColors::parse` accepts, for error messages.
const MODES: &str = "off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom";
impl Automation {
/// Set the document's reading colours. Colours are only meaningful with `mode: "custom"`;
/// `parse` enforces the rest and its errors are actionable.
pub(crate) fn view_reading_colors(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let Some(mode) = a.opt_str("mode")? else {
return Err(ToolError::InvalidArgs(format!("mode is required (one of: {MODES})")));
};
let colors = ReadingColors::parse(mode, a.opt_str("text")?, a.opt_str("background")?, a.opt_bool("only_black")?, a.opt_bool("line_art")?)
.map_err(ToolError::InvalidArgs)?;
if self.session.set_reading_colors(id, colors) {
// Cached renderers baked the old setting into their config; page_render must re-read it.
self.renderers.remove(&id);
}
Ok(json!({
"mode": colors.mode_name(),
"text": colors.text_hex(),
"background": colors.background_hex(),
"only_black": colors.params.is_some_and(|p| p.only_black),
"line_art": colors.params.is_some_and(|p| p.line_art),
}))
}
}
+10 -1
View File
@@ -54,6 +54,11 @@ impl Automation {
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
@@ -95,7 +100,11 @@ impl Automation {
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles.iter().enumerate().flat_map(|(i, w)| text.find(w).into_iter().map(move |h| (Some(i), h))).collect(),
None => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
.collect(),
};
for (word, h) in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();
+8
View File
@@ -214,6 +214,14 @@ impl Automation {
}
if let Some(text) = o.get("text") {
let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?;
// Nobody sees a preview here: refuse what the script font and its fallback would leave out.
if let Some(c) = pdfcraft_engine::script_missing(text) {
return Err(bad(format!(
"the signature script font can't draw \"{}\" (U+{:04X}) in graphic.text; type it in Latin, Cyrillic or Greek letters, or pass graphic.image instead",
c.escape_debug(),
u32::from(c)
)));
}
return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| {
bad(format!(
"graphic.text {text:?} has nothing the script font can draw (at most {} characters)",
+119 -20
View File
@@ -149,7 +149,9 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])),
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
@@ -219,7 +221,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.")
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
.cmd("page.combine")
.with(schema(
json!({
@@ -227,6 +229,7 @@ pub fn tools() -> Vec<ToolDef> {
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
"field_conflicts": { "type": "string", "enum": ["rename", "merge"] },
"out": save_out,
"open": open,
}),
@@ -303,7 +306,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@@ -345,7 +348,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "path", "at"],
)),
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.")
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
t(
"form_set_props",
@@ -388,10 +391,10 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). what: all (default), comments, fields.")
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. Undoable.")
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
.destructive()
@@ -399,7 +402,7 @@ pub fn tools() -> Vec<ToolDef> {
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, words, pattern and whole_pages work on pages (default all). 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: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). 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!({
@@ -408,6 +411,8 @@ pub fn tools() -> Vec<ToolDef> {
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"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"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),
@@ -545,6 +550,11 @@ pub fn tools() -> Vec<ToolDef> {
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." } }),
&["doc", "page", "index"],
)),
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
.cmd("edit.link")
.with(schema(
@@ -571,10 +581,10 @@ pub fn tools() -> Vec<ToolDef> {
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.")
.cmd("edit.links_from_urls")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("links_remove", "Remove all links", "Remove every link in the document. Undoable.")
t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.")
.destructive()
.cmd("edit.remove_links")
.with(schema(json!({ "doc": doc() }), &["doc"])),
.with(schema(json!({ "doc": doc(), "pages": pages("whose links to remove (default: all)") }), &["doc"])),
t("measure_distance", "Measure distance", "Add an undoable two-point distance annotation using the scale of the first point's viewport.")
.cmd("measure.distance").with(measure_schema()),
t("measure_perimeter", "Measure perimeter", "Add an undoable connected-line length annotation. To include a closing edge, repeat the first point at the end.")
@@ -600,7 +610,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.",
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@@ -709,6 +719,19 @@ pub fn tools() -> Vec<ToolDef> {
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
.cmd("comment.hide_all")
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
t("view_reading_colors", "Set reading colours", "Set the document's reading colours (Acrobat's Replace Document Colors, in the Reading Options): solid text and line-art paints are remapped for readability and the page clears to the background colour in page_render. A view setting: the file doesn't change. mode is one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, or custom (which takes text and background as #RRGGBB). only_black maps only near-black paints (default false) and line_art also maps line-art paints (default true); both override the mode's preset. A flag that differs from the mode's preset default makes the returned mode read custom, because the resulting params no longer match any preset. Returns the resulting setting.")
.cmd("a11y.reading_options")
.with(schema(
json!({
"doc": doc(),
"mode": { "type": "string", "enum": ["off", "black-on-white", "white-on-black", "yellow-on-black", "green-on-black", "custom"] },
"text": { "type": "string", "description": "With mode custom: the text colour, #RRGGBB." },
"background": { "type": "string", "description": "With mode custom: the background colour, #RRGGBB." },
"only_black": { "type": "boolean", "description": "Map only near-black paints. Default false." },
"line_art": { "type": "boolean", "description": "Also map line-art paints. Default true." },
}),
&["doc", "mode"],
)),
t("comments_summarize", "Summarize comments", "Make a PDF summarising every comment (number, author, type, date, text, replies), sorted by page, author, date or type.")
.cmd("comment.summarize")
.with(schema(
@@ -839,7 +862,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
t("accessibility_autotag_preview", "Preview automatic tags", "Propose paragraph and heading tags for visible text blocks on one page. Returns page bounds, tentative roles and reasons. Does not change the PDF or claim accessibility compliance. Images, tables, lists, annotations and existing structure require separate review.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
.with(schema(
@@ -866,6 +892,34 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "path"],
)),
t("accessibility_review_record", "Accessibility review record", "Compare the same accessibility rules on two open versions of a PDF and write a JSON record to path. Includes document SHA-256 hashes, before/after rule statuses and finding counts, changed rules, and rules still needing review. Does not change either PDF or claim accessibility compliance. Open the original and edited copies as separate documents.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_review_save", "Save accessibility review decisions", "Write a local JSON sidecar for an open PDF with rule-level human review decisions. The sidecar records the PDF SHA-256; replacing one tied to different bytes requires replace: true. Does not change the PDF or assert compliance.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "decisions": { "type": "array", "items": { "type": "object", "properties": { "rule": { "type": "string" }, "decision": { "type": "string", "enum": ["accepted", "needs_work", "skipped"] }, "page": { "type": "integer", "minimum": 1 }, "note": { "type": "string" } }, "required": ["rule", "decision"], "additionalProperties": false } }, "replace": { "type": "boolean" } }), &["doc", "path", "decisions"])),
t("accessibility_review_load", "Open accessibility review decisions", "Read a local review sidecar only when its recorded SHA-256 matches the open PDF. Returns the decisions without changing the PDF.")
.ro()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("accessibility_review_html", "Accessibility review summary", "Compare two open versions of a PDF and write a self-contained HTML summary of the same evidence as accessibility_review_record: document hashes, outcome counts, rule changes, and rules still requiring review. Does not change either PDF or claim accessibility compliance.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.")
.with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])),
t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).")
@@ -875,6 +929,25 @@ pub fn tools() -> Vec<ToolDef> {
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"anchor": { "type": "integer", "minimum": 1 },
"anchor_generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"placement": { "type": "string", "enum": ["before", "after"] },
}), &["doc", "object", "generation", "anchor", "anchor_generation", "placement"])),
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
@@ -897,13 +970,16 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
.ro()
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["doc"])),
t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
@@ -951,7 +1027,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
.cmd("ocr.recognize")
.with(schema(
json!({
@@ -959,11 +1035,11 @@ pub fn tools() -> Vec<ToolDef> {
"pages": pages("to recognise (default: all)"),
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution pages are read at (default 300; a scanned page is read at most at its own resolution)." },
"language": { "type": "string", "enum": ["en"], "description": "Document language (default en)." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true). false also reads pictures on such pages, without adding their text again." },
}),
&["doc"],
)),
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone). Returns, per file, the output path, word count and skipped pages, or the error.")
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Returns, per file, the output path, word count and skipped pages, or the error.")
.cmd("ocr.recognize_batch")
.with(schema(
json!({
@@ -971,6 +1047,7 @@ pub fn tools() -> Vec<ToolDef> {
"folder": { "type": "string" },
"dpi": { "type": "number", "minimum": 72, "maximum": 600 },
"language": { "type": "string", "enum": ["en"] },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true); see ocr_recognize." },
}),
&["paths", "folder"],
)),
@@ -995,7 +1072,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1005,7 +1082,10 @@ pub fn tools() -> Vec<ToolDef> {
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
"format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." },
"language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." },
"author": { "type": "string" },
@@ -1063,11 +1143,15 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
t("image_hit", "Hit test page artwork", "List image/Form numbers whose placement and all clipping paths contain point [x,y] in top-left-origin points. Bezier curves, even-odd/nonzero fill and holes are respected. Read only.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}), &["doc","page","point"])),
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, full placement rect and visible_rect after page/path clipping (top-left-origin points; null if fully hidden), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
@@ -1085,15 +1169,30 @@ pub fn tools() -> Vec<ToolDef> {
.destructive()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
t("text_font_files", "List installed document fonts", "Discover openly licensed, embeddable TrueType/OpenType files on request. Optional directories (at most 16) restrict the scan; with --root, only that root is scanned by default and supplied folders must stay inside it. The scan skips symlinks, collections and invalid/licence-restricted fonts, with depth, entry and byte budgets. Select a returned path with text_font_import.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"directories":{"type":"array","items":{"type":"string"},"maxItems":16}}), &[])),
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
t("text_edit_layout", "Measure text draft", "Measure a paragraph draft using the same PDF rewrite as Apply, without changing the document. Returns source_rect, draft_rect (top-left-origin points), overflow, and the embedded sfnt source faces available to the inline editor. Text defaults to the unchanged paragraph. Optional size, width, font_resource and disjoint UTF-8 ranges preview formatting changes.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
"paragraph": { "type": "integer", "minimum": 1 },
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
"text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
"bold": { "type": "boolean" },
File diff suppressed because it is too large Load Diff
+38 -15
View File
@@ -125,12 +125,19 @@ pub fn parse(data: &[u8]) -> Parsed {
start.get_or_insert(at);
operands.push(Object::Null);
}
b"BI" => {
let (dict, img, end) = inline_image(data, lx.pos);
lx.pos = end;
out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
operands.clear();
}
b"BI" => match inline_image(data, lx.pos) {
Ok((dict, img, end)) => {
lx.pos = end;
out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
operands.clear();
}
Err(end) => {
out.skipped += end.saturating_sub(at);
lx.pos = end;
start = None;
operands.clear();
}
},
_ => {
let s = start.take().unwrap_or(at);
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
@@ -141,8 +148,9 @@ pub fn parse(data: &[u8]) -> Parsed {
out
}
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`).
fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec<u8>, usize) {
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
let mut lx = Lexer::new(data, pos);
let mut dict = Dict::new();
loop {
@@ -150,17 +158,32 @@ fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec<u8>, usize) {
if lx.eat_keyword(b"ID") {
break;
}
let Ok(Object::Name(k)) = lx.object() else {
return (dict, Vec::new(), data.len());
let at = lx.pos;
let entry = match lx.object() {
Ok(Object::Name(k)) => lx.object().ok().map(|v| (k, v)),
_ => None,
};
let Ok(v) = lx.object() else {
return (dict, Vec::new(), data.len());
let Some((k, v)) = entry else {
// A stream that ends inside the parameters is cut short, not unreadable: keep what was read.
if at >= data.len() {
return Ok((dict, Vec::new(), data.len()));
}
// Without an `EI` after it, the image runs to the end of the stream.
return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end));
};
dict.set(k, v);
}
// One whitespace byte separates ID from the data.
let begin = (lx.pos + 1).min(data.len());
// The data ends at whitespace + `EI` + (whitespace or end). Search for that pattern.
Ok(match end_of_image(data, begin) {
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
None => (dict, data[begin..].to_vec(), data.len()),
})
}
/// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The
/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end).
fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
let mut i = begin;
while i + 2 <= data.len() {
if data[i] == b'E'
@@ -169,11 +192,11 @@ fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec<u8>, usize) {
&& data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))
{
let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i };
return (dict, data[begin..end_data].to_vec(), i + 2);
return Some((end_data, i + 2));
}
i += 1;
}
(dict, data[begin..].to_vec(), data.len())
None
}
/// Write one operator (with a trailing newline).
+30
View File
@@ -48,6 +48,36 @@ fn damage_is_skipped_not_fatal() {
assert!(parse(b"BI /W 1").ops.len() == 1);
}
/// Issue #1318: an inline image whose dictionary can't be read is skipped up to its `EI`, counted,
/// and what follows it is parsed.
#[test]
fn an_unreadable_inline_image_is_skipped_to_its_end() {
let src = b"q BI /W 4 H 4 ID aa EI Q BT (After) Tj ET";
let p = parse(src);
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..], b"Q", b"BT", b"Tj", b"ET"]);
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa EI".len());
let control = parse(b"q BI /W 4 /H 4 ID aa EI Q BT (After) Tj ET");
assert_eq!(control.skipped, 0);
assert_eq!(control.ops.len(), 6);
}
/// Issue #1318, follow-up: without an `EI`, an unreadable inline image runs to the end of the
/// stream, and those bytes are counted. A readable one whose data is cut short is kept, data and
/// all, so nothing of it is skipped.
#[test]
fn an_unreadable_inline_image_without_ei_is_counted() {
let p = parse(b"q BI /W 4 H 4 ID aa");
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..]]);
assert_eq!(p.skipped, b"BI /W 4 H 4 ID aa".len());
let control = parse(b"q BI /W 4 /H 4 ID aa");
assert_eq!(control.skipped, 0);
let names: Vec<&[u8]> = control.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..], b"BI"]);
assert_eq!(control.ops[1].inline.as_ref().map(|(_, data)| data.as_slice()), Some(&b"aa"[..]));
}
#[test]
fn matrices_compose_and_invert() {
let a = Matrix([2.0, 0.0, 0.0, 3.0, 10.0, 20.0]);
+4 -2
View File
@@ -24,8 +24,10 @@ mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, parse_indirect};
pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, pdfdoc_byte, pdfdoc_char};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
+5
View File
@@ -7,6 +7,11 @@ pub const MAX_LABEL_BYTES: usize = 1024;
pub const MAX_PREFIX_BYTES: usize = 2 * MAX_LABEL_BYTES + 2;
/// Sum of the UTF-8 bytes of custom labels for one document.
pub const MAX_LABEL_TOTAL_BYTES: usize = 4 << 20;
/// Sum of the UTF-8 bytes of the decoded prefixes of one `/PageLabels` tree. Each range decodes
/// its own prefix, and ranges may share one prefix object, so without this a small file could ask
/// for a copy per range: [`MAX_LABEL_TREE_WORK`] ranges naming one 1,024-byte prefix would hold
/// about 200 MiB. Real documents stay far below it.
pub const MAX_PREFIX_TOTAL_BYTES: usize = 4 << 20;
/// The most number-tree entries (nodes, kids and range pairs) read from one `/PageLabels`
/// tree. Scaled for real documents: one range per page on a ten-thousand-page PDF happens
/// (prefix labels; #307's file has 9,156 pages), so the cap is twenty times that while still
+2 -1
View File
@@ -11,7 +11,8 @@ use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
pub(crate) fn is_whitespace(b: u8) -> bool {
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
pub fn is_whitespace(b: u8) -> bool {
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ')
}
+9
View File
@@ -431,6 +431,15 @@ fn write_xref_table(out: &mut Vec<u8>, offsets: &BTreeMap<u32, (u64, u16, bool)>
if include_zero {
all.insert(0, (0, (0, 65535, false)));
}
// A section is one or more subsections. `xref` followed immediately by `trailer` is not a
// table (qpdf: "xref syntax invalid") and is what an incremental save of a classic-table
// file used to append when nothing was edited. A zero-length subsection names no objects,
// so the free list and every in-use entry stay as they were, while a trailer-only change
// (a missing `/ID`, or a caller edit of the trailer) is still written.
if all.is_empty() {
let _ = writeln!(out, "0 0");
return;
}
let mut i = 0;
while i < all.len() {
let start = all[i].0;
+110
View File
@@ -135,6 +135,116 @@ fn full_saves_are_allocated_at_their_exact_size() {
}
}
/// A one-page classic-table file with no `/ID`, the case that used to append `xref` and then
/// `trailer` with no subsection (#1339).
fn classic_table() -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
fn qpdf_check(bytes: &[u8]) -> Option<std::process::Output> {
let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else {
return None;
};
assert!(version.status.success());
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap();
let _ = std::fs::remove_file(&path);
Some(check)
}
fn page(doc: &Document) -> Dict {
let root = doc.resolve(doc.trailer().get(b"Root").unwrap());
let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap());
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
doc.resolve(kids.first().unwrap()).as_dict().cloned().unwrap()
}
fn assert_qpdf_clean(bytes: &[u8]) {
let Some(check) = qpdf_check(bytes) else {
eprintln!("qpdf not installed: skipped");
return;
};
let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr));
assert!(check.status.success() && !report.contains("WARNING"), "{report}");
}
#[test]
fn unchanged_incremental_save_of_a_classic_table_is_a_valid_xref() {
let input = classic_table();
let doc = Document::open(Arc::new(input.clone())).unwrap();
assert!(!doc.is_modified());
assert!(doc.repair_log().is_empty());
assert_eq!(doc.revisions().len(), 1);
assert!(!doc.revisions()[0].is_stream);
let unchanged = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(unchanged.starts_with(&input));
let appended = &unchanged[input.len()..];
assert!(appended.windows(8).any(|w| w == b"xref\n0 0"), "a section with no entries still needs a subsection");
assert!(!appended.windows(13).any(|w| w == b"xref\ntrailer"));
let back = Document::open(Arc::new(unchanged.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_eq!(back.revisions().len(), 2);
assert_eq!(page(&back).name(b"Type"), Some(b"Page".as_slice()));
assert!(back.trailer().get(b"ID").is_some(), "a file without an identifier still receives one");
assert_eq!(hayro_pages(&unchanged), 1);
assert_qpdf_clean(&unchanged);
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(3, 0), |d| d.set(b"Rotate".to_vec(), Object::Int(90))).unwrap();
let edited = write_incremental(&edited, &SaveOptions::default()).unwrap();
let edited_back = Document::open(Arc::new(edited.clone())).unwrap();
assert!(edited_back.repair_log().is_empty(), "{:?}", edited_back.repair_log());
assert_eq!(edited_back.get(ObjRef::new(3, 0)).as_dict().unwrap().int(b"Rotate"), Some(90));
assert_qpdf_clean(&edited);
let stamped = write_incremental(&doc, &SaveOptions { mod_date: Some("D:20260101000000Z".into()), ..SaveOptions::default() }).unwrap();
let stamped_back = Document::open(Arc::new(stamped.clone())).unwrap();
assert!(stamped_back.repair_log().is_empty(), "{:?}", stamped_back.repair_log());
let info = stamped_back.resolve(stamped_back.trailer().get(b"Info").unwrap());
assert_eq!(
info.as_dict().unwrap().get(b"ModDate").and_then(|d| d.as_string()).map(|s| s.bytes.as_slice()),
Some(b"D:20260101000000Z".as_slice())
);
assert_qpdf_clean(&stamped);
}
#[test]
fn incremental_save_keeps_a_trailer_only_edit() {
let input = classic_table();
let mut doc = Document::open(Arc::new(input)).unwrap();
assert!(!doc.is_modified());
doc.trailer_mut().set(b"Custom".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"keep me".to_vec())));
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_eq!(back.trailer().get(b"Custom").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"keep me".as_slice()));
assert_eq!(hayro_pages(&saved), 1);
assert_qpdf_clean(&saved);
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {
+5 -1
View File
@@ -16,6 +16,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
`from_images`. The largest page side is still capped at 14,400 points;
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated.
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica
can't draw is refused, naming the line and the character, never set as `?`.
- `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents
(comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first
line says so (`ESCAPED_NOTE`).
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
+43 -1
View File
@@ -618,8 +618,50 @@ fn turkish_code_page(bytes: &[u8]) -> bool {
&& !bytes.iter().any(|b| matches!(b, 0xC1 | 0xCD | 0xD3 | 0xDA | 0xE1 | 0xED | 0xF3 | 0xFA))
}
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins.
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't
/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and
/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
// Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`.
if let Some((line, c)) = first_undrawable_line(text) {
return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c)));
}
set_text(title, text, page, font_size)
}
/// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and
/// form feeds (new pages) are layout, not drawn.
fn first_undrawable_line(text: &str) -> Option<(usize, char)> {
text.split('\n').enumerate().find_map(|(i, l)| pdfcraft_fonts::first_undrawable(&l.replace('\u{c}', "")).map(|c| (i + 1, c)))
}
/// What a PDF made from text PdfCraft generated says, in its first line, about characters it
/// wrote as `[U+XXXX]`.
pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points.";
/// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare
/// report), which may quote any language. Each character Helvetica can't draw is written as
/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says
/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way.
pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> {
let mut escaped = 0usize;
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '\n' | '\r' | '\u{c}') || pdfcraft_fonts::first_undrawable(c.encode_utf8(&mut [0; 4])).is_none() {
out.push(c);
} else {
escaped = escaped.saturating_add(1);
out.push_str(&format!("[U+{:04X}]", u32::from(c)));
}
}
if escaped > 0 {
out = format!("{ESCAPED_NOTE}\n\n{out}");
}
// The title is a text string in /Info, which holds any character.
Ok((set_text(title, &out, page, font_size)?, escaped))
}
fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
let (w, h) = page;
let margin = 72.0;
+26
View File
@@ -124,6 +124,32 @@ fn text_is_wrapped_and_paginated() {
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
}
/// Text Helvetica can't draw is refused with its line and character, never set as `?`; text
/// PdfCraft generates writes such characters as [U+XXXX] and says so.
#[test]
fn text_helvetica_cannot_draw_is_refused_or_escaped() {
let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err();
let CreateError::Invalid(msg) = &err else { panic!("{err:?}") };
assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}");
// Western European text, tabs, CRLF and form feeds included, still converts.
from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap();
let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap();
assert_eq!(escaped, 2);
let doc = reopen(&doc);
let p = pages(&doc);
let c = doc.resolve(p[0].get(b"Contents").unwrap());
let Object::Stream(s) = &*c else { panic!() };
let shown = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(shown.contains("(Comment: [U+7B7E][U+540D] ok) Tj") && shown.contains(&format!("({ESCAPED_NOTE}) Tj")), "{shown}");
assert!(!shown.contains('?'), "{shown}");
// Nothing to escape: no note.
let (doc, escaped) = from_text_escaped("r", "Plain report", LETTER, 11.0).unwrap();
assert_eq!(escaped, 0);
let p = pages(&doc);
let Object::Stream(s) = &*doc.resolve(p[0].get(b"Contents").unwrap()) else { panic!() };
assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX"));
}
fn image_of(doc: &Document, page: usize) -> Dict {
let p = &pages(doc)[page];
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
+29 -4
View File
@@ -539,6 +539,32 @@ pub enum Algorithm {
Aes256,
}
/// A password as [`create`] prepares it: for R2–R4 its PDFDocEncoding (characters it lacks
/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed,
/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8,
/// at most 127 bytes.
fn prepared_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password),
Algorithm::Aes256 => sasl_password(password),
}
}
/// The bytes new encryption with `algorithm` derives its keys from for `password`: for R2–R4 its
/// PDFDocEncoding cut or padded to exactly 32 bytes (Algorithm 2); for R6 its SASLprep (the
/// password as given if SASLprep refuses it), at most 127 bytes of UTF-8. Two passwords with the
/// same bytes are the same password to the file, and a password with the bytes of `""` opens it
/// without asking, so callers can refuse either before encrypting. Different bytes can still give
/// the same key (RC4-40 keeps 5 bytes of a hash). `create` still uses the user password for an
/// empty R2–R4 owner password.
pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
let prepared = prepared_password(algorithm, password);
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(),
Algorithm::Aes256 => prepared,
}
}
/// Parameters for new encryption.
#[derive(Clone, Debug)]
pub struct NewEncryption<'a> {
@@ -573,6 +599,8 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
// Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2).
let p = (params.permissions | !0xFFF | 0xC0) & !0x3;
let mut d = EncryptDict { filter: b"Standard".to_vec(), p, encrypt_metadata: params.encrypt_metadata, ..Default::default() };
let user = prepared_password(params.algorithm, params.user_password);
let owner = prepared_password(params.algorithm, params.owner_password);
match params.algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => {
(d.v, d.r, d.length_bits) = match params.algorithm {
@@ -587,8 +615,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
} else {
d.encrypt_metadata = true; // V < 4 always encrypts metadata
}
let user = legacy_password(params.user_password);
let owner = if params.owner_password.is_empty() { user.clone() } else { legacy_password(params.owner_password) };
let owner = if params.owner_password.is_empty() { user.clone() } else { owner };
// Algorithm 3: /O
let rk = owner_rc4_key(&d, &owner);
let mut o = rc4(&rk, &pad(&user));
@@ -610,8 +637,6 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
d.str_f = b"StdCF".to_vec();
let key = expand(&params.seed, b"file key", 32);
let salts = expand(&params.seed, b"salts", 32);
let user = sasl_password(params.user_password);
let owner = sasl_password(params.owner_password);
// Algorithm 8: /U, /UE
let mut u = hash_r6(6, &user, &salts[0..8], &[]);
u.extend_from_slice(&salts[0..16]);
+47
View File
@@ -145,6 +145,53 @@ fn r6_passwords_are_saslprepped() {
assert!(SecurityHandler::open(d, ID, Some("SaSL-prep")).is_err());
}
#[test]
fn effective_passwords_are_the_bytes_the_keys_are_made_from() {
// AES-256: SASLprep drops soft hyphens, makes other spaces plain and folds compatibility
// characters (NFKC); at most 127 bytes of UTF-8.
let r6 = |pw: &str| effective_password(Algorithm::Aes256, pw);
assert_eq!(r6("\u{AD}"), r6(""), "a soft hyphen alone is no password");
assert_eq!(r6("\u{FF21}"), r6("A"), "fullwidth A");
assert_eq!(r6("\u{FB01}"), r6("fi"), "the fi ligature");
assert_eq!(r6("a\u{3000}b"), b"a b", "an ideographic space");
assert_ne!(r6("A"), r6("a"), "case matters");
assert_eq!(r6(&"é".repeat(100)).len(), 127);
// RC4 and AES-128: PDFDocEncoding, padded or cut to exactly 32 bytes.
let (a31, a32) = ("a".repeat(31), "a".repeat(32));
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
assert_eq!(effective_password(alg, &format!("{a32}1")), a32.as_bytes(), "{alg:?}");
assert_eq!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("1{a32}2")), "{alg:?}");
assert_ne!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("2{a32}")), "{alg:?}");
assert_eq!(effective_password(alg, &a31), effective_password(alg, &format!("{a31}(")), "{alg:?}: the padding starts with '('");
assert_eq!(effective_password(alg, ""), PADDING, "{alg:?}");
assert_eq!(effective_password(alg, "ılık€")[..5], [0x9A, b'l', 0x9A, b'k', 0xA0], "{alg:?}");
}
}
#[test]
fn passwords_with_the_same_effective_bytes_are_one_password() {
// A file protected with "\u{FF21}" (fullwidth A) opens with "A".
let d = new(Algorithm::Aes256, "\u{FF21}", "owner", 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d.clone(), ID, Some("A")).unwrap().auth(), Auth::User);
assert_eq!(SecurityHandler::open(d, ID, Some("a")).unwrap_err(), CryptError::WrongPassword);
// A soft hyphen is no open password at all.
let d = new(Algorithm::Aes256, "\u{AD}", "owner", 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, None).unwrap().auth(), Auth::User);
// Open and permissions passwords with the same bytes: the open password gives owner rights.
let d = new(Algorithm::Aes256, "\u{FB01}", "fi", 0b0100).dict().clone();
assert_eq!(effective_password(Algorithm::Aes256, "\u{FB01}"), effective_password(Algorithm::Aes256, "fi"));
assert_eq!(SecurityHandler::open(d, ID, Some("\u{FB01}")).unwrap().auth(), Auth::Owner);
let a31 = "a".repeat(31);
let pairs = [(format!("{a31}a1"), format!("{a31}a2")), (a31.clone(), format!("{a31}("))];
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
for (open, perms) in &pairs {
assert_eq!(effective_password(alg, open), effective_password(alg, perms), "{alg:?}");
let d = new(alg, open, perms, 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, Some(open)).unwrap().auth(), Auth::Owner, "{alg:?} {open:?}");
}
}
}
#[test]
fn unsupported_handlers_are_reported() {
let mut d = new(Algorithm::Aes128, "", "o", -1).dict().clone();
+3 -1
View File
@@ -15,7 +15,9 @@ pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
# Display order of right-to-left lines in added text; already in the tree via the interface.
kurbo = { workspace = true }
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
unicode-bidi = "0.3"
[dev-dependencies]
+168 -58
View File
@@ -11,7 +11,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
@@ -65,15 +65,30 @@ impl Family {
(family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique"))
}
/// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths,
/// scaled for Times; Courier is monospaced).
pub fn width(self, s: &str, size: f64, bold: bool) -> f64 {
let w = match self {
Family::Courier => s.chars().count() as f64 * 0.6 * size,
Family::Times => helvetica_width(s, size) * 0.9,
Family::Helvetica => helvetica_width(s, size),
};
if bold { w * 1.05 } else { w }
/// Advance width of `s` at `size` points, from the published standard-14 metrics.
///
/// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]),
/// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722).
pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 {
self.std14(bold, italic).text_width(s, size)
}
/// The standard-14 face this family means at the given style.
pub fn std14(self, bold: bool, italic: bool) -> Std14 {
match (self, bold, italic) {
(Family::Courier, false, false) => Std14::Courier,
(Family::Courier, true, false) => Std14::CourierBold,
(Family::Courier, false, true) => Std14::CourierOblique,
(Family::Courier, true, true) => Std14::CourierBoldOblique,
(Family::Times, false, false) => Std14::TimesRoman,
(Family::Times, true, false) => Std14::TimesBold,
(Family::Times, false, true) => Std14::TimesItalic,
(Family::Times, true, true) => Std14::TimesBoldItalic,
(Family::Helvetica, false, false) => Std14::Helvetica,
(Family::Helvetica, true, false) => Std14::HelveticaBold,
(Family::Helvetica, false, true) => Std14::HelveticaOblique,
(Family::Helvetica, true, true) => Std14::HelveticaBoldOblique,
}
}
}
@@ -225,17 +240,26 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let mut out = Vec::new();
for para in t.text.split('\n') {
let arabic = para.chars().any(is_arabic);
let thai = para.chars().any(is_thai);
if thai
&& !arabic
&& let Ok(lines) = thai_lines(t, para, width)
{
out.extend(lines.into_iter().map(|line| (line, false)));
continue;
}
let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if arabic { arabic_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
let candidate_w = if arabic { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
let candidate_w =
if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
line = word.to_string();
@@ -250,6 +274,39 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
out
}
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
let (pieces, _) = shaped_layout(t, para, false)?;
let mut atoms = Vec::new();
for piece in pieces {
match piece {
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = t.family.width(&text, t.size, t.bold, t.italic);
(text, w)
})),
}
}
let mut lines = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
for (text, advance) in atoms {
if !line.is_empty() && used + advance > width {
lines.push(std::mem::take(&mut line));
used = 0.0;
}
line.push_str(&text);
used += advance;
}
lines.push(line);
Ok(lines)
}
fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
fn is_arabic(c: char) -> bool {
@@ -260,6 +317,13 @@ fn is_arabic(c: char) -> bool {
/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no
/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open.
pub fn first_undrawable(t: &AddedText) -> Option<char> {
if t.text.chars().any(is_thai) {
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
return Some(c);
}
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
return first_undrawable(&rest);
}
// Arabic that nothing can draw: there's no face, or the face lacks it.
if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) {
return Some(c);
@@ -269,12 +333,12 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
// that is also how `draw` redraws an existing item, so it's checked that way.
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `arabic_layout` leaves to the standard font, exactly as `draw_arabic` splits it.
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
let (pieces, _) = arabic_layout(t, &line, rtl).ok()?;
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
Piece::Arabic(_) => None,
Piece::Shaped(_) => None,
})
})
}
@@ -305,16 +369,16 @@ fn mirrored(c: char) -> char {
}
}
/// A piece of a line with Arabic text, in drawing order.
/// A piece of a line with shaped text, in drawing order.
enum Piece {
/// Shown in the item's own standard font.
Latin(String),
Arabic(Vec<ShapedCluster>),
Shaped(Vec<ShapedCluster>),
}
/// A line of an Arabic item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// its width at the item's size. `rtl` is its paragraph's direction.
fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
// unicode-bidi indexes the first level of a line, so an empty one would panic.
if line.is_empty() {
return Ok((Vec::new(), 0.0));
@@ -329,20 +393,30 @@ fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let Some(text) = line.get(run) else { continue };
// Arabic letters take the Arabic face; so do spaces and punctuation between them, which the
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
let mut segments: Vec<(bool, String)> = Vec::new();
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
let arabic = is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c));
let script = if is_thai(c) {
Some(ShapedScript::Thai)
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
Some(ShapedScript::Arabic)
} else {
None
};
match segments.last_mut() {
Some((kind, s)) if *kind == arabic => s.push(c),
_ => segments.push((arabic, c.to_string())),
Some((kind, s)) if *kind == script => s.push(c),
_ => segments.push((script, c.to_string())),
}
}
if run_rtl {
segments.reverse();
}
for (arabic, s) in segments {
pieces.push(if arabic {
Piece::Arabic(shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?)
for (script, s) in segments {
pieces.push(if let Some(script) = script {
let shaped = match script {
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
};
Piece::Shaped(shaped)
} else if run_rtl {
Piece::Latin(s.chars().rev().map(mirrored).collect())
} else {
@@ -353,17 +427,17 @@ fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold),
Piece::Arabic(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic),
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
})
.sum();
Ok((pieces, width))
}
/// The width of `s` in an Arabic item; the item's font's estimate if it can't be shaped (drawing
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn arabic_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
arabic_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
@@ -376,6 +450,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError {
})
}
fn thai_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => {
"Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into()
}
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(),
})
}
/// The box a text item occupies (height from its lines).
pub fn text_rect(t: &AddedText) -> [f64; 4] {
let r = norm(t.rect);
@@ -391,31 +475,31 @@ fn font_name(base: &str) -> String {
/// per 240 distinct glyphs it needs.
const GLYPHS_PER_FONT: usize = 240;
/// A line of an Arabic item after layout: its text, whether its paragraph runs right to left, its
/// pieces in drawing order and its width at the item's size.
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its pieces in drawing order and its width at the item's size.
type LaidLine = (String, bool, Vec<Piece>, f64);
/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic glyphs
/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
fn draw_arabic(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
.map(|(line, rtl)| arabic_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.collect::<Result<Vec<_>, _>>()?;
draw_laid_arabic(doc, t, &laid, taken, fonts, out, arabic_glyph)
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
/// [`draw_arabic`] for lines that are already laid out. `outline` is the source of glyph outlines
/// (the craft-fonts face, or a stand-in in tests).
fn draw_laid_arabic(
/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines
/// for a script's face (the craft-fonts faces, or a stand-in in tests).
fn draw_laid_shaped(
doc: &mut Document,
t: &AddedText,
laid: &[LaidLine],
taken: &Dict,
fonts: &mut Dict,
out: &mut Vec<u8>,
outline: impl Fn(u32) -> Result<GlyphOutline, GlyphError>,
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
) -> Result<(), EditError> {
let r = text_rect(t);
let latin = font_name(t.family.base_font(t.bold, t.italic));
@@ -424,7 +508,7 @@ fn draw_laid_arabic(
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Arabic(clusters) => clusters.as_slice(),
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
index.entry(cluster_key(c)).or_insert_with(|| {
@@ -432,12 +516,16 @@ fn draw_laid_arabic(
keys.len() - 1
});
}
// Every outline is read before anything is written, so a failure leaves the document as it was.
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if !outlines.contains_key(id) {
outlines.insert(*id, outline(*id).map_err(|e| arabic_error(e, &c.text))?);
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?);
}
}
}
@@ -445,7 +533,7 @@ fn draw_laid_arabic(
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
let mut names = Vec::new();
for chunk in keys.chunks(GLYPHS_PER_FONT) {
let font = type3_arabic(doc, chunk, &outlines);
let font = type3_shaped(doc, chunk, &outlines);
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut name = format!("PCAr{}", font.num);
@@ -461,6 +549,10 @@ fn draw_laid_arabic(
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
@@ -481,9 +573,9 @@ fn draw_laid_arabic(
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64;
}
Piece::Arabic(clusters) => {
Piece::Shaped(clusters) => {
for c in clusters {
let Some(&k) = index.get(&cluster_key(c)) else { continue };
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
@@ -501,6 +593,7 @@ fn draw_laid_arabic(
}
}
}
out.extend_from_slice(b"EMC\n");
}
out.extend_from_slice(b"ET\n");
Ok(())
@@ -508,14 +601,14 @@ fn draw_laid_arabic(
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey<'a> = (&'a str, Vec<(u32, u64, u64)>, u64);
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
(c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from the Arabic face's glyph `outlines`.
fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<u32, GlyphOutline>) -> ObjRef {
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
@@ -527,7 +620,7 @@ fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(id) else { continue };
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
@@ -564,7 +657,18 @@ fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |f| f.family);
// The face's family when every glyph comes from one script; without the face, the script's
// name (as Arabic items were always named).
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
Some(script) => script.font().map_or(
match script {
ShapedScript::Arabic => "Arabic",
ShapedScript::Thai => "Thai",
},
|f| f.family,
),
None => "PdfCraft Shaped Text",
};
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
@@ -611,8 +715,8 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
}
let mut fonts = Dict::new();
fonts.set(name.clone().into_bytes(), Object::Dict(font));
if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) {
draw_arabic(doc, t, taken, &mut fonts, &mut out)?;
if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) {
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
out.extend_from_slice(b"Q\n");
return Ok((out, res));
@@ -622,7 +726,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
for (i, line) in lines(t).iter().enumerate() {
let w = t.family.width(line, t.size, t.bold);
let w = t.family.width(line, t.size, t.bold, t.italic);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
@@ -840,11 +944,17 @@ fn validate(c: &Content) -> Result<(), EditError> {
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
return Err(thai_error(GlyphError::NoFont, &t.text));
}
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
// item is refused with what's missing, and one that already holds Arabic stays movable.
// Everything else in it is drawn in the standard font, so it still has to fit.
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
t.text.split('\n').find_map(|line| line.chars().find(|c| !shaped_arabic(*c) && !pdfcraft_fonts::win_ansi_encodable(*c)))
t.text.split('\n').find_map(|line| {
line.chars()
.find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))
})
} else {
first_undrawable(t)
};
@@ -963,7 +1073,7 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(
Ok(())
}
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_arabic`
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
/// names them so), so they go when the item is replaced or deleted.
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
+74 -21
View File
@@ -6,20 +6,25 @@ use std::cell::Cell;
use super::*;
/// The fingerprint of an item below the old limit, from the code before the change: its content
/// stream, its new fonts and every object of the document.
const SMALL_ITEM_FINGERPRINT: u64 = 14_135_892_979_695_233_112;
/// Regression fingerprint for the small-item fixture: drawing bytes, font map and document
/// objects, with generated streams decoded so Flate backend selection cannot change the hash.
// Re-pinned when the standard-14 metrics became exact (F1). The Latin run in the fingerprinted
// item is "Hi " in Helvetica 12pt: the old character-class guess called H 680, i 260 and space
// 260 (14.4pt), where the published metrics are 722, 222 and 278 (14.664pt). The Arabic clusters
// that follow therefore start 0.264pt further right, which is the whole point of F1 -- the glyphs
// now land where the font says they do. Nothing about the Arabic path changed.
const SMALL_ITEM_FINGERPRINT: u64 = 9_200_256_185_091_160_371;
/// A cluster that shows one character with the single glyph `id`, at `advance` em.
fn cluster(id: u32, advance: f64) -> ShapedCluster {
ShapedCluster { glyphs: vec![(id, [0.0, 0.0])], advance, text: "ب".into() }
ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(id, [0.0, 0.0])], advance, text: "ب".into() }
}
/// One run of `clusters` as a line of an item at `item()`'s size.
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
(text, rtl, vec![Piece::Arabic(clusters)], width)
(text, rtl, vec![Piece::Shaped(clusters)], width)
}
/// A glyph outline: a box on the baseline, `advance` em wide.
@@ -43,6 +48,25 @@ fn snapshot(doc: &Document) -> Vec<u8> {
bytes
}
/// Serialize generated objects with decoded stream bytes so the golden does not depend on which
/// Flate backend Cargo's unified dependency features select for the test binary. Only the raw
/// bytes (and so `/Length`, which the writer takes from them) are normalised: `/Filter` and
/// `/DecodeParms` stay in the hash, so a change that stops compressing generated streams, or
/// switches filters, still fails the golden.
fn golden_snapshot(doc: &Document) -> Vec<u8> {
let mut bytes = Vec::new();
for num in doc.object_numbers() {
let mut obj = (*doc.get(ObjRef { num, generation: doc.generation(num) })).clone();
if let Object::Stream(ref mut stream) = obj {
let decoded = stream.decoded_strict().expect("generated stream decodes");
stream.raw = decoded.into();
}
pdfcraft_cos::serialize(&obj, &mut bytes);
bytes.push(b'\n');
}
bytes
}
/// The fonts an item drew, in creation order, with their glyph counts.
fn fonts_of(doc: &Document, fonts: &Dict) -> Vec<(Vec<u8>, usize)> {
fonts
@@ -85,7 +109,7 @@ fn an_item_past_the_old_glyph_limit_gets_a_font_per_240_glyphs() {
let laid = vec![line((0..n).map(|i| cluster(i as u32 + 1, 0.5)).collect(), false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let made = fonts_of(&doc, &fonts);
let mut expected = vec![GLYPHS_PER_FONT; 16];
@@ -108,7 +132,7 @@ fn an_outline_missing_from_a_later_font_leaves_the_document_unchanged() {
let before = snapshot(&doc);
let missing = n as u32;
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| {
if id == missing { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
});
assert!(matches!(&drawn, Err(EditError::Invalid(msg)) if msg.contains("can't show")), "{drawn:?}");
@@ -120,21 +144,45 @@ fn an_outline_missing_from_a_later_font_leaves_the_document_unchanged() {
fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
// A left-to-right line with a Latin run, a space and a two-glyph cluster, then a right-to-left
// line. Glyphs repeat across the two lines and share one font.
let space = ShapedCluster { glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Arabic(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| Ok(boxed(0.5 + f64::from(id) * 0.01)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| Ok(boxed(0.5 + f64::from(id) * 0.01)));
assert!(drawn.is_ok(), "{drawn:?}");
assert_eq!(fonts.len(), 1);
// The injected outlines are fixed; only the descriptor's family comes from craft-fonts.
// Check that metadata separately, then normalize those two fields to the original golden.
// ActualText is new metadata: check each line's value and remove only its added wrappers
// before hashing. Drawing bytes and font objects still match the original golden exactly.
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family);
for (_, font) in fonts.iter() {
let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap();
let dict = doc.dict(&Object::Ref(descriptor)).unwrap();
assert_eq!(dict.name(b"FontName"), Some(family.replace(' ', "").as_bytes()));
assert_eq!(dict.get(b"FontFamily").and_then(Object::as_string).unwrap().bytes, family.as_bytes());
doc.update_dict(descriptor, |d| {
d.set(b"FontName".to_vec(), Object::name("Arabic"));
d.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(b"Arabic".to_vec())));
})
.unwrap();
}
let mut bytes = out.clone();
for (line, ..) in &laid {
let hex: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
let header = format!("/Span <</ActualText <FEFF{hex}>>> BDC\n").into_bytes();
let start = bytes.windows(header.len()).position(|w| w == header).expect("each line has its logical text");
bytes.drain(start..start + header.len());
let end = bytes.windows(4).position(|w| w == b"EMC\n").expect("each ActualText span closes");
bytes.drain(end..end + 4);
}
pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes);
bytes.extend(snapshot(&doc));
assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change");
bytes.extend(golden_snapshot(&doc));
assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "small-item output changed");
}
#[test]
@@ -145,7 +193,7 @@ fn long_text_with_few_distinct_glyphs_needs_one_font() {
let reads = Cell::new(0usize);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| {
reads.set(reads.get() + 1);
Ok(boxed(0.5))
});
@@ -159,13 +207,18 @@ fn long_text_with_few_distinct_glyphs_needs_one_font() {
fn distinct_clusters_past_the_old_limit_share_their_glyph_outlines() {
// 3,841 different clusters (their advances differ) built from four glyphs: four outlines are read.
let clusters: Vec<ShapedCluster> = (0..3_841u32)
.map(|i| ShapedCluster { glyphs: vec![(1 + i % 4, [0.0, 0.0])], advance: 0.5 + f64::from(i) * 1e-6, text: "ب".into() })
.map(|i| ShapedCluster {
script: ShapedScript::Arabic,
glyphs: vec![(1 + i % 4, [0.0, 0.0])],
advance: 0.5 + f64::from(i) * 1e-6,
text: "ب".into(),
})
.collect();
let laid = vec![line(clusters, false)];
let reads = Cell::new(0usize);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| {
reads.set(reads.get() + 1);
Ok(boxed(0.5))
});
@@ -178,18 +231,18 @@ fn distinct_clusters_past_the_old_limit_share_their_glyph_outlines() {
#[test]
fn hostile_clusters_draw_empty_or_fail_without_changing_the_document() {
// A cluster with no glyphs and no text draws nothing, but it is still one glyph of a font.
let empty = ShapedCluster { glyphs: Vec::new(), advance: 0.0, text: String::new() };
let empty = ShapedCluster { script: ShapedScript::Arabic, glyphs: Vec::new(), advance: 0.0, text: String::new() };
let laid = vec![line(vec![empty, cluster(1, 0.5)], false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
assert_eq!(glyphs_shown(&out).len(), 2);
// A glyph id the face doesn't have fails the item before anything is written.
let before = snapshot(&doc);
let laid = vec![line(vec![cluster(1, 0.5), cluster(u32::MAX, 0.5)], false)];
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| {
if id == u32::MAX { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
});
assert!(drawn.is_err());
@@ -202,7 +255,7 @@ fn a_font_name_the_page_already_uses_is_not_reused() {
let laid = vec![line(vec![cluster(1, 0.5)], false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let first = fonts_of(&doc, &fonts)[0].0.clone();
// The same item on a page whose resources already use that name.
@@ -210,7 +263,7 @@ fn a_font_name_the_page_already_uses_is_not_reused() {
taken.set(first.clone(), Object::Null);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &taken, &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &taken, &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let second = fonts_of(&doc, &fonts)[0].0.clone();
assert_ne!(second, first);
+364
View File
@@ -0,0 +1,364 @@
//! Right-to-left text in Edit text (Hebrew, Arabic).
//!
//! A content stream draws glyphs left to right, so a producer writes a right-to-left line in
//! *visual* order: `בדיוק` is drawn, and read back from the stream, as `קוידב`. The
//! editor works on *logical* (typing) order instead: [`to_logical`] turns a line read from the
//! page into the order a person types it, and [`to_visual`] turns a line about to be written back
//! into the order it must be drawn in (UAX #9 through `unicode-bidi`).
//!
//! Visual order doesn't always determine one logical order (`abc 12 םולש` is both `abc שלום 12`
//! and `abc 12 שלום`), so [`to_logical`] tries a few readings and keeps the first that draws back
//! exactly as the page did. Lines without right-to-left characters are returned unchanged.
//!
//! Combining marks (Hebrew points, Arabic harakat) stay after their base character in both
//! orders. Brackets drawn in a right-to-left run are mirrored, as UAX #9 L4 mirrors them on
//! display.
use unicode_bidi::{BidiInfo, Level};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Class {
Rtl,
Ltr,
Number,
Neutral,
}
/// Letters, marks and punctuation of the right-to-left scripts' blocks (digits excepted).
pub(crate) fn is_rtl(c: char) -> bool {
!is_digit(c) && matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFC | 0x1_0800..=0x1_0FFF | 0x1_E800..=0x1_EFFF)
}
/// The Hebrew block and the Hebrew presentation forms.
pub(crate) fn is_hebrew(c: char) -> bool {
matches!(u32::from(c), 0x0590..=0x05FF | 0xFB1D..=0xFB4F)
}
/// Hebrew points and cantillation marks (niqqud, te'amim): combining, drawn on their letter.
pub(crate) fn is_hebrew_mark(c: char) -> bool {
matches!(u32::from(c), 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | 0xFB1E)
}
fn is_digit(c: char) -> bool {
c.is_ascii_digit() || matches!(c, '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}')
}
/// Combining marks that ride on the character before them.
fn is_mark(c: char) -> bool {
is_hebrew_mark(c)
|| matches!(
u32::from(c),
0x0300..=0x036F | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06DC | 0x06DF..=0x06E4 | 0x06E7..=0x06E8 | 0x06EA..=0x06ED | 0xFE20..=0xFE2F
)
}
/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
fn mirrored(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
'«' => '»',
'»' => '«',
other => other,
}
}
fn class(c: char) -> Class {
if is_digit(c) {
Class::Number
} else if is_rtl(c) {
Class::Rtl
} else if c.is_alphabetic() {
Class::Ltr
} else {
Class::Neutral
}
}
/// A base character with the combining marks after it.
#[derive(Clone, Debug)]
struct Cluster {
text: String,
class: Class,
}
impl Cluster {
/// The cluster with its base mirrored (marks stay as they are).
fn mirror(&self) -> Cluster {
let mut chars = self.text.chars();
let mut text: String = chars.next().map(mirrored).into_iter().collect();
text.extend(chars);
Cluster { text, class: self.class }
}
}
fn clusters(s: &str) -> Vec<Cluster> {
let mut out: Vec<Cluster> = Vec::new();
for c in s.chars() {
match out.last_mut() {
Some(last) if is_mark(c) => last.text.push(c),
_ => out.push(Cluster { text: c.to_string(), class: class(c) }),
}
}
out
}
/// Whether the line reads right to left as a whole: more right-to-left letters than
/// left-to-right ones. Counting (not the first strong character) gives the same answer for a
/// line in either order, so reading a line and writing it back agree on its direction.
pub(crate) fn rtl_base(text: &str) -> bool {
let (mut r, mut l) = (0usize, 0usize);
for c in text.chars() {
match class(c) {
Class::Rtl if !is_mark(c) => r = r.saturating_add(1),
Class::Ltr if !is_mark(c) => l = l.saturating_add(1),
_ => {}
}
}
r > l
}
/// Maximal ranges that start and end with a `member` and hold only members and `bridge`s.
fn runs(cl: &[Cluster], member: impl Fn(&Cluster) -> bool, bridge: impl Fn(&[Cluster], usize) -> bool) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut i = 0;
while i < cl.len() {
if !cl.get(i).is_some_and(&member) {
i += 1;
continue;
}
let mut end = i;
let mut j = i + 1;
while let Some(c) = cl.get(j) {
if member(c) {
end = j;
} else if !bridge(cl, j) {
break;
}
j += 1;
}
out.push(i..end + 1);
i = end + 1;
}
out
}
/// One separator between two digits (`1,000`, `12:30`, `3.5`, `1/2`, `5-6`) keeps a number whole.
fn number_separator(cl: &[Cluster], j: usize) -> bool {
let digit = |k: Option<usize>| k.and_then(|k| cl.get(k)).is_some_and(|c| c.class == Class::Number);
cl.get(j).is_some_and(|c| matches!(c.text.as_str(), "." | "," | ":" | "/" | "-" | "+")) && digit(j.checked_sub(1)) && digit(Some(j + 1))
}
fn reverse_mirrored(cl: &mut [Cluster]) {
cl.reverse();
for c in cl.iter_mut() {
*c = c.mirror();
}
}
/// One reading of `visual` in logical order. `wide` lets runs of the other direction swallow the
/// spaces and numbers between their words; narrow keeps numbers and words apart.
fn reading(visual: &[Cluster], rtl: bool, wide: bool) -> String {
let mut cl = visual.to_vec();
let number = |c: &Cluster| c.class == Class::Number;
if rtl {
// Everything reads right to left, except left-to-right words and numbers.
reverse_mirrored(&mut cl);
let ltr = |c: &Cluster| matches!(c.class, Class::Ltr | Class::Number);
let bridge =
|cl: &[Cluster], j: usize| cl.get(j).is_some_and(|c| c.class == Class::Neutral) && (wide || !neutral_between_word_and_number(cl, j));
for r in runs(&cl, ltr, bridge) {
if let Some(run) = cl.get_mut(r) {
reverse_mirrored(run);
}
}
} else {
// Left to right, except right-to-left stretches; numbers inside those read left to right.
let rtl_member = |c: &Cluster| c.class == Class::Rtl;
let bridge = |cl: &[Cluster], j: usize| cl.get(j).is_some_and(|c| c.class == Class::Neutral || (wide && c.class == Class::Number));
for r in runs(&cl, rtl_member, bridge) {
if let Some(run) = cl.get_mut(r) {
reverse_mirrored(run);
for n in runs(run, number, number_separator) {
if let Some(num) = run.get_mut(n) {
reverse_mirrored(num);
}
}
}
}
}
cl.iter().map(|c| c.text.as_str()).collect()
}
/// Whether the neutral at `j` sits between a left-to-right word on one side and a number on the
/// other (UAX #9 resolves such a space to the line's direction, splitting the run).
fn neutral_between_word_and_number(cl: &[Cluster], j: usize) -> bool {
let strong = |mut k: usize, step: isize| -> Option<Class> {
loop {
k = k.checked_add_signed(step)?;
match cl.get(k)?.class {
Class::Neutral => continue,
other => return Some(other),
}
}
};
matches!((strong(j, -1), strong(j, 1)), (Some(Class::Ltr), Some(Class::Number)) | (Some(Class::Number), Some(Class::Ltr)))
}
/// A line read from a content stream (drawn left to right), in logical order.
pub fn to_logical(visual: &str) -> String {
if !visual.chars().any(is_rtl) {
return visual.to_owned();
}
let cl = clusters(visual);
let rtl = rtl_base(visual);
let readings = [reading(&cl, rtl, true), reading(&cl, rtl, false)];
// Readings that draw back exactly as the page did; of those, one whose brackets pair up
// (`12 (PDF)`, not `PDF) 12)`), else the first.
let exact: Vec<&String> = readings.iter().filter(|r| to_visual(r) == visual).collect();
let chosen = exact.iter().find(|r| balanced(r)).or(exact.first()).map_or_else(|| readings[0].clone(), |r| (*r).clone());
// Some producers (LibreOffice) map a bracket drawn in a right-to-left run to the character it
// stands for, not the shape drawn, so mirroring it again turns it inside out: `)טיוטה(`.
let swapped: String = chosen.chars().map(mirrored).collect();
if !balanced(&chosen) && balanced(&swapped) { swapped } else { chosen }
}
/// Whether every closing bracket closes an open one.
fn balanced(text: &str) -> bool {
let mut open: Vec<char> = Vec::new();
for c in text.chars() {
match c {
'(' | '[' | '{' => open.push(c),
')' | ']' | '}' if open.pop() != Some(mirrored(c)) => return false,
_ => {}
}
}
open.is_empty()
}
/// A line in logical order, in the order it must be drawn (left to right), without the bidi
/// controls fonts can't draw.
pub fn to_visual(logical: &str) -> String {
let text: String = logical.chars().filter(|c| !is_bidi_control(*c)).collect();
if !text.chars().any(is_rtl) {
return text;
}
let level = if rtl_base(&text) { Level::rtl() } else { Level::ltr() };
let info = BidiInfo::new(&text, Some(level));
let mut out = String::with_capacity(text.len());
for para in &info.paragraphs {
let (levels, runs) = info.visual_runs(para, para.range.clone());
for run in runs {
let rtl = levels.get(run.start).is_some_and(|l| l.is_rtl());
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
let Some(piece) = text.get(run) else { continue };
if rtl {
for c in clusters(piece).iter().rev() {
out.push_str(&c.mirror().text);
}
} else {
out.push_str(piece);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// What a producer writes into the content stream for `logical`.
fn drawn(logical: &str) -> String {
to_visual(logical)
}
#[test]
fn the_reported_word_reads_back_in_typing_order() {
// The report: Edit text showed "בדיוק" (exactly) reversed.
assert_eq!(drawn("בדיוק"), "קוידב");
assert_eq!(to_logical("קוידב"), "בדיוק");
}
#[test]
fn hebrew_sentences_numbers_latin_and_brackets_round_trip() {
for logical in [
"שלום עולם",
"עמוד 12 מתוך 40",
"מחיר: 1,250.50 ₪",
"השעה 12:30",
"קובץ PDF חדש",
"שלום (עולם)",
"ראו [1] ו-[2]",
"גרסה 2.0 של PdfCraft נוסחה",
"hello שלום עולם world",
"abc שלום def",
"שלום abc def עולם",
"Report: דוח שנתי 2026",
"שלום עולם, עמוד 12 (PDF)",
"פרק 3 (מבוא)",
"שָׁלוֹם עוֹלָם",
"مرحبا بالعالم",
] {
let visual = drawn(logical);
assert_ne!(visual, logical, "{logical:?} should be reordered");
assert_eq!(to_visual(&to_logical(&visual)), visual, "{logical:?} drawn as {visual:?}");
assert_eq!(to_logical(&visual), logical, "{logical:?} drawn as {visual:?}");
}
}
#[test]
fn ambiguous_lines_still_draw_back_the_same() {
// "abc 12 םולש" is both "abc שלום 12" and "abc 12 שלום"; either must draw the same.
for logical in ["abc שלום 12", "שלום 12 abc", "a (ב) c", "דוח Report 2026", "(1) שלום"] {
let visual = drawn(logical);
assert_eq!(to_visual(&to_logical(&visual)), visual, "{logical:?}");
}
}
#[test]
fn brackets_mapped_to_their_logical_character_pair_up() {
// LibreOffice draws "(טיוטה)" with ToUnicode giving the logical brackets: `)הטויט(`.
assert_eq!(to_logical(")הטויט("), "(טיוטה)");
// Brackets mapped to the shape drawn read the same.
assert_eq!(to_logical("(הטויט)"), "(טיוטה)");
}
#[test]
fn left_to_right_text_is_untouched() {
for s in ["", "Hello, world (1)", "日本語のテキスト", "Ünïcödé 12:30"] {
assert_eq!(to_logical(s), s);
assert_eq!(to_visual(s), s);
}
}
#[test]
fn points_stay_on_their_letter() {
// Each point follows its letter in both orders.
let visual = drawn("שָׁלוֹם");
let first = visual.chars().next();
assert_eq!(first, Some('ם'));
assert!(visual.contains("לוֹ") || visual.contains("וֹל"), "{visual:?}");
assert_eq!(to_logical(&visual), "שָׁלוֹם");
}
#[test]
fn controls_are_not_drawn_and_hostile_input_does_not_panic() {
assert_eq!(to_visual("\u{200F}שלום\u{202B}"), "םולש");
for s in ["\u{05B8}", "\u{05B8}\u{05B8}א", "(((", "א\u{0300}", "1-", "-1א", "א\n\tב", "\u{202E}abc"] {
let _ = to_visual(&to_logical(s));
}
let long = "שלום 12 abc (x) ".repeat(2_000);
assert_eq!(to_visual(&to_logical(&to_visual(&long))), to_visual(&long));
}
}
+38 -1
View File
@@ -97,9 +97,45 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
flatten_which(doc, pages, Which::FillSign)
}
/// The first annotation flattening would turn into page content with `?` in it, before anything
/// changes: one whose appearance PdfCraft drew with `?` for characters its fonts can't draw
/// (`pdfcraft_fonts::UNDRAWN_MARK`: a form field's value, a comment's text), or a comment without
/// an appearance whose text the standard fonts can't draw. Its page (0-based), whether it is a
/// form field, and the character.
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> Option<(usize, bool, char)> {
for &i in pages {
let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for entry in &list {
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 {
continue;
}
let widget = d.name(b"Subtype") == Some(b"Widget");
let drawn = pdfcraft_fonts::undrawn_mark(&d);
let would_draw = || (!widget && appearance(doc, &d).is_none()).then(|| pdfcraft_annot::appearance::undrawable_text(&d)).flatten();
if let Some(c) = drawn.or_else(|| would_draw().map(|(_, c)| c)) {
return Some((i, widget, c));
}
}
}
None
}
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
// `?` in an appearance can still be fixed (a field's value, a comment's text are stored as
// typed, and other viewers redraw form fields); flattened, it would be the page for good.
if let Some((i, widget, c)) = undrawn_on(doc, &all, pages, which) {
return Err(EditError::Invalid(format!(
"can't flatten {} on page {}: it shows ? in place of \"{}\" (U+{:04X}), which its font can't draw, and flattening would make that permanent (nothing was flattened); change or remove that text first",
if widget { "a form field" } else { "a comment" },
i + 1,
c.escape_debug(),
u32::from(c)
)));
}
let mut drawn = 0;
let mut removed_widgets: HashSet<ObjRef> = HashSet::new();
for &i in pages {
@@ -132,7 +168,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below.
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {
+399 -136
View File
@@ -3,8 +3,10 @@
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use std::sync::Arc;
use crate::EditError;
@@ -13,6 +15,11 @@ use crate::EditError;
pub struct PageImage {
/// Its box in user space.
pub rect: [f64; 4],
/// Visible frame after page and analytic path bounds. Hit testing also respects curve
/// interiors, winding rules and holes, rather than intercepting their empty areas.
pub visible_rect: Option<[f64; 4]>,
/// Current page/path clip in user space. A private crop transforms with its image.
pub clip_rect: Option<[f64; 4]>,
/// The placement: image unit square or Form coordinates → user space.
pub matrix: [f64; 6],
/// The XObject resource name and object.
@@ -27,6 +34,41 @@ pub struct PageImage {
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
draw_matrix: [f64; 6],
clips: Vec<ClipPath>,
}
impl PageImage {
/// Whether a user-space point belongs to the placed artwork and every active clip.
pub fn contains(&self, point: [f64; 2]) -> bool {
if !point.iter().all(|v| v.is_finite()) {
return false;
}
let Some(bounds) = self.visible_rect else {
return false;
};
if point[0] < bounds[0] || point[0] > bounds[2] || point[1] < bounds[1] || point[1] > bounds[3] {
return false;
}
if !self.is_form {
let Some(inverse) = Matrix(self.matrix).invert() else {
return false;
};
let (x, y) = inverse.apply(point[0], point[1]);
if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
return false;
}
}
self.clips.iter().all(|clip| {
let winding = clip.path.winding(Point::new(point[0], point[1]));
if clip.even_odd { winding % 2 != 0 } else { winding != 0 }
})
}
}
#[derive(Clone, Debug, PartialEq)]
struct ClipPath {
path: Arc<BezPath>,
even_odd: bool,
}
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -59,6 +101,113 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict {
.unwrap_or_default()
}
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
}
#[derive(Clone)]
struct Placement {
matrix: Matrix,
clip: [f64; 4],
clips: Vec<ClipPath>,
}
/// Path coordinates are transformed when constructed; q/Q save clips, not the current path.
struct ImageState {
placement: Placement,
stack: Vec<Placement>,
path: BezPath,
pending_clip: Option<bool>,
}
impl ImageState {
fn new(matrix: Matrix, clip: [f64; 4], clips: Vec<ClipPath>) -> Self {
Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None }
}
fn point(&self, x: f64, y: f64) -> Point {
let (x, y) = self.placement.matrix.apply(x, y);
Point::new(x, y)
}
fn apply(&mut self, op: &Op) -> Result<(), EditError> {
if matches!(op.op.as_slice(), b"cm" | b"m" | b"l" | b"c" | b"v" | b"y" | b"re")
&& op.operands.iter().any(|v| v.as_f64().is_some_and(|v| !v.is_finite() || v.abs() > 1e12))
{
return Err(EditError::Invalid("the image path contains invalid coordinates".into()));
}
if self.path.elements().len() > 16384 || self.stack.len() > 4096 || self.placement.clips.len() > 256 {
return Err(EditError::Invalid("the image clip or graphics state exceeds its complexity budget".into()));
}
match op.op.as_slice() {
b"q" => self.stack.push(self.placement.clone()),
b"Q" => {
if let Some(saved) = self.stack.pop() {
self.placement = saved;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
self.placement.matrix = Matrix(m).then(&self.placement.matrix);
}
}
b"m" => {
if let Some([x, y]) = op.nums::<2>() {
self.path.move_to(self.point(x, y));
}
}
b"l" => {
if let Some([x, y]) = op.nums::<2>() {
self.path.line_to(self.point(x, y));
}
}
b"c" => {
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() {
self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3));
}
}
b"v" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
&& let Some(current) = self.path.elements().last().and_then(|e| e.end_point())
{
self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2));
}
}
b"y" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>() {
let end = self.point(x2, y2);
self.path.curve_to(self.point(x1, y1), end, end);
}
}
b"h" => self.path.close_path(),
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
self.path.move_to(self.point(x, y));
self.path.line_to(self.point(x + w, y));
self.path.line_to(self.point(x + w, y + h));
self.path.line_to(self.point(x, y + h));
self.path.close_path();
}
}
b"W" => self.pending_clip = Some(false),
b"W*" => self.pending_clip = Some(true),
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if let Some(even_odd) = self.pending_clip.take() {
// Filling/clipping implicitly closes open subpaths. BezPath's winding
// follows the same rule, and its bounds use cubic extrema, not controls.
let bounds = self.path.bounding_box();
self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]);
self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd });
}
self.path = BezPath::new();
}
_ => {}
}
Ok(())
}
}
/// The images and Form artwork page `page` (0-based) draws, in drawing order.
/// Forms stay intact: no traversal or rewriting of their internal objects is needed.
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
@@ -66,76 +215,70 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
// The graphics state carries over from one stream to the next.
let mut ctm = Matrix::IDENTITY;
let mut stack = Vec::new();
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
for (i, op) in parse(&data).ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
ctm = Matrix(m).then(&ctm);
}
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
});
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
_ => {}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
}
}
}
@@ -153,65 +296,73 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
path: Vec<ObjRef>,
visits: usize,
}
struct State {
ctm: Matrix,
stack: Vec<Matrix>,
}
fn walk(doc: &Document, data: &[u8], resources: &Dict, st: &mut State, stream: usize, forms: &mut Forms, out: &mut Vec<PageImage>) {
fn walk(
doc: &Document,
data: &[u8],
resources: &Dict,
st: &mut ImageState,
stream: usize,
forms: &mut Forms,
out: &mut Vec<PageImage>,
) -> Result<(), EditError> {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (ctm, stack) = (&mut st.ctm, &mut st.stack);
for (i, op) in parse(data).ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => stack.push(*ctm),
b"Q" => *ctm = stack.pop().unwrap_or(*ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
*ctm = Matrix(m).then(ctm);
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) {
if let Some(r) = form.obj {
forms.path.push(r);
}
let bounds =
xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then(
|b| {
let a = b.as_array()?;
let [x0, y0, x1, y1] = a.as_slice() else { return None };
Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?]))
},
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
continue;
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
if let Some(form) = crate::text::form_call(doc, resources, name, *ctm, &forms.path, &mut forms.visits) {
if let Some(r) = form.obj {
forms.path.push(r);
}
let mut inner = State { ctm: form.ctm, stack: Vec::new() };
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out);
if form.obj.is_some() {
forms.path.pop();
}
continue;
}
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
matrix: ctm.0,
name: String::from_utf8_lossy(name).into_owned(),
object: Some(r),
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
op: i,
draw_matrix: ctm.0,
});
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
_ => {}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
clip_rect: Some(st.placement.clip),
matrix: ctm.0,
name: String::from_utf8_lossy(name).into_owned(),
object: Some(r),
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
});
}
}
Ok(())
}
let p = page_of(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = State { ctm: Matrix::IDENTITY, stack: Vec::new() };
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut forms = Forms { path: Vec::new(), visits: 0 };
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out);
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
}
Ok(out)
}
@@ -284,33 +435,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let all = streams(doc, &p.dict);
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let mut ops = parse(&data).ops;
let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
if ops.get(img.op).is_none() {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let mut resources = None;
let mut clip_move = None;
let replacement: Vec<Op> = match change {
ImageChange::Delete => Vec::new(),
ImageChange::Transform(t) => {
if !t.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform must be finite".into()));
}
// New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹.
let pm = Matrix(img.draw_matrix);
let inv = pm.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let m = pm.then(&Matrix(*t)).then(&inv);
if !m.0.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
// `q re W n cm Do Q` clips only this image: move the clip with it instead of leaving it behind.
if let Some((at, cm)) = exclusive_clip(&ops, img.op) {
if let Some(c) = clip_matrix(Matrix(img.draw_matrix), cm, Matrix(*t)).filter(|c| c.0.iter().all(|v| v.is_finite())) {
clip_move = Some((at, Op::new("cm", c.0.iter().map(|v| pdfcraft_content::num(*v)).collect())));
vec![original]
} else {
return Err(EditError::Invalid("the image transform is too large".into()));
}
} else {
vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])]
}
return transform_image(doc, &p, &all, &img, *t);
}
ImageChange::Replace(r) => {
if img.is_form {
@@ -328,9 +460,6 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
ops.splice(img.op..=img.op, replacement);
if let Some((at, cm)) = clip_move {
ops.insert(at, cm);
}
let mut dict = match &*doc.resolve(&obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
@@ -362,6 +491,140 @@ pub(crate) fn selectable_images(
.collect())
}
/// Find the saved graphics-state scope containing just this image and its crop. Applying
/// the transform before that scope moves the actual clipping path as well as the pixels.
/// A shared clip must never be moved along with unrelated artwork or text.
fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
inherited_clip: bool,
inherited_path: bool,
selected: bool,
other_paint: bool,
}
let mut stack: Vec<Scope> = Vec::new();
let mut matrix = Matrix::IDENTITY;
let mut clipped = false;
let mut path = false;
let mut pending_clip = false;
let mut text = false;
let mut at_image = None;
let mut isolated = None;
for (index, op) in ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into()));
}
stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false });
}
b"Q" => {
if let Some(scope) = stack.pop() {
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
// Prefer the innermost scope that contains every active clipping path.
isolated.get_or_insert((scope.start, index, scope.matrix));
}
matrix = scope.matrix;
clipped = scope.inherited_clip;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
matrix = Matrix(m).then(&matrix);
}
}
b"BT" => text = true,
b"ET" => text = false,
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true,
b"W" | b"W*" => pending_clip = true,
_ => {}
}
if index == target {
if text {
return Err(EditError::Invalid("an image inside a text object cannot be transformed".into()));
}
at_image = Some((matrix, clipped));
for scope in &mut stack {
scope.selected = true;
}
} else if matches!(
op.op.as_slice(),
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
) {
for scope in &mut stack {
scope.other_paint = true;
}
}
if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") {
clipped |= pending_clip;
pending_clip = false;
path = false;
}
}
let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
if !clipped {
return Ok(Some((target, target, placement)));
}
Ok(isolated)
}
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
image: &PageImage,
transform: [f64; 6],
) -> Result<(), EditError> {
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let local = placement.then(&Matrix(transform)).then(&inverse);
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if index == start {
let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())];
if start == end {
out.extend(ops.get(index).cloned());
out.push(Op::new("Q", vec![]));
return (out, false);
}
(out, true)
} else if index == end {
let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect();
out.push(Op::new("Q", vec![]));
(out, false)
} else {
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
+258
View File
@@ -0,0 +1,258 @@
//! Visual and logical order for Indic scripts in Edit text (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Malayalam).
//!
//! Legacy PDF producers often output glyphs in *visual* display order when using fonts without
//! OpenType GSUB layout tables. In visual order, pre-base vowel signs (such as Devanagari short 'i'
//! `ि` U+093F) appear in the stream *before* the consonant cluster they modify, matching their visual
//! placement to the left of the glyph.
//!
//! Standard Unicode text is *logical* (typing order): the pre-base vowel sign follows the consonant
//! cluster. The editor presents and edits text in logical order. When a source font is flagged with
//! `visual_order`, [`to_logical`] converts extracted stream text to logical order for editing, and
//! [`to_visual`] converts logical edits back into the visual glyph sequence the font expects.
/// Whether `c` is an Indic consonant across covered scripts.
pub fn is_indic_consonant(c: char) -> bool {
matches!(
c,
'\u{0915}'..='\u{0939}' | '\u{0958}'..='\u{095F}' | '\u{0978}'..='\u{097F}' // Devanagari
| '\u{0995}'..='\u{09B9}' | '\u{09DC}'..='\u{09DF}' // Bengali
| '\u{0A15}'..='\u{0A39}' | '\u{0A59}'..='\u{0A5E}' // Gurmukhi
| '\u{0A95}'..='\u{0AB9}' // Gujarati
| '\u{0B15}'..='\u{0B39}' | '\u{0B5C}'..='\u{0B5F}' // Oriya
| '\u{0B95}'..='\u{0BB9}' // Tamil
| '\u{0C15}'..='\u{0C39}' | '\u{0C58}'..='\u{0C5A}' // Telugu
| '\u{0C95}'..='\u{0CB9}' | '\u{0CDE}' // Kannada
| '\u{0D15}'..='\u{0D3A}' // Malayalam
)
}
/// Whether `c` is an Indic nukta (diacritic dot modifying a consonant).
pub fn is_indic_nukta(c: char) -> bool {
matches!(
c,
'\u{093C}' // Devanagari
| '\u{09BC}' // Bengali
| '\u{0A3C}' // Gurmukhi
| '\u{0ABC}' // Gujarati
| '\u{0B3C}' // Oriya
)
}
/// Whether `c` is an Indic virama (halant / vowel killer).
pub fn is_indic_virama(c: char) -> bool {
matches!(
c,
'\u{094D}' // Devanagari
| '\u{09CD}' // Bengali
| '\u{0A4D}' // Gurmukhi
| '\u{0ACD}' // Gujarati
| '\u{0B4D}' // Oriya
| '\u{0BCD}' // Tamil
| '\u{0C4D}' // Telugu
| '\u{0CCD}' // Kannada
| '\u{0D4D}' // Malayalam
)
}
/// Whether `c` is an Indic pre-base vowel sign that appears visually to the left of its consonant.
pub fn is_indic_pre_base_vowel(c: char) -> bool {
matches!(
c,
'\u{093F}' // Devanagari short I
| '\u{09BF}' | '\u{09C7}' | '\u{09C8}' // Bengali short I, E, AI
| '\u{0A3F}' // Gurmukhi short I
| '\u{0ABF}' // Gujarati short I
| '\u{0B3F}' // Oriya short I
| '\u{0BC6}' | '\u{0BC7}' | '\u{0BC8}' // Tamil E, EE, AI
| '\u{0D46}' | '\u{0D47}' | '\u{0D48}' // Malayalam E, EE, AI
)
}
/// Modifiers that attach to an Indic vowel sign (such as candrabindu, anusvara, or visarga).
fn is_indic_vowel_modifier(c: char) -> bool {
matches!(
c,
'\u{0901}'..='\u{0903}' // Devanagari candrabindu, anusvara, visarga
| '\u{0981}'..='\u{0983}' // Bengali
| '\u{0A01}'..='\u{0A03}' // Gurmukhi
| '\u{0A81}'..='\u{0A83}' // Gujarati
| '\u{0B01}'..='\u{0B03}' // Oriya
)
}
/// The character at `i`, if any.
fn at(chars: &[char], i: usize) -> Option<char> {
chars.get(i).copied()
}
/// The end of the consonant cluster starting at `start` (consonants with their nuktas, joined by
/// viramas and optional ZWNJ/ZWJ); `start` itself when no consonant starts there.
fn cluster_end(chars: &[char], start: usize) -> usize {
let mut end = start;
while at(chars, end).is_some_and(is_indic_consonant) {
end += 1;
if at(chars, end).is_some_and(is_indic_nukta) {
end += 1;
}
if at(chars, end).is_some_and(is_indic_virama) {
end += 1;
if at(chars, end).is_some_and(|c| matches!(c, '\u{200C}' | '\u{200D}')) {
end += 1;
}
if at(chars, end).is_some_and(is_indic_consonant) {
continue;
}
}
break;
}
end
}
/// The end of the vowel-modifier run (candrabindu, anusvara, visarga) starting at `start`.
fn modifiers_end(chars: &[char], start: usize) -> usize {
let mut end = start;
while at(chars, end).is_some_and(is_indic_vowel_modifier) {
end += 1;
}
end
}
/// Convert Indic text from visual glyph order (pre-base vowels before their consonant cluster)
/// to standard Unicode logical order.
pub fn to_logical(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while let Some(ch) = at(&chars, i) {
if is_indic_pre_base_vowel(ch) {
let v_end = modifiers_end(&chars, i + 1);
let c_end = cluster_end(&chars, v_end);
if c_end > v_end {
out.extend_from_slice(chars.get(v_end..c_end).unwrap_or_default());
out.extend_from_slice(chars.get(i..v_end).unwrap_or_default());
i = c_end;
continue;
}
}
out.push(ch);
i += 1;
}
out.into_iter().collect()
}
/// Convert Indic text from standard Unicode logical order to visual display order for fonts
/// that expect pre-base vowels before their consonant cluster.
pub fn to_visual(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<char> = Vec::with_capacity(chars.len());
let mut i = 0;
while let Some(ch) = at(&chars, i) {
if is_indic_consonant(ch) {
let c_end = cluster_end(&chars, i);
if at(&chars, c_end).is_some_and(is_indic_pre_base_vowel) {
let v_end = modifiers_end(&chars, c_end + 1);
out.extend_from_slice(chars.get(c_end..v_end).unwrap_or_default());
out.extend_from_slice(chars.get(i..c_end).unwrap_or_default());
i = v_end;
} else {
out.extend_from_slice(chars.get(i..c_end).unwrap_or_default());
i = c_end;
}
continue;
}
out.push(ch);
i += 1;
}
out.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_words_roundtrip() {
// किताब (kitab): k + i + t + aa + b
let visual = "\u{093F}\u{0915}\u{0924}\u{093E}\u{092C}";
let logical = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// दिन (din): d + i + n
let visual_din = "\u{093F}\u{0926}\u{0928}";
let logical_din = "\u{0926}\u{093F}\u{0928}";
assert_eq!(to_logical(visual_din), logical_din);
assert_eq!(to_visual(logical_din), visual_din);
}
#[test]
fn decomposed_nukta_with_pre_base_vowel() {
// Decomposed ड़ि: DDA (U+0921) + NUKTA (U+093C) + Short I (U+093F)
let logical = "\u{0921}\u{093C}\u{093F}";
let visual = "\u{093F}\u{0921}\u{093C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// Decomposed ढ़ि: DDHA (U+0922) + NUKTA (U+093C) + Short I (U+093F)
let logical_ddhi = "\u{0922}\u{093C}\u{093F}";
let visual_ddhi = "\u{093F}\u{0922}\u{093C}";
assert_eq!(to_logical(visual_ddhi), logical_ddhi);
assert_eq!(to_visual(logical_ddhi), visual_ddhi);
}
#[test]
fn precomposed_nukta_with_pre_base_vowel() {
// Precomposed ड़ि (U+095C + U+093F)
let logical = "\u{095C}\u{093F}";
let visual = "\u{093F}\u{095C}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
}
#[test]
fn full_words_with_nukta_and_pre_base_vowel() {
// बढ़िया (badhiya): ba + ddha + nukta + short_i + ya + aa
let logical = "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}";
let visual = "\u{092C}\u{093F}\u{0922}\u{093C}\u{092F}\u{093E}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
// खिड़की (khirki): kha + short_i + dda + nukta + ka + long_ii
let logical_khirki = "\u{0916}\u{093F}\u{0921}\u{093C}\u{0915}\u{0940}";
let visual_khirki = "\u{093F}\u{0916}\u{0921}\u{093C}\u{0915}\u{0940}";
assert_eq!(to_logical(visual_khirki), logical_khirki);
assert_eq!(to_visual(logical_khirki), visual_khirki);
}
#[test]
fn complex_conjuncts_with_pre_base_vowel() {
// स्थिति (sthiti): sa + virama + tha + short_i + ta + short_i
let logical = "\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}\u{093F}";
let visual = "\u{093F}\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}";
assert_eq!(to_logical(visual), logical);
assert_eq!(to_visual(logical), visual);
}
#[test]
fn other_indic_scripts_pre_base_vowels() {
// Bengali কি (ki): KA (U+0995) + I (U+09BF)
let b_logical = "\u{0995}\u{09BF}";
let b_visual = "\u{09BF}\u{0995}";
assert_eq!(to_logical(b_visual), b_logical);
assert_eq!(to_visual(b_logical), b_visual);
// Gurmukhi ਕਿ (ki): KA (U+0A15) + I (U+0A3F)
let g_logical = "\u{0A15}\u{0A3F}";
let g_visual = "\u{0A3F}\u{0A15}";
assert_eq!(to_logical(g_visual), g_logical);
assert_eq!(to_visual(g_logical), g_visual);
}
#[test]
fn non_indic_text_is_untouched() {
for s in ["", "Hello world!", "PDF 123", "日本語テキスト", "123.45"] {
assert_eq!(to_logical(s), s);
assert_eq!(to_visual(s), s);
}
}
}
+6 -7
View File
@@ -395,12 +395,6 @@ pub fn undrawable_message(c: char) -> String {
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
}
/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks
/// and tabs are layout, not glyphs).
pub fn first_undrawable_plain(text: &str) -> Option<char> {
text.chars().find(|c| !matches!(c, '\r' | '\n' | '\t' | '\u{c}') && !pdfcraft_fonts::win_ansi_encodable(*c))
}
fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));
@@ -629,8 +623,13 @@ mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
pub mod bidi;
pub mod indic;
pub mod text;
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
pub use text::{
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
text_lines,
};
pub mod added;
pub use added::{
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,
+2 -1
View File
@@ -81,9 +81,10 @@ pub fn editable_objects(doc: &Document, page: usize) -> Result<Vec<EditableObjec
});
}
for (index, image) in crate::images::selectable_images(doc, page, &excluded)? {
let Some(rect) = image.visible_rect else { continue };
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Image, index },
rect: image.rect,
rect,
content_type: if image.is_form { "form" } else { "image" },
text: None,
});
+832 -2
View File
@@ -234,6 +234,58 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
/// with a font that has those characters (an appearance, no mark) flattens as before.
#[test]
fn flattening_refuses_what_would_bake_in_question_marks() {
let page = stream("", " ");
let refused = |annot: &str, comments: bool, fields: bool, what: &str| {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
annot,
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = flatten(&mut doc, &[0], comments, fields).unwrap_err().to_string();
assert!(err.contains(what) && err.contains("page 1") && err.contains("\"欢\" (U+6B22)") && err.contains("nothing was flattened"), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "a refused flatten changes nothing");
assert!(pdfcraft_model::pages(&doc)[0].dict.contains(b"Annots"), "the annotation is still there");
};
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V <FEFF6B228FCE> /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
false,
true,
"a form field",
);
// Drawn by another program, with a font that has 欢: no mark, so it flattens.
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1);
}
/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many
/// user-space units. Items record the `/UserUnit` they were written for; one without it was
/// written when display space was in user-space units, and reads back scaled to points.
@@ -1151,6 +1203,89 @@ fn missing_glyphs_preserve_source_style_in_fallback() {
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() {
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "ab ba");
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, "ba ab ba") } else { text::replace_line(&mut doc, 0, 0, "ba ab ba") };
assert_eq!(edit.unwrap().substituted, None);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, "ba ab ba");
assert_eq!(lines[0].font, "F2");
assert!((lines[0].rect[2] - lines[0].rect[0]) > 3.0 * 12.0);
}
}
#[test]
fn spaces_in_another_subset_do_not_split_a_line() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello world");
text::replace_block(&mut doc, 0, 0, "Goodbye").unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "Goodbye");
}
#[test]
fn editor_background_removes_glyphs_but_preserves_following_text_advance() {
for show in ["(Hello) Tj", "[(He) -20 (llo)] TJ", "(Hello) '", "2 1 (Hello) \""] {
let mut doc = text_page(&format!("BT /F1 12 Tf 14 TL 72 700 Td {show} [-3000] TJ /F2 12 Tf (ab) Tj ET"));
let before = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.len(), 2);
text::clear_block(&mut doc, 0, 0).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(after.len(), 1, "{show}");
assert_eq!(after[0].text, "ab");
for (a, b) in after[0].rect.iter().zip(before[1].rect) {
assert!((a - b).abs() < 1e-6, "unselected text moved: {show}");
}
}
}
#[test]
fn editor_background_refuses_clipping_text_without_mutation() {
let mut doc = text_page("BT /F1 12 Tf 7 Tr 72 700 Td (Clip) Tj ET");
let before = page_content_bytes(&doc, 0);
assert!(text::clear_block(&mut doc, 0, 0).is_err());
assert_eq!(page_content_bytes(&doc, 0), before);
}
#[test]
fn explicit_colour_spaces_expose_the_text_colour() {
let doc = text_page("/DeviceRGB cs 0 0.23 0.45 sc BT /F1 12 Tf 72 700 Td (Blue) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].color, [0.0, 0.23, 0.45]);
}
#[test]
fn words_in_separate_text_objects_form_one_editable_paragraph() {
let mut doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 1 0 0 1 103 700 Tm (world) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 685 Tm (Second) Tj ET \
BT /F1 12 Tf 1 0 0 1 117 685 Tm (line) Tj ET \
BT /F1 12 Tf 1 0 0 1 300 700 Tm (Other column) Tj ET",
);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].text, "Hello world Second line");
assert_eq!(blocks[0].lines.len(), 2);
let other = blocks[1].rect;
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Replacement", "Other column"]);
assert_eq!(blocks[1].rect, other);
}
#[test]
fn independent_text_objects_with_different_transforms_stay_separate() {
let doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 0 1 -1 0 103 700 Tm (Vertical) Tj ET",
);
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
@@ -1779,6 +1914,148 @@ fn the_graphics_state_carries_over_between_content_streams() {
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
fn hebrew_page(content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 9 beginbfchar \
<20> <0020> <41> <05D1> <42> <05D3> <43> <05D9> <44> <05D5> <45> <05E7> <46> <05E9> <47> <05DC> <48> <05DD> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
// Widths for codes 32..=72: a space, then the eight letters at 65..=72.
let widths: Vec<&str> = (32..=72)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 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 >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+FakeHebrew /FirstChar 32 /LastChar 72 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The report: Edit text showed "בדיוק" (exactly) reversed, because the line was read in the
/// order the page draws it.
#[test]
fn hebrew_lines_read_in_typing_order() {
let doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET BT /F1 12 Tf 300 600 Td (EDCBA HDGF) Tj ET");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["בדיוק", "שלום בדיוק"]);
let blocks: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["בדיוק", "שלום בדיוק"]);
}
/// Text the page's own font can show is written back in drawing order, right-aligned to where
/// the line ended, and reads back as typed.
#[test]
fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = text::text_lines(&doc, 0).unwrap()[0].rect;
// One line: the bytes are the visual order of the typed text.
assert_eq!(text::replace_line(&mut doc, 0, 0, "שלום בדיוק").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(EDCBA HDGF)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "שלום בדיוק");
// A paragraph: shorter text stays right-aligned at the old right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "דוב").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(ADB)"), "{content}");
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "דוב");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] > before[0] + 1.0, "shorter text starts further right: {:?} vs {before:?}", line.rect);
// Longer text grows to the left, not off the right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
text::replace_block(&mut doc, 0, 0, "שלום בדיוק שלום").unwrap();
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "שלום בדיוק שלום");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] < before[0] - 1.0, "longer text grows left: {:?} vs {before:?}", line.rect);
// An alignment asked for still wins.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let left = text::BlockStyle { align: Some(Align::Left), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("דוב"), &left).unwrap();
let line = text::text_lines(&reopen(&doc), 0).unwrap().swap_remove(0);
assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect);
}
/// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build
/// without it says so and changes nothing. Hebrew points are refused, not misdrawn.
#[test]
fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = page_content_bytes(&doc, 0);
let pointed = text::replace_line(&mut doc, 0, 0, "שָׁלוֹם עוֹלָם");
let Err(EditError::Invalid(msg)) = pointed else { panic!("expected a clear error, got {pointed:?}") };
assert!(msg.contains("niqqud"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
// ע and ה aren't in the page's font.
let typed = "שלום עולם, עמוד 12 (PDF)";
let line = text::replace_line(&mut doc, 0, 0, typed);
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
let Err(EditError::Invalid(msg)) = line else { panic!("expected a clear error, got {line:?}") };
assert!(msg.contains("Hebrew fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
eprintln!("built without a craft-fonts Hebrew face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
assert_eq!(line.unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCHe"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, typed);
// Every glyph has an outline, so nothing is drawn as a box.
let p = pdfcraft_model::pages(&reopened).swap_remove(0);
let res = reopened.resolve(p.dict.get(b"Resources").unwrap());
let fonts = reopened.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = reopened.resolve(fonts.as_dict().unwrap().get(b"PCHe").unwrap());
let procs = reopened.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
let procs = procs.as_dict().unwrap();
assert_eq!(procs.iter().count(), typed.chars().collect::<std::collections::HashSet<_>>().len());
for (_, glyph) in procs.iter() {
let pdfcraft_cos::Object::Stream(s) = &*reopened.resolve(glyph) else { panic!("glyph stream") };
let path = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(path.starts_with(|c: char| c.is_ascii_digit()) && path.contains(" d1"), "{path}");
}
// A whole paragraph too, right-aligned.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "עברית מלאה").unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה");
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
@@ -2051,6 +2328,37 @@ fn hostile_form_xobjects_are_read_once() {
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn added_thai_text_wraps_clusters_and_survives_reopen() {
let mut doc = fixture();
let t = AddedText {
rect: [20.0, 100.0, 250.0, 300.0], text: "สวัสดี น้ำ ผู้ใช้ ปู่ ๑๒๓ Hello 123".into(), size: 16.0, ..AddedText::default()
};
if pdfcraft_fonts::document_thai_font().is_none() {
assert_eq!(first_undrawable(&t), Some('ส'));
let before = list_added(&doc);
let err = add_content(&mut doc, 0, &Content::Text(t)).unwrap_err().to_string();
assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(list_added(&doc), before);
return;
}
assert_eq!(first_undrawable(&t), None);
add_content(&mut doc, 0, &Content::Text(t.clone())).unwrap();
let reopened = reopen(&doc);
assert_eq!(list_added(&reopened)[0].content, Content::Text(AddedText { rect: super::added::text_rect(&t), ..t.clone() }));
let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() };
assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]);
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "แก้ไข น้ำ".into(), ..t.clone() })).unwrap();
assert!(!own(&doc, 0).is_empty());
let bad = Content::Text(AddedText { text: "ไทย 日".into(), ..t });
let before = list_added(&doc);
assert!(update_content(&mut doc, 0, 0, &bad).is_err());
assert_eq!(list_added(&doc), before);
delete_content(&mut doc, 0, 0).unwrap();
assert!(list_added(&doc).is_empty());
assert!(arabic_font_names(&doc, 0).is_empty());
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
@@ -2116,13 +2424,25 @@ fn group_moves_preserve_small_offsets_under_large_transforms() {
let before = editable_objects(&doc, 0).unwrap();
let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::<Vec<_>>();
assert_eq!(targets.len(), 2);
let image_before = images::page_images(&doc, 0).unwrap()[0].rect;
let offset = [0.00123456789, -0.00987654321];
move_objects(&mut doc, 0, &targets, offset).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
let reopened = reopen(&doc);
let image_after = images::page_images(&reopened, 0).unwrap()[0].clone();
assert!(
close(image_after.rect, [image_before[0] + offset[0], image_before[1] + offset[1], image_before[2] + offset[0], image_before[3] + offset[1]]),
"full image placement preserves sub-point movement"
);
let after = editable_objects(&reopened, 0).unwrap();
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] };
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
if old.target.kind == crate::ObjectKind::Image {
// The full placement moves; the visible right/top edges stay at the page crop.
assert_eq!(Some(new.rect), image_after.visible_rect);
} else {
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
}
}
}
@@ -2432,3 +2752,513 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() {
assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory");
}
}
#[test]
fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() {
for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "שלום עולם", "Latin text"] {
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = text_page(&format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, logical, "ActualText is already in typing order");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical);
}
}
}
#[test]
fn latin_ligature_actual_text_stays_on_its_line() {
// Word and Chrome wrap ligatures in ActualText spans mid-word ("fi" drawn, "fi" meant).
let hex: String = "fi".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = text_page(&format!("BT /F1 12 Tf 72 700 Td (de) Tj /Span <</ActualText <FEFF{hex}>>> BDC (X) Tj EMC (ned well) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["defined well"]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, "defined well");
}
}
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = hebrew_page(&format!("BT /F1 12 Tf 72 700 Td (EDCBA) Tj /Span <</ActualText <FEFF{hex}>>> BDC (FGHD) Tj EMC (EDCBA) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["בדיוק", "שלום", "בדיוק"]);
}
}
#[test]
fn replacing_arabic_actual_text_removes_the_whole_original_drawing() {
let logical = "مرحبا بالعالم";
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
let mut doc = text_page(&format!(
"/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC \
BT /F1 12 Tf 72 600 Td (Keep this line) Tj ET"
));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, logical);
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Replacement", "Keep this line"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(abcd)") && !content.contains("(efgh)"));
}
fn mixed_region_page() -> Document {
let mut doc = text_page(
"BT /F2 1 Tf 18 0 0 18 123 700 Tm (TITLE) Tj ET \
BT /F2 1 Tf 12 0 0 12 117.6 676 Tm (two lines) Tj ET \
BT /F2 1 Tf 12 0 0 12 135.6 652 Tm (last) Tj ET \
BT 0 0.2 0.5 rg /F2 10 Tf 72 600 Td (About) Tj ET \
BT 0 g /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf 0 0.2 0.5 rg (bold) Tj /F1 10 Tf 0 g ( text.) Tj ET \
BT 0.2 0.4 0.7 rg /F3 8 Tf 72 566 Td (Italic footer) Tj ET \
BT 0 g /F1 10 Tf 400 600 Td (Other column) Tj ET",
);
for (obj, base) in [(5, "Courier"), (6, "Courier-Bold")] {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
doc.set(pdfcraft_cos::ObjRef::new(obj, 0), Object::Dict(f));
}
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Courier-Oblique"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let font = doc.add(Object::Dict(f));
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
fonts.set(b"F3".to_vec(), Object::Ref(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc
}
#[test]
fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() {
let doc = mixed_region_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nPlain bold text.\nItalic footer");
assert_eq!(blocks[1].rows.len(), 3);
assert_eq!(blocks[1].lines.len(), 5);
assert_eq!(blocks[2].text, "Other column");
}
#[test]
fn explicit_editor_rows_survive_single_run_edit_and_reopen() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET");
let text = "First row\nSecond row\nLast row";
let layout = text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap();
text::replace_block(&mut doc, 0, 0, text).unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks[0].editor_text, text);
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(Some(blocks[0].rect), layout.draft_rect);
}
#[test]
fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen() {
let original = mixed_region_page();
let before = text::text_lines(&original, 0).unwrap();
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
let mut doc = original.clone();
text::replace_block(&mut doc, 0, 1, &block.editor_text.replace("Plain", "Other")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nOther bold text.\nItalic footer");
let lines = text::text_lines(&reopened, 0).unwrap();
for word in ["About", "bold", "Italic footer"] {
let source = before.iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!(
(after.base_font.as_str(), after.size, after.color, after.bold, after.italic),
(source.base_font.as_str(), source.size, source.color, source.bold, source.italic)
);
assert!((after.origin_baseline() - source.origin_baseline()).abs() < 0.001);
}
for i in [0, 2] {
assert_eq!(blocks[i].editor_text, text::text_blocks(&original, 0).unwrap()[i].editor_text);
assert_eq!(blocks[i].rect, text::text_blocks(&original, 0).unwrap()[i].rect);
}
let mut removed = original.clone();
text::replace_block(&mut removed, 0, 1, "").unwrap();
let after = text::text_blocks(&reopen(&removed), 0).unwrap();
assert_eq!(after.len(), 2);
assert_eq!(after[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(after[1].text, "Other column");
let mut preview = original.clone();
text::clear_block(&mut preview, 0, 1).unwrap();
assert_eq!(text::text_blocks(&reopen(&preview), 0).unwrap().len(), 2);
assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3);
}
#[test]
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
let mut doc = mixed_region_page();
let block = text::text_blocks(&doc, 0).unwrap()[1].clone();
let a = block.editor_text.find("Plain").unwrap();
let b = block.editor_text.find("bold").unwrap();
let style = BlockStyle {
ranges: vec![
RangeStyle {
range: a..a + 5,
family: None,
resource: None,
size: Some(15.0),
color: Some([0.8, 0.1, 0.2]),
underline: Some(true),
char_spacing: None,
scale: None,
},
RangeStyle {
range: b..b + 4,
family: Some((Family::Courier, false, true)),
resource: None,
size: None,
color: None,
underline: None,
char_spacing: Some(0.2),
scale: Some(90.0),
},
],
width: Some(300.0),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap();
doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let plain = lines.iter().find(|l| l.text == "Plain").unwrap();
assert_eq!((plain.size, plain.color), (15.0, [0.8, 0.1, 0.2]));
let bold = lines.iter().find(|l| l.text == "bold").unwrap();
assert_eq!(bold.base_font, "Courier-Oblique");
let original = mixed_region_page();
for word in ["About", "Italic footer"] {
let prior = text::text_lines(&original, 0).unwrap().into_iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!((after.size, after.color, &after.font, after.baseline()), (prior.size, prior.color, &prior.font, prior.baseline()));
}
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let invalid = BlockStyle {
ranges: vec![RangeStyle {
range: 1..2,
family: None,
resource: None,
size: Some(12.0),
color: None,
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert!(text::rewrite_block(&mut doc, 0, 1, Some("é bad"), &invalid).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn centered_mixed_size_title_keeps_three_rows_when_changed() {
let mut doc = mixed_region_page();
let title = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::replace_block(&mut doc, 0, 0, &title.editor_text.replace("last", "tail")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\ntail");
assert_eq!(blocks[0].rows.len(), 3);
let lines = text::text_lines(&reopened, 0).unwrap();
for row in &blocks[0].rows {
let line = &lines[row[0]];
assert!(((line.rect[0] + line.rect[2]) / 2.0 - 150.0).abs() < 0.01);
}
}
#[test]
fn image_selection_bounds_respect_clips_and_restore_graphics_state() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let content = b"q 1 0 0 1 10 20 cm 20 30 100 80 re W* n q 1200 0 0 1400 -500 -600 cm /Im0 Do Q Q q 200 0 0 100 100 100 cm /Im0 Do Q q 0 0 10 10 re W n 100 0 0 100 200 200 cm /Im0 Do Q";
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 3, "fully clipped placements retain source indexes");
assert_eq!(before[0].visible_rect, Some([30.0, 50.0, 130.0, 130.0]));
assert_eq!(before[1].visible_rect, Some(before[1].rect), "Q restores the prior clip");
assert_eq!(before[2].visible_rect, None);
let objects = crate::editable_objects(&doc, 0).unwrap();
assert_eq!(objects.len(), 2, "hidden images cannot intercept selection");
assert_eq!(objects[0].rect, before[0].visible_rect.unwrap());
let offset = [12.0, 14.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(pdfcraft_content::Matrix::translate(offset[0], offset[1]).0)).unwrap();
let reopened = reopen(&doc);
let after = images::page_images(&reopened, 0).unwrap();
assert_eq!(after[0].visible_rect, Some([42.0, 64.0, 142.0, 144.0]), "an exclusive crop moves with the image even across nested graphics states");
for (a, b) in after[0].rect.iter().zip(before[0].rect.iter()).enumerate() {
assert!((b.0 - b.1 - offset[a % 2]).abs() < 0.001);
}
assert_eq!(after[1].rect, before[1].rect, "neighbor placement unchanged");
assert_eq!(after[1].visible_rect, before[1].visible_rect);
assert_eq!(after[1].matrix, before[1].matrix);
assert_eq!(after[1].object, before[1].object);
}
#[test]
fn image_clip_path_and_graphics_state_continue_across_content_streams() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let pieces: [&[u8]; 3] = [b"q 20 30 40 50 re W", b"n 1000 0 0 1000 -300 -400 cm /Im0 Do", b"Q q 50 0 0 50 100 200 cm /Im0 Do Q"];
let refs: Vec<_> = pieces.into_iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images[0].visible_rect, Some([20.0, 30.0, 60.0, 80.0]));
assert_eq!(images[1].visible_rect, Some([100.0, 200.0, 150.0, 250.0]));
images::change_image(
&mut doc,
0,
0,
&images::ImageChange::Transform(images::rect_to_rect(images[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0])),
)
.unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0]), "an exclusive crop scales with its image across streams");
assert_eq!(after[1].matrix, images[1].matrix, "graphics state restored across stream boundaries");
}
#[test]
fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
// The moved clip's `cm` is written with the content stream's number precision.
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
assert!(near(after.visible_rect.unwrap(), target), "{:?} vs {target:?}", after.visible_rect);
assert!(near(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)), "full pixels use the same crop transform");
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [30.0, 130.0]);
}
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 40 200 Td (Shared caption) Tj ET Q",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
let transform = [1.2, 0.0, 0.0, 1.2, 0.0, 0.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), before[0].visible_rect.unwrap()), "a shared clip stays where it is");
assert_eq!(after[0].object, before[0].object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform([0.5, 0.0, 0.0, 0.5, 0.0, 0.0])).unwrap();
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
}
#[test]
fn private_image_crop_with_intervening_state_resizes_across_streams() {
let variants: Vec<Vec<&[u8]>> = vec![
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W* n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
];
for pieces in variants {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(after.visible_rect.unwrap(), target), "the private crop follows the image without a literal operator pattern");
assert!(close(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)));
assert_eq!(after.object, before.object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
}
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let bytes =
b"q 0 0 m 0 200 100 200 100 0 c h W n 300 0 0 300 0 0 cm /Im0 Do Q q 100 100 100 100 re 125 125 50 50 re W* n 400 0 0 400 0 0 cm /Im0 Do Q";
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert!(close(images[0].visible_rect.unwrap(), [0.0, 0.0, 100.0, 150.0]), "cubic extrema, not controls");
assert!(images[0].contains([50.0, 100.0]));
assert!(!images[0].contains([2.0, 140.0]), "empty corners of curve bounds are not artwork");
assert!(images[1].contains([110.0, 110.0]));
assert!(!images[1].contains([150.0, 150.0]), "even-odd hole");
images::change_image(&mut doc, 0, 1, &images::ImageChange::Transform([1.2, 0.0, 0.0, 1.2, 10.0, 15.0])).unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(!after[1].contains([190.0, 195.0]), "the transformed even-odd hole remains empty");
assert!(after[1].contains([142.0, 147.0]), "the transformed filled corner remains selectable");
assert_eq!(after[0].visible_rect, images[0].visible_rect, "the neighbouring curved clip stays put");
}
#[test]
fn marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite() {
let mut doc =
text_page("/P << /MCID 0 >> BDC BT /F1 12 Tf 40 500 Td (One) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 40 482 Td (Two) Tj ET EMC");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "distinct explicit paragraphs win over nearby geometry");
let page = pdfcraft_model::pages(&doc)[0].obj;
let parts: [&[u8]; 2] =
[b"/P << /MCID 2 >> BDC BT /F1 12 Tf 40 500 Td (First row) Tj ET", b"q 1 0 0 1 10 12 cm BT /F1 12 Tf 30 470 Td (Second row) Tj ET Q EMC"];
let refs = parts.iter().map(|p| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), p))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let block = text::text_blocks(&doc, 0).unwrap();
assert_eq!(block.len(), 1, "one marked paragraph across streams and coordinate spaces");
assert_eq!(block[0].editor_text, "First row\nSecond row");
let original = text::text_lines(&doc, 0).unwrap();
text::rewrite_block(&mut doc, 0, 0, Some("First row\nChanged row"), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
for (a, b) in after.iter().zip(original) {
assert!(close(a.rect, b.rect) || a.baseline() == b.baseline());
}
assert_eq!(after[1].text, "Changed row");
}
#[test]
fn logical_indic_with_nuktas_not_reordered_on_standard_font() {
// Already logical Hindi text with nuktas (बढ़िया) must not be corrupted by pre-base reordering
// when the font does not require visual order.
let hex_actual = "<FEFF092C0922093C093F092F093E>";
let doc = text_page(&format!("/Span << /ActualText {hex_actual} >> BDC BT /F1 12 Tf 40 500 Td (ab) Tj ET EMC"));
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
assert!(!lines[0].visual_order);
}
/// One page in a font named `base_font` whose ToUnicode maps A ब, B ि, C ढ़, D य, E ा, F क, G त.
/// A legacy visual-order font draws "बढ़िया" as `(ABCDE)` (ब ि ढ़ या) and "किताब" as `(BFGEA)`.
fn devanagari_page(base_font: &str, content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 8 beginbfchar \
<20> <0020> <41> <092C> <42> <093F> <43> <0922093C> <44> <092F> <45> <093E> <46> <0915> <47> <0924> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let widths: Vec<&str> = (32..=71)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let font = format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} /FirstChar 32 /LastChar 71 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
);
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", cmap),
])
}
/// A legacy visual-order font (Kruti Dev) whose ToUnicode gives the glyphs in drawing order: the
/// pre-base vowel sign is read back after its consonant, for editing and for reading.
#[test]
fn legacy_indic_font_flags_visual_order_and_reorders_pre_base_vowels() {
let doc = devanagari_page("KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// Typed (logical) text is written back in the font's visual order and reads back as typed.
#[test]
fn legacy_indic_font_edits_are_written_in_visual_order() {
let mut doc = devanagari_page("ABCDEF+KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let kitab = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}";
assert_eq!(text::replace_line(&mut doc, 0, 0, kitab).unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(BFGEA)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, kitab);
}
/// `/ActualText` is already in logical order, even in a visual-order font: "बढ़िया" must not
/// become "बढ़यिा".
#[test]
fn logical_actual_text_in_a_legacy_indic_font_is_not_reordered() {
let doc = devanagari_page("KrutiDev010", "/Span << /ActualText <FEFF092C0922093C093F092F093E> >> BDC BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET EMC");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// A modern subset font with a logical ToUnicode and no GSUB in the PDF (subsets and bare CFF
/// often drop it) is not visual order: its text is read as it is.
#[test]
fn subset_indic_font_without_legacy_name_keeps_logical_text() {
let doc = devanagari_page("ABCDEF+NotoSansDevanagari-Regular", "BT /F1 12 Tf 40 500 Td (ACBDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(!lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
}
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
#[test]
fn an_items_measured_face_is_the_face_it_is_written_as() {
for family in [Family::Helvetica, Family::Times, Family::Courier] {
for bold in [false, true] {
for italic in [false, true] {
let written = family.base_font(bold, italic);
let measured = family.std14(bold, italic);
assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}");
// ... and the public measure reaches that face, rather than an upright stand-in.
assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}");
}
}
}
// The faces really do differ, so the assertions above are not all comparing the same number.
let times = |italic| Family::Times.width("Anna", 12.0, false, italic);
assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman");
// Courier is monospaced in every style: equal widths here are correct, not a collapsed face.
let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic);
assert_eq!(courier(false), courier(true));
}
+666 -148
View File
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
//! Geometry-based regions and mixed-style rewriting of existing text.
use super::*;
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm));
a.scope == b.scope
&& [0, 2].iter().all(|axis| {
let ax = at.0.get(*axis).copied().unwrap_or(0.0);
let ay = at.0.get(axis + 1).copied().unwrap_or(0.0);
let bx = bt.0.get(*axis).copied().unwrap_or(0.0);
let by = bt.0.get(axis + 1).copied().unwrap_or(0.0);
let al = ax.hypot(ay);
let bl = bx.hypot(by);
al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001
})
}
fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
}
fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union)
}
pub(super) fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, true)
}
/// Exporters currently represent a block with one style. Keep their style boundaries until
/// they support rich runs, while edit selection may encompass the complete visual region.
pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, false)
}
/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`])
/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does.
fn across(line: &TextLine) -> f64 {
let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0;
let h = m[0].hypot(m[1]);
if h.is_finite() && h > 1e-9 { h } else { line.origin.k }
}
/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image.
fn invisible(line: &TextLine) -> bool {
line.origin.state.render_mode == 3
}
fn same_style(a: &TextLine, b: &TextLine) -> bool {
same_font(a, b.font.as_bytes(), b.face.as_deref())
&& a.base_font == b.base_font
&& a.bold == b.bold
&& a.italic == b.italic
&& a.color == b.color
&& (a.rotation() - b.rotation()).abs() < 0.01
&& (a.size - b.size).abs() < 0.01
}
fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
let mut rows: Vec<Vec<usize>> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| {
let gap = line.rect[0] - prev.rect[2];
// OCR words (invisible text over a scan) join only across a word space: a wider gap
// is a table cell or a tab stop, which a rewrite would close up.
let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6));
scanned
&& (mixed || same_style(prev, line))
&& same_plane(prev, line)
&& (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2
&& gap >= -prev.size.min(line.size) * 0.2
&& gap < prev.size.max(line.size) * 1.5
// Separate same-style text objects that merely touch or overlap, with no space
// between them, are separate labels (for example a paragraph dragged against
// another, #594). Style runs and words separated by a space or gap still join.
&& (prev.origin.bt_op == line.origin.bt_op
|| gap > prev.size.min(line.size) * 0.01
|| !same_style(prev, line)
|| prev.text.ends_with(char::is_whitespace)
|| line.text.starts_with(char::is_whitespace))
});
if joins && let Some(row) = rows.last_mut() {
row.push(i);
} else {
rows.push(vec![i]);
}
}
let mut blocks: Vec<TextBlock> = Vec::new();
for row in rows {
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
let Some(rect) = row_rect(&row, lines) else { continue };
let joins = blocks.last().is_some_and(|block| {
let Some(prev_row) = block.rows.last() else { return false };
let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false };
let Some(pr) = row_rect(prev_row, lines) else { return false };
let gap = prev.origin.baseline - first.origin.baseline;
let tol = prev.size.max(first.size).max(1.0);
let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol;
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
&& same_plane(prev, first)
&& aligned
&& first.size.min(prev.size) >= first.size.max(prev.size) * 0.4
&& gap > first.size.min(prev.size) * 0.8
&& gap < first.size.max(prev.size) * 2.5
});
if !joins {
blocks.push(TextBlock {
text: String::new(),
editor_text: String::new(),
spans: Vec::new(),
rect,
base_font: first.base_font.clone(),
size: first.size,
bold: first.bold,
italic: first.italic,
color: first.color,
rotation: first.rotation(),
lines: Vec::new(),
rows: Vec::new(),
invisible: true,
});
}
let Some(block) = blocks.last_mut() else { continue };
let mut row_text = String::new();
if !block.rows.is_empty() {
block.editor_text.push('\n');
}
for &index in &row {
let Some(line) = lines.get(index) else { continue };
// Some producers position separate words without encoding a space.
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
&& !row_text.ends_with(char::is_whitespace)
&& !line.text.starts_with(char::is_whitespace)
&& line.rect[0] - prev.rect[2] > line.size * 0.12
{
row_text.push(' ');
block.editor_text.push(' ');
}
let start = block.editor_text.len();
block.editor_text.push_str(&line.text);
block.spans.push((start..block.editor_text.len(), index));
row_text.push_str(&line.text);
block.invisible &= line.origin.state.render_mode == 3;
block.lines.push(index);
}
if !block.text.is_empty() {
if block.text.ends_with('-') {
block.text.pop();
} else {
block.text.push(' ');
}
}
block.text.push_str(row_text.trim_matches(paragraph_separator));
block.rect = union(block.rect, rect);
block.rows.push(row);
}
blocks
}
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
fn shape_source(
doc: &Document,
fonts: &Dict,
name: &str,
text: &str,
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
if !pdfcraft_fonts::source_shaping::required(text) {
return Ok(None);
}
let source =
fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?;
let family = Metrics::from_dict(doc, &source).base_font;
let mut candidates = vec![source];
candidates.extend(
fonts
.iter()
.filter_map(|(_, f)| doc.dict(f))
.filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)),
);
for font in candidates {
if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)
&& pdfcraft_fonts::embedding::covers(&bytes, text)
&& let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text)
{
return Ok(Some((bytes, shaped, family)));
}
}
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
}
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
pub(super) fn rewrite_styled_block(
doc: &mut Document,
page: usize,
block: &TextBlock,
lines: &[TextLine],
text: Option<&str>,
style: &BlockStyle,
) -> Result<LineEdit, EditError> {
let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
let draft = match text {
Some(t) if t != block.text => t,
_ => &block.editor_text,
};
if draft.len() > 4_000_000 {
return Err(EditError::Invalid("the replacement text is too large".into()));
}
if draft == block.editor_text && *style == BlockStyle::default() {
return Ok(LineEdit { substituted: None });
}
let mut ranges: Vec<_> = style.ranges.iter().collect();
ranges.sort_by_key(|r| r.range.start);
if ranges.len() > 1024
|| ranges.iter().any(|r| {
r.range.start >= r.range.end
|| draft.get(r.range.clone()).is_none()
|| [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite())
|| r.size.is_some_and(|n| n <= 0.0 || n > 14400.0)
|| r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n)))
})
|| ranges.windows(2).any(|r| r[0].range.end > r[1].range.start)
{
return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into()));
}
let mut styled_lines = lines.to_vec();
let mut styled_spans = Vec::new();
let mut underlines = HashMap::new();
let mut overrides = HashMap::new();
for (span, source) in block.edited_spans(draft) {
let mut edges = vec![span.start, span.end];
for r in &ranges {
for edge in [r.range.start, r.range.end] {
if span.contains(&edge) {
edges.push(edge);
}
}
}
edges.sort_unstable();
edges.dedup();
for bounds in edges.windows(2) {
let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue };
let mut index = source;
let selected = ranges.iter().find(|r| r.range.contains(&start)).copied();
if selected.is_some() || style.family.is_some() || style.bold.is_some() {
let default_range = RangeStyle {
range: start..end,
family: None,
resource: None,
size: None,
color: None,
underline: None,
char_spacing: None,
scale: None,
};
let r = selected.unwrap_or(&default_range);
let mut line = lines.get(source).unwrap_or(first).clone();
if let Some((family, bold, italic)) =
r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic)))
{
let base = family.base_font(bold, italic);
line.font = format!("PCRange{}", base.replace('-', ""));
line.base_font = base.to_owned();
line.bold = bold;
line.italic = italic;
}
if let Some(resource) = &r.resource {
line.font = resource.clone();
}
if let Some(size) = r.size {
line.size = size;
}
if let Some(color) = r.color {
line.color = color;
line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())];
}
if let Some(spacing) = r.char_spacing {
line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6);
}
if let Some(scale) = r.scale {
line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0);
}
index = styled_lines.len();
styled_lines.push(line);
if let Some(r) = selected {
overrides.insert(index, r);
}
if let Some(underline) = r.underline {
underlines.insert(index, underline);
}
}
styled_spans.push((start..end, index));
}
}
let lines = styled_lines.as_slice();
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(&line.base_font));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f));
}
let mut metrics = std::collections::BTreeMap::new();
for (name, object) in fonts.iter().take(256) {
if let Some(font) = doc.resolve(object).as_dict() {
metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font));
}
}
for index in &block.lines {
if let Some(line) = lines.get(*index)
&& !metrics.contains_key(&line.font)
&& let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())
{
metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font));
}
}
if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite())
|| style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite()))
{
return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into()));
}
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) {
return Err(EditError::Invalid("the paragraph's position and width must be finite".into()));
}
// A rewritten OCR line grows to the right rather than wrapping onto the scanned line below.
let natural = if block.invisible {
(p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0])
} else {
block.rect[2] - block.rect[0]
};
let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0);
// Each tuple is a character, its source style and an encodable resource name.
let mut chars = Vec::new();
let mut substituted = None;
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
let mut full_faces: HashMap<String, String> = HashMap::new();
for (range, index) in styled_spans {
let source = lines.get(index).unwrap_or(first);
for ch in draft.get(range).unwrap_or_default().chars() {
if ch == '\n' {
chars.push((ch, index, source.font.clone()));
continue;
}
let s = ch.to_string();
let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) {
return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch))));
}
resource.clone()
} else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) {
source.font.clone()
} else if let Some((name, _)) = metrics.iter().find(|(_, m)| {
pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s)
}) {
name.clone()
} else if let Some(face) =
pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s))
{
let key = face.name();
if let Some(name) = full_faces.get(&key) {
name.clone()
} else {
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?;
let font = fonts
.get(name.as_bytes())
.and_then(|f| doc.dict(f))
.ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?;
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
full_faces.insert(key, name.clone());
name
}
} else {
let family = source_family(&source.base_font);
if needs_type3(&s) {
let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch);
if let Some(name) = fallback_names.get(&key) {
chars.push((ch, index, name.clone()));
continue;
}
if fallback_names.len() >= MAX_TYPE3_GLYPHS {
return Err(EditError::Invalid("the replacement has too many fallback glyphs".into()));
}
let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?;
let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
let dict = fonts
.get(name.as_bytes())
.and_then(|f| doc.resolve(f).as_dict().cloned())
.ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?;
metrics.insert(name.clone(), Metrics::from_dict(doc, &dict));
substituted = Some(fallback.family.to_string());
fallback_names.insert(key, name.clone());
name
} else {
let base = family.base_font(source.bold, source.italic);
let name = format!("PCEd{}", base.replace('-', ""));
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f));
fonts.set(name.clone().into_bytes(), Object::Dict(f));
substituted = Some(base.to_string());
name
}
};
chars.push((ch, index, name));
}
}
let size_of = |source: &TextLine, index: usize| {
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
let mut calibration = HashMap::new();
for (index, source) in lines.iter().enumerate() {
if let Some(m) = metrics.get(&source.font) {
let nominal = source_width(
m,
&source.text,
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
source_space(doc, &fonts, m),
source.origin.state.char_spacing,
source.origin.state.word_spacing,
) * source.origin.state.scale
* across(source);
let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some());
let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 };
calibration.insert(index, factor);
}
}
let advance = |item: &(char, usize, String)| -> f64 {
let source = lines.get(item.1).unwrap_or(first);
let Some(m) = metrics.get(&item.2) else { return 0.0 };
source_width(
m,
&item.0.to_string(),
size_of(source, item.1),
source_space(doc, &fonts, m),
overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
.or(style.char_spacing)
.map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)),
source.origin.state.word_spacing,
) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0))
* across(source)
* calibration.get(&item.1).copied().unwrap_or(1.0)
};
let measure = |items: &[(char, usize, String)]| -> Result<f64, EditError> {
let mut width = 0.0;
let mut at = 0;
while let Some(item) = items.get(at) {
let mut end = at + 1;
while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
let segment = items.get(at..end).unwrap_or_default();
let string: String = segment.iter().map(|v| v.0).collect();
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
let source = lines.get(item.1).unwrap_or(first);
let scale =
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
let spacing = overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
.or(style.char_spacing)
.map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6));
width += (shaped.advance * size_of(source, item.1)
+ spacing * string.chars().count() as f64
+ source.origin.state.word_spacing * string.matches(' ').count() as f64)
* scale
* source.origin.k;
} else {
width += segment.iter().map(&advance).sum::<f64>();
}
at = end;
}
Ok(width)
};
let mut rows: Vec<Vec<(char, usize, String)>> = Vec::new();
let mut row = Vec::new();
let mut row_width = 0.0;
// Wrap at word boundaries, retaining explicit source row breaks and whitespace.
let mut at = 0;
while at < chars.len() {
let Some(item) = chars.get(at) else { break };
if item.0 == '\n' {
rows.push(std::mem::take(&mut row));
row_width = 0.0;
at += 1;
continue;
}
let mut end = at + 1;
if !item.0.is_whitespace() {
while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) {
end += 1;
}
}
let word = chars.get(at..end).unwrap_or_default();
let w = measure(word)?;
if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 {
while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) {
row.pop();
}
rows.push(std::mem::take(&mut row));
row_width = 0.0;
}
row.extend_from_slice(word);
row_width += w;
at = end;
}
if !row.is_empty() {
rows.push(row);
}
for row in &rows {
let text: String = row.iter().map(|v| v.0).collect();
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
return Err(EditError::Invalid(
"contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported"
.into(),
));
}
}
let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::<Result<_, _>>()?;
let mut shaped_runs = HashMap::new();
let mut segment_advances = HashMap::new();
for (row_index, row) in rows.iter().enumerate() {
let mut at = 0;
while let Some(item) = row.get(at) {
let mut end = at + 1;
while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::<f64>());
let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect();
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? {
shaped_runs.insert((row_index, at), (bytes, shaped, family));
}
at = end;
}
}
let mut glyph_resources = HashMap::new();
for (key, (bytes, shaped, family)) in &shaped_runs {
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
glyph_resources.insert(*key, resource);
}
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?);
let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
let centre = (block.rect[0] + block.rect[2]) / 2.0;
let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect();
let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min);
let centered = block.rows.len() > 1
&& left_span > first.size * 0.5
&& block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < first.size));
let source_leading = block
.rows
.windows(2)
.filter_map(|rows| {
let a = rows[0].first().and_then(|i| lines.get(*i))?;
let b = rows[1].first().and_then(|i| lines.get(*i))?;
Some((a.origin.baseline - b.origin.baseline).abs())
})
.rfind(|v| *v > 0.0)
.unwrap_or(first.size * 1.5);
let mut baseline = first.origin.baseline;
for (row_index, row) in rows.iter().enumerate() {
let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i));
if style.line_spacing.is_none()
&& style.size.is_none()
&& let Some(anchor) = anchor
{
baseline = anchor.origin.baseline;
}
let anchor = anchor.unwrap_or(first);
let w = measured.get(row_index).copied().unwrap_or(0.0);
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] };
let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width };
let mut x = left
+ match align {
crate::added::Align::Center => (available - w) / 2.0,
crate::added::Align::Right => available - w,
_ => 0.0,
};
let mut pos = 0;
while pos < row.len() {
let Some(item) = row.get(pos) else { break };
let source = lines.get(item.1).unwrap_or(first);
let mut end = pos + 1;
while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) {
end += 1;
}
let segment = row.get(pos..end).unwrap_or_default();
let string: String = segment.iter().map(|c| c.0).collect();
let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?;
let mut state = source.origin.state.clone();
state.font = Some((item.2.clone(), size_of(source, item.1)));
if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) {
state.char_spacing = c / source.origin.k.max(1e-6);
}
if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) {
state.scale = (s / 100.0).clamp(0.1, 10.0);
}
if !shaped_runs.contains_key(&(row_index, pos)) {
state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0);
}
if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) {
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])];
}
let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0;
let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline);
tm[4] += ax;
tm[5] += ay;
let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0);
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
let mut properties = Dict::new();
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
state.font = Some((resource.clone(), size_of(source, item.1)));
// Absolute glyph matrices preserve marks and contextual positions; font-program
// codes are separate from Unicode and ToUnicode/ActualText keep logical text.
let size = size_of(source, item.1);
let mut extra = 0.0;
for (i, glyph) in shaped.glyphs.iter().enumerate() {
let mut matrix = tm;
let x = (glyph.position[0] * size + extra) * state.scale;
let y = glyph.position[1] * size;
matrix[4] += matrix[0] * x + matrix[2] * y;
matrix[5] += matrix[1] * x + matrix[3] * y;
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", matrix.map(n).to_vec()));
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))]));
block_ops.push(Op::new("ET", vec![]));
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
}
block_ops.push(Op::new("EMC", vec![]));
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
} else {
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
block_ops.extend(source_show(
m,
&string,
size_of(source, item.1),
source_space(doc, &fonts, m),
state.char_spacing,
state.word_spacing,
)?);
block_ops.push(Op::new("ET", vec![]));
}
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, item.1) * source.origin.k;
let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))]));
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
block_ops.push(Op::new("l", vec![n(x1), n(y1)]));
block_ops.push(Op::new("S", vec![]));
}
x += segment_width;
pos = end;
}
baseline -= if style.line_spacing.is_none() && style.size.is_none() {
source_leading
} else {
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
};
}
block_ops.push(Op::new("Q", vec![]));
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
let rects: Vec<_> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(lines, &rects);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let o = &first.origin;
let start = first.ops.first().copied().unwrap_or(o.bt_op);
let split = ops
.get(o.bt_op..start)
.unwrap_or_default()
.iter()
.enumerate()
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
let after = if split { &o.state } else { &o.bt_state };
block_ops.extend(after.ops());
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
let at = if split { start } else { o.bt_op };
let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i)));
let contents = rewritten_contents(doc, &streams, data);
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
d.set(b"Resources".to_vec(), Object::Dict(res));
})?;
Ok(LineEdit { substituted })
}
+4
View File
@@ -78,3 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
- 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.
+86
View File
@@ -0,0 +1,86 @@
//! Reviewable structure proposals for pages that have not yet been tagged.
//!
//! A proposal is deliberately not a PDF tag. It records the visible text blocks and the
//! evidence for a tentative role, so a later edit can attach marked content and a parent tree
//! without presenting a font-size guess as a finished accessible document.
use crate::Document;
/// A bounded text block proposed for a structure element.
#[derive(Clone, Debug, PartialEq)]
pub struct Candidate {
pub kind: &'static str,
pub reason: &'static str,
pub text: String,
pub text_truncated: bool,
pub rect: [f64; 4],
pub size: f64,
pub bold: bool,
}
/// The visible text portion of one page's proposed structure.
#[derive(Clone, Debug, PartialEq)]
pub struct Preview {
pub candidates: Vec<Candidate>,
/// Raster or grouped artwork requires separate figure/artifact review.
pub unclassified_images: usize,
/// Text blocks omitted because their geometry or font size was invalid.
pub unclassified_text_blocks: usize,
/// Existing structure must be reconciled before any proposal is applied.
pub existing_structure: bool,
}
const MAX_BLOCKS: usize = 2_048;
const MAX_TEXT_CHARS: usize = 512;
impl Document {
/// Propose text tags for one 0-based page. This reads the current working PDF and does not
/// alter it. Every result needs review before tags are written; image and table semantics
/// are outside this text-only proposal.
pub fn autotag_preview(&self, page: usize) -> Result<Preview, String> {
if self.info.pages.get(page).is_none() {
return Err(format!("page {} is outside the document", page.saturating_add(1)));
}
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let existing_structure = editor.cos.root().is_some_and(|r| editor.cos.get(r).as_dict().is_some_and(|d| d.contains(b"StructTreeRoot")));
let blocks = pdfcraft_edit::text_blocks(&editor.cos, page).map_err(|e| e.to_string())?;
if blocks.len() > MAX_BLOCKS {
return Err(format!("page {} has too many text blocks to preview (maximum {MAX_BLOCKS})", page.saturating_add(1)));
}
let mut sizes: Vec<f64> = blocks.iter().map(|b| b.size).filter(|s| s.is_finite() && *s > 0.0).collect();
sizes.sort_by(f64::total_cmp);
let body_size = sizes.get(sizes.len().saturating_sub(1) / 2).copied().unwrap_or(12.0);
let mut unclassified_text_blocks = 0;
let candidates = blocks
.into_iter()
.filter(|b| {
let valid = !b.text.trim().is_empty() && b.rect.iter().all(|v| v.is_finite()) && b.size.is_finite() && b.size > 0.0;
if !valid {
unclassified_text_blocks += 1;
}
valid
})
.map(|b| {
let mut chars = b.text.chars();
let text: String = chars.by_ref().take(MAX_TEXT_CHARS).collect();
let text_truncated = chars.next().is_some();
let short_line = b.rows.len() == 1 && b.text.chars().count() <= 120;
let heading = short_line && b.size.is_finite() && b.size > body_size * 1.35;
Candidate {
kind: if heading { "H1" } else { "P" },
reason: if heading {
"short, single-line text larger than the page body"
} else {
"visible text block; semantic role needs review"
},
text,
text_truncated,
rect: b.rect,
size: b.size,
bold: b.bold,
}
})
.collect();
Ok(Preview { candidates, unclassified_images: self.page_images(page).len(), unclassified_text_blocks, existing_structure })
}
}
+2 -1
View File
@@ -232,7 +232,8 @@ fn support(a: Point, b: Point, edges: &[Option<Edge>], w: usize, h: usize) -> f6
edges.get(y as usize * w + x as usize).copied().flatten().is_some_and(|e| {
// Either way across: dark-to-light or light-to-dark.
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
d < 0.35 || d > std::f32::consts::PI - 0.35
// A NaN direction is no hit, as the two comparisons it replaces said.
!d.is_nan() && !(0.35..=std::f32::consts::PI - 0.35).contains(&d)
})
});
hits += hit as usize;
+3 -1
View File
@@ -71,7 +71,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Microsoft Word (.docx)", "file-text", "export.docx", Ready),
item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")),
item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")),
item("Presentation (.pptx)", "presentation", "export.pptx", Ready),
item("Image (PNG)", "image", "export.image", Ready),
item("Export all images", "image", "export.all_images", Ready),
item("HTML web page", "file-symlink", "export.html", Ready),
@@ -150,6 +150,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Single file", "file-input", "create.file", Ready),
item("Multiple files", "files", "create.multiple", Ready),
item("Separate PDFs", "files", "create.multiple_separate", Ready),
item("Images", "image", "create.images", Ready),
item("Clipboard", "copy-plus", "create.clipboard", Ready),
item("Blank page", "file-plus-2", "create.blank", Ready),
@@ -461,6 +462,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),
item("Add alternate text", "image", "a11y.alt_text", Ready),
item("Tags panel", "tag", "a11y.tags", Ready),
item("Fix reading order", "list-ordered", "a11y.reading_order", Planned("M12")),
],
}],
+3
View File
@@ -179,6 +179,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"),
c("create.multiple_separate", "Create separate PDFs…", FILE, None, Nothing, "files"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
@@ -310,6 +311,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
c("export.text", "Export to text…", FILE, None, Copying, "type"),
c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"),
c("export.pptx", "Export to PowerPoint…", FILE, None, Copying, "presentation"),
c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"),
c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"),
c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"),
@@ -322,6 +324,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
+28 -8
View File
@@ -64,13 +64,12 @@ impl Session {
let (mut ra, mut rb) = (renderer(a), renderer(b));
let scale = dpi.clamp(18.0, 150.0) / 72.0;
let mut out = Vec::new();
for page in 0..a.info.pages.len().min(b.info.pages.len()) {
for (page, info) in b.info.pages.iter().enumerate().take(a.info.pages.len()) {
let req = pdfcraft_render::RenderRequest { page, scale, ..Default::default() };
let (x, y) = (ra.render(req), rb.render(req));
if x.error.is_some() || y.error.is_some() {
continue;
}
let info = &b.info.pages[page];
let s = y.width as f32 / info.width.max(1e-3);
for r in pdfcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) {
let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s);
@@ -85,7 +84,7 @@ impl Session {
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare(old, new)?;
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.create_from_text("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
}
/// Mark the differences in `new` as comments: highlights over replaced and inserted text
@@ -122,10 +121,11 @@ impl Session {
}
}
/// Export a PDF ▸ Word, HTML or RTF.
/// Export a PDF ▸ Word, PowerPoint, HTML or RTF.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OfficeFormat {
Docx,
Pptx,
Html,
Rtf,
}
@@ -134,6 +134,7 @@ impl OfficeFormat {
pub fn extension(self) -> &'static str {
match self {
OfficeFormat::Docx => "docx",
OfficeFormat::Pptx => "pptx",
OfficeFormat::Html => "html",
OfficeFormat::Rtf => "rtf",
}
@@ -142,6 +143,7 @@ impl OfficeFormat {
pub fn from_extension(ext: &str) -> Option<OfficeFormat> {
match ext.to_ascii_lowercase().as_str() {
"docx" => Some(OfficeFormat::Docx),
"pptx" => Some(OfficeFormat::Pptx),
"html" | "htm" => Some(OfficeFormat::Html),
"rtf" => Some(OfficeFormat::Rtf),
_ => None,
@@ -159,6 +161,16 @@ impl crate::Document {
.iter()
.enumerate()
.map(|(i, info)| {
// Export uses the visible page in points, y up. Raw PDF coordinates include
// CropBox offsets and precede /Rotate and /UserUnit.
let visible_rect = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
};
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
let blocks = pdfcraft_edit::reading_blocks(cos, i)
.unwrap_or_default()
.into_iter()
@@ -167,11 +179,12 @@ impl crate::Document {
let f = b.base_font.to_ascii_lowercase();
pdfcraft_export::Block {
text: b.text,
rect: b.rect,
size: b.size,
rect: visible_rect(b.rect),
size: b.size * unit,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
color: b.color,
rotation: b.rotation - f64::from(info.rotation),
}
})
.collect();
@@ -180,7 +193,13 @@ impl crate::Document {
.iter()
.filter_map(|im| {
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
Some(pdfcraft_export::Image {
ext: if ext == "jpg" { "jpg" } else { "png" },
bytes,
rect: visible_rect(im.rect),
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
rotation: im.matrix[1].atan2(im.matrix[0]).to_degrees() - f64::from(info.rotation),
})
})
.collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
@@ -188,12 +207,13 @@ impl crate::Document {
.collect()
}
/// The document as a Word, HTML or RTF file.
/// The document as a Word, PowerPoint, HTML or RTF file.
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
let pages = self.export_pages();
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
}
+108
View File
@@ -0,0 +1,108 @@
//! Copy Image: find the picture under a point and hand it to the clipboard, as pixels or as a
//! file in its original format. A picture drawn through grouped
//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group.
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object};
use crate::Document;
/// Clipboards copy the whole bitmap; past this many pixels a pixel copy is refused.
const MAX_COPY_PIXELS: u64 = 64_000_000;
/// How deep Form artwork is searched for its picture.
const MAX_FORM_DEPTH: usize = 12;
impl Document {
/// The topmost picture on `page` under user-space point `at`, as its index in
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
let editor = self.editor.as_ref()?;
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
self.page_images(page).iter().rposition(|i| {
inside(&i.rect)
&& match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
_ => false,
}
})
}
/// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):
/// a JPEG as stored in the PDF, otherwise a lossless PNG.
pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
let object = img.object.ok_or("the image has no object")?;
let picture = if img.is_form { picture_in_form(&editor.cos, object).ok_or("this artwork holds no picture")? } else { object };
pdfcraft_create::image_file(&editor.cos, picture)
}
/// Copy Image: picture `index` on `page` at its own resolution, as straight (unpremultiplied)
/// RGBA pixels (width, height, pixels) for the clipboard. Soft masks become alpha.
pub fn page_image_rgba(&self, page: usize, index: usize) -> Result<(u32, u32, Vec<u8>), String> {
let (_, bytes) = self.page_image_copy_file(page, index)?;
let format = image::guess_format(&bytes).map_err(|e| e.to_string())?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes.as_slice()), format);
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_COPY_PIXELS * 4);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
let (w, h) = image::ImageDecoder::dimensions(&decoder);
if w == 0 || h == 0 {
return Err("the image is empty".into());
}
if u64::from(w) * u64::from(h) > MAX_COPY_PIXELS {
return Err(format!("the image is too large to copy ({w} × {h} pixels); use Save Image As"));
}
let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8();
Ok((rgba.width(), rgba.height(), rgba.into_raw()))
}
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
collect(cos, &res, 0, &mut seen, &mut best);
best.map(|(_, r)| r)
}
fn resources(cos: &Cos, stream: ObjRef) -> Option<Dict> {
match &*cos.get(stream) {
Object::Stream(s) => s.dict.get(b"Resources").map(|r| cos.resolve(r)).and_then(|r| r.as_dict().cloned()),
_ => None,
}
}
fn collect(cos: &Cos, res: &Dict, depth: usize, seen: &mut HashSet<ObjRef>, best: &mut Option<(u64, ObjRef)>) {
if depth > MAX_FORM_DEPTH {
return;
}
let Some(xobjects) = res.get(b"XObject").map(|x| cos.resolve(x)).and_then(|x| x.as_dict().cloned()) else { return };
for (_, v) in xobjects.iter() {
let Some(r) = v.as_ref() else { continue };
if !seen.insert(r) {
continue;
}
let obj = cos.get(r);
let Object::Stream(s) = &*obj else { continue };
match s.dict.name(b"Subtype") {
Some(b"Image") => {
let px = |k: &[u8]| u64::try_from(s.dict.int(k).unwrap_or(0)).unwrap_or(0);
let size = px(b"Width").saturating_mul(px(b"Height"));
if size > 0 && best.is_none_or(|(b, _)| size > b) {
*best = Some((size, r));
}
}
Some(b"Form") => {
if let Some(inner) = resources(cos, r) {
collect(cos, &inner, depth + 1, seen, best);
}
}
_ => {}
}
}
}
+27 -2
View File
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub static DATE_LANGUAGES: [DateLanguage; 21] = [
pub static DATE_LANGUAGES: [DateLanguage; 22] = [
DateLanguage {
code: "en",
name: "English",
@@ -325,7 +325,7 @@ pub static DATE_LANGUAGES: [DateLanguage; 21] = [
"Νοεμβρίου",
"Δεκεμβρίου",
]),
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαΐ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μάι", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
days: ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"],
days_short: ["Κυρ", "Δευ", "Τρί", "Τετ", "Πέμ", "Παρ", "Σάβ"],
},
@@ -338,6 +338,28 @@ pub static DATE_LANGUAGES: [DateLanguage; 21] = [
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
},
DateLanguage {
code: "lt",
name: "Lietuvių",
months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"],
months_with_day: Some([
"sausio",
"vasario",
"kovo",
"balandžio",
"gegužės",
"birželio",
"liepos",
"rugpjūčio",
"rugsėjo",
"spalio",
"lapkričio",
"gruodžio",
]),
months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."],
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@@ -651,6 +673,9 @@ mod tests {
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
s.set_date_language(Some("LT")).unwrap();
assert_eq!(s.date_language(), Some("lt"));
assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio"));
s.set_date_language(Some("IT")).unwrap();
assert_eq!(s.date_language(), Some("it"));
assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF");
+585 -31
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -2,6 +2,7 @@
//! About dialog, the home screen, the CLI and the README agree.
use icu_properties::props::Script;
use std::path::{Path, PathBuf};
/// The app's name in ArtCraft URLs (`getartcraft.com/apps/{APP}`, `github.com/storytold/{APP}`).
pub const APP: &str = "pdfcraft";
@@ -128,6 +129,104 @@ pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
Ok(url.to_string())
}
/// Maximum decoded GoToR file-spec size retained by inspection and accepted by the resolver.
pub const MAX_REMOTE_DOCUMENT_PATH_BYTES: usize = 4096;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteDocumentPathError {
Empty,
TooLong,
Hidden,
UrlOrNetwork,
MissingSourcePath,
/// The target doesn't end in `.pdf`.
NotPdf,
/// The target is a directory, device, FIFO or socket rather than a regular file.
NotAFile,
/// The target is larger than [`MAX_REMOTE_DOCUMENT_BYTES`].
TooLarge,
/// The target couldn't be read.
Read(String),
}
impl std::fmt::Display for RemoteDocumentPathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"),
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
Self::NotAFile => f.write_str("the linked path is not a regular file"),
Self::TooLarge => f.write_str("the linked file is too large to open"),
Self::Read(e) => write!(f, "the linked file couldn't be read: {e}"),
}
}
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::Empty);
}
if raw.len() > MAX_REMOTE_DOCUMENT_PATH_BYTES {
return Err(RemoteDocumentPathError::TooLong);
}
if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden);
}
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = path.is_absolute();
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute;
if valid_scheme && !windows_drive {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
}
if drive_absolute {
return Ok(path.to_path_buf());
}
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
}
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
pub const MAX_REMOTE_DOCUMENT_BYTES: u64 = 1 << 30;
/// Read the PDF a GoToR link resolved to. The path comes from an untrusted document, so only a
/// regular `.pdf` file of at most [`MAX_REMOTE_DOCUMENT_BYTES`] is read: a directory, a device such
/// as `/dev/zero` or a FIFO is refused before it is opened, and a file that grows past the cap
/// while it is read is refused too.
pub fn read_remote_document(path: &Path) -> Result<Vec<u8>, RemoteDocumentPathError> {
use std::io::Read;
let is_pdf = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
if !is_pdf {
return Err(RemoteDocumentPathError::NotPdf);
}
let meta = std::fs::metadata(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if !meta.is_file() {
return Err(RemoteDocumentPathError::NotAFile);
}
if meta.len() > MAX_REMOTE_DOCUMENT_BYTES {
return Err(RemoteDocumentPathError::TooLarge);
}
let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) {
return Err(RemoteDocumentPathError::TooLarge);
}
Ok(bytes)
}
/// Email-address parameters that make an email app read a file from the user's computer: Evolution,
/// KMail and Claws Mail attach the file named by `attach` (Evolution and KMail also by
/// `attachment`), and Claws Mail reads an `insert` file into the message body. Names are matched as
@@ -339,6 +438,54 @@ fn mixes_scripts(label: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn remote_documents_must_be_regular_pdf_files() {
use super::{RemoteDocumentPathError as E, read_remote_document as read};
let dir = std::env::temp_dir().join(format!("pdfcraft-gotor-read-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
std::fs::write(dir.join("doc.PDF"), b"%PDF-1.7\n").unwrap();
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
assert_eq!(read(&dir.join("doc.PDF")).as_deref(), Ok(&b"%PDF-1.7\n"[..]));
assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused");
assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf));
assert_eq!(read(&dir), Err(E::NotPdf));
assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_))));
#[cfg(unix)]
{
// A device (like /dev/zero, which never ends) and a FIFO (which blocks) are never opened.
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
assert_eq!(read(&dir.join("zero.pdf")), Err(E::NotAFile));
let fifo = dir.join("pipe.pdf");
if std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
assert_eq!(read(&fifo), Err(E::NotAFile));
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remote_document_paths_are_local_and_source_relative() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
{
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
}
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
}
#[test]
fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
+74 -10
View File
@@ -21,10 +21,30 @@ pub struct OcrSettings {
/// Language code from [`LANGUAGES`] (the models read the Latin alphabet, whatever this says).
pub language: String,
/// Leave pages that already have text alone (Acrobat reports "page contains renderable
/// text" and skips them).
/// text" and skips them). When `false`, such pages are read too (a picture on a page that
/// also has text, #1028) and only the words that aren't already there are added.
pub skip_text_pages: bool,
}
/// A recognised word whose box is at least this much covered by the page's own text is that
/// text read again, not a word in a picture.
const ALREADY_ON_PAGE: f32 = 0.5;
/// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page
/// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the
/// sum is capped at the whole word.
fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 {
// `max(0.0)` also turns a NaN side into 0: a degenerate box covers nothing.
let area = |[x0, y0, x1, y1]: [f32; 4]| (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
let total = area(word);
if !total.is_finite() || total <= 0.0 {
return 0.0;
}
let inside: f32 =
text.iter().map(|g| area([word[0].max(g[0]), word[1].max(g[1]), word[2].min(g[2]), word[3].min(g[3])])).filter(|a| a.is_finite()).sum();
(inside / total).min(1.0)
}
impl Default for OcrSettings {
fn default() -> Self {
OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true }
@@ -36,6 +56,8 @@ impl Default for OcrSettings {
pub struct OcrPage {
pub page: usize,
pub words: Vec<PlacedWord>,
/// Words recognised but left out because the page's own text already has them there.
pub already_on_page: usize,
/// Why the page was not read, if it was skipped.
pub skipped: Option<String>,
}
@@ -88,13 +110,16 @@ impl OcrJob {
break;
}
let Some(info) = self.infos.get(page) else { continue };
let skip = |why: &str| OcrPage { page, words: Vec::new(), skipped: Some(why.into()) };
if self.settings.skip_text_pages {
let t = r.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
if t.text.as_ref().is_some_and(|t| !t.plain_text().trim().is_empty()) {
out.push(skip("the page already contains text"));
continue;
}
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()) };
// The boxes of the page's own text (an earlier OCR layer's included), in view space.
let existing: Vec<[f32; 4]> = r
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
.unwrap_or_default();
if self.settings.skip_text_pages && !existing.is_empty() {
out.push(skip("the page already contains text"));
continue;
}
// A scan is read at its own resolution (enlarging it only blurs the letters), at most
// the chosen one.
@@ -117,8 +142,11 @@ impl OcrJob {
let [u, v] = info.view_to_user(x / scale, y / scale);
[u as f64, v as f64]
};
let words = lines.iter().flat_map(|l| &l.words).map(|w| PlacedWord::place(w, to_user)).collect();
out.push(OcrPage { page, words, skipped: None });
// Words the page's own text already has are not added a second time.
let (fresh, already): (Vec<_>, Vec<_>) =
lines.iter().flat_map(|l| &l.words).partition(|w| covered(w.rect.map(|v| v / scale), &existing) < ALREADY_ON_PAGE);
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect();
out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None });
}
progress(total, total);
out
@@ -210,3 +238,39 @@ pub fn recognize_file(
}
Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages })
}
#[cfg(test)]
mod tests {
use super::*;
/// #1028: a recognised word over the page's own text is that text again; one in an empty
/// part of the page (a picture) is new.
#[test]
fn words_over_existing_text_count_as_already_there() {
// Glyph em boxes of "Total" at 12 pt, five letters side by side.
let glyphs: Vec<[f32; 4]> = (0..5).map(|i| [100.0 + 7.0 * i as f32, 50.0, 107.0 + 7.0 * i as f32, 62.0]).collect();
// OCR's tight box around the same ink.
assert!(covered([100.5, 52.0, 134.0, 61.0], &glyphs) >= ALREADY_ON_PAGE);
// A word in a picture further down, and one beside the text on the same line.
assert_eq!(covered([100.0, 200.0, 140.0, 212.0], &glyphs), 0.0);
assert_eq!(covered([140.0, 52.0, 170.0, 61.0], &glyphs), 0.0);
// Touching the text's last letter is not enough.
assert!(covered([131.0, 52.0, 170.0, 61.0], &glyphs) < ALREADY_ON_PAGE);
// A page without text has nothing to cover a word.
assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0);
}
#[test]
fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() {
// A glyph drawn twice (fake bold) does not cover more than the whole word.
let twice = [[0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]];
assert_eq!(covered([0.0, 0.0, 10.0, 10.0], &twice), 1.0);
// Empty, inverted and non-finite boxes cover nothing and are covered by nothing.
for word in [[5.0, 5.0, 5.0, 9.0], [9.0, 0.0, 1.0, 9.0], [f32::NAN, 0.0, 9.0, 9.0], [0.0, 0.0, f32::INFINITY, 9.0]] {
assert_eq!(covered(word, &twice), 0.0, "{word:?}");
}
let broken = [[f32::NAN; 4], [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]];
let c = covered([0.0, 0.0, 10.0, 10.0], &broken);
assert!((0.0..=1.0).contains(&c), "{c}");
}
}
+474
View File
@@ -0,0 +1,474 @@
//! Print readiness, verification only.
//!
//! Measures what a press looks at and changes nothing: effective image resolution where each
//! image is drawn, the page boxes (media, crop, trim, bleed), the output intent, fonts that
//! aren't embedded, and the existing PDF/A-2b result. There is no fixup and no generated colour
//! profile. A screen profile such as sRGB is reported as a screen profile, not accepted as a
//! press condition.
use pdfcraft_cos::Document;
use pdfcraft_edit::PageImage;
use pdfcraft_preflight::{Issue, Level};
use crate::Document as OpenDocument;
/// Below this, ordinary sheet printing cannot hold the picture. Large-format work sometimes can.
const FAIL_PPI: f64 = 150.0;
/// Ordinary close-up print. Between this and [`FAIL_PPI`] the picture is usable but soft.
const WARN_PPI: f64 = 300.0;
/// Less than this past the trim, on the tightest side, is not a normal 3 mm bleed.
const MIN_BLEED_MM: f64 = 2.0;
/// One thing a printer should see, without the file having been rewritten.
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
pub id: &'static str,
pub severity: &'static str,
pub message: String,
/// 1-based page, when the finding is about one page.
pub page: Option<u32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PageBoxes {
pub page: u32,
pub media_mm: [f64; 2],
pub crop_mm: [f64; 2],
pub trim_mm: [f64; 2],
pub bleed_mm: [f64; 2],
pub trim_set: bool,
pub bleed_set: bool,
/// Left, bottom, right, top, in millimetres, from the trim out to the bleed.
pub bleed_margin_mm: [f64; 4],
}
#[derive(Clone, Debug, PartialEq)]
pub struct DrawnImage {
pub page: u32,
pub name: String,
pub ppi: f64,
pub pixels: [u32; 2],
pub size_mm: [f64; 2],
}
#[derive(Clone, Debug, PartialEq)]
pub struct PrintReadiness {
pub pages: Vec<PageBoxes>,
pub images: Vec<DrawnImage>,
pub output_intents: Vec<String>,
pub fonts_not_embedded: Vec<String>,
pub pdfa_issues: Vec<Issue>,
pub findings: Vec<Finding>,
/// Image placements whose resolution couldn't be measured (a degenerate or non-finite
/// placement matrix). They are neither passed nor failed.
pub resolution_unknown: usize,
}
impl PrintReadiness {
/// A plain-text report. The document is unchanged.
pub fn text(&self) -> String {
let mut out = String::from("Print readiness (verification only; the file was not changed)\n");
if self.output_intents.is_empty() {
out.push_str("Output intent: none\n");
} else {
out.push_str(&format!("Output intent: {}\n", self.output_intents.join(", ")));
}
for page in &self.pages {
if !page.trim_set {
out.push_str(&format!("Page {}: media {} mm, no TrimBox\n", page.page, size_mm(page.media_mm)));
continue;
}
out.push_str(&format!(
"Page {}: media {} mm, trim {} mm, bleed margin {} mm{}\n",
page.page,
size_mm(page.media_mm),
size_mm(page.trim_mm),
min_mm(page.bleed_margin_mm),
if page.bleed_set { "" } else { " (no BleedBox)" },
));
}
for image in &self.images {
out.push_str(&format!(
"Image {} on page {}: {:.0} ppi, {} × {} px, {} mm\n",
image.name,
image.page,
image.ppi,
image.pixels[0],
image.pixels[1],
size_mm(image.size_mm),
));
}
if self.resolution_unknown > 0 {
out.push_str(&format!("Images with unknown resolution: {}\n", self.resolution_unknown));
}
if self.fonts_not_embedded.is_empty() {
out.push_str("Fonts: every referenced font is embedded, or the file has none\n");
} else {
out.push_str(&format!("Fonts not embedded: {}\n", self.fonts_not_embedded.join(", ")));
}
let level = Level::A2b.label();
if self.pdfa_issues.is_empty() {
out.push_str(&format!("{level}: no problems found by the structural check\n"));
} else {
out.push_str(&format!("{level}: {} problem{}\n", self.pdfa_issues.len(), if self.pdfa_issues.len() == 1 { "" } else { "s" }));
for issue in &self.pdfa_issues {
match issue.page {
Some(p) => out.push_str(&format!("- page {}: {}\n", p + 1, issue.message)),
None => out.push_str(&format!("- {}\n", issue.message)),
}
}
}
if self.findings.is_empty() {
out.push_str("Print checks: no stop for image resolution or bleed\n");
} else {
out.push_str("Print checks:\n");
for finding in &self.findings {
out.push_str(&format!("- [{}] {}\n", finding.severity, finding.message));
}
}
out.push_str("Not checked: PDF/X, overprint, total area coverage, spot colours, transparency flattening.\n");
out
}
}
impl OpenDocument {
/// A print-readiness report. Reads the file and does not edit it.
pub fn print_readiness(&self) -> PrintReadiness {
match &self.editor {
Some(editor) => inspect(&editor.cos),
None => PrintReadiness {
pages: Vec::new(),
images: Vec::new(),
output_intents: Vec::new(),
fonts_not_embedded: Vec::new(),
pdfa_issues: Vec::new(),
resolution_unknown: 0,
findings: vec![Finding {
id: "document",
severity: "info",
message: "The document has no object graph to measure.".into(),
page: None,
}],
},
}
}
}
fn inspect(doc: &Document) -> PrintReadiness {
let declared = pdfcraft_preflight::declared(doc);
let pdfa_issues = pdfcraft_preflight::verify(doc, Level::A2b);
let fonts_not_embedded = pdfa_issues
.iter()
.filter(|issue| issue.pattern.contains("isn't embedded") || issue.pattern == "A font isn't embedded")
.map(|issue| issue.value.clone().unwrap_or_else(|| "unnamed font".into()))
.collect();
let model_pages = pdfcraft_model::pages(doc);
let pages = page_reports(doc, &model_pages);
let (images, resolution_unknown) = image_reports(doc, &model_pages);
let mut findings = Vec::new();
// Without a TrimBox the trim, the bleed and the crop are the same box, so there is no bleed to
// measure. That is a property of the document, not a fault on every page: report it once.
let untrimmed = pages.iter().filter(|page| !page.trim_set).count();
if untrimmed > 0 {
findings.push(Finding {
id: "page-trim",
severity: "info",
message: if untrimmed == pages.len() {
"No page has a TrimBox, so bleed can't be measured.".to_string()
} else {
format!("{untrimmed} of {} pages have no TrimBox, so their bleed can't be measured.", pages.len())
},
page: None,
});
}
for page in pages.iter().filter(|page| page.trim_set) {
let tight = page.bleed_margin_mm.into_iter().fold(f64::INFINITY, f64::min);
if tight < MIN_BLEED_MM {
findings.push(Finding {
id: "page-bleed",
severity: "warn",
message: format!(
"Page {} has {:.1} mm of bleed past the trim on its tightest side (a press usually wants about 3 mm). Nothing was added.",
page.page,
tight.max(0.0)
),
page: Some(page.page),
});
}
}
for image in &images {
if image.ppi < FAIL_PPI {
findings.push(Finding {
id: "image-ppi",
severity: "fail",
message: format!(
"Image {} on page {} is drawn at {:.0} ppi, below {:.0}. Enlarging it would not add detail.",
image.name, image.page, image.ppi, FAIL_PPI
),
page: Some(image.page),
});
} else if image.ppi < WARN_PPI {
findings.push(Finding {
id: "image-ppi",
severity: "warn",
message: format!(
"Image {} on page {} is drawn at {:.0} ppi, below {:.0} for close-up print.",
image.name, image.page, image.ppi, WARN_PPI
),
page: Some(image.page),
});
}
}
if declared.output_intents.iter().any(|intent| is_screen_profile(intent))
&& !declared.output_intents.iter().any(|intent| is_press_profile(intent))
{
findings.push(Finding {
id: "output-intent",
severity: "warn",
message: format!(
"The output intent ({}) is a screen profile. A press condition was not added, because inventing one would retint the file.",
declared.output_intents.join(", ")
),
page: None,
});
}
if resolution_unknown > 0 {
findings.push(Finding {
id: "image-ppi-unknown",
severity: "info",
message: format!(
"The resolution of {resolution_unknown} image placement{} couldn't be measured (its placement matrix is degenerate or not finite).",
if resolution_unknown == 1 { "" } else { "s" }
),
page: None,
});
}
findings.sort_by(|a, b| a.page.cmp(&b.page).then(a.id.cmp(b.id)).then(a.message.cmp(&b.message)));
PrintReadiness { pages, images, output_intents: declared.output_intents, fonts_not_embedded, pdfa_issues, findings, resolution_unknown }
}
fn page_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> Vec<PageBoxes> {
let boxes = pdfcraft_organize::page_boxes(doc).unwrap_or_default();
pages
.iter()
.enumerate()
.map(|(i, page)| {
let row = boxes.get(i).copied().unwrap_or([[0.0; 4]; 5]);
let trim = row[3];
let bleed = row[2];
PageBoxes {
page: (i + 1) as u32,
media_mm: wh_mm(row[0]),
crop_mm: wh_mm(row[1]),
trim_mm: wh_mm(trim),
bleed_mm: wh_mm(bleed),
trim_set: page.dict.contains(b"TrimBox"),
bleed_set: page.dict.contains(b"BleedBox"),
bleed_margin_mm: [mm(trim[0] - bleed[0]), mm(trim[1] - bleed[1]), mm(bleed[2] - trim[2]), mm(bleed[3] - trim[3])],
}
})
.collect()
}
/// The images drawn on each page, and how many placements had no measurable resolution.
fn image_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> (Vec<DrawnImage>, usize) {
let mut out = Vec::new();
let mut unknown = 0usize;
let mut seen = std::collections::HashSet::new();
for (i, page) in pages.iter().enumerate() {
let page_no = (i + 1) as u32;
if let Ok(placed) = pdfcraft_edit::page_images(doc, i) {
for image in placed {
if let Some(ppi) = placed_ppi(&image) {
if let Some(object) = image.object {
seen.insert((page_no, object));
}
if !ppi.is_finite() {
unknown = unknown.saturating_add(1);
continue;
}
let w = (image.rect[2] - image.rect[0]).abs();
let h = (image.rect[3] - image.rect[1]).abs();
out.push(DrawnImage { page: page_no, name: image.name, ppi, pixels: [image.width, image.height], size_mm: [mm(w), mm(h)] });
}
}
}
// Images drawn inside a form XObject are not listed above. Their resolution is the
// smallest use on this page, which is what the optimizer already measures.
for (object, ppi) in pdfcraft_optimize::effective_resolutions(doc, &[page.obj]) {
if seen.contains(&(page_no, object)) {
continue;
}
if !ppi.is_finite() {
unknown = unknown.saturating_add(1);
continue;
}
out.push(DrawnImage { page: page_no, name: format!("object {object}"), ppi, pixels: [0, 0], size_mm: [0.0, 0.0] });
}
}
out.sort_by(|a, b| a.page.cmp(&b.page).then(a.name.cmp(&b.name)).then(a.ppi.total_cmp(&b.ppi)));
(out, unknown)
}
/// The effective resolution of a raster placement, `None` for Form artwork. Not finite (NaN)
/// when the placement can't be measured: callers report that rather than pass it.
fn placed_ppi(image: &PageImage) -> Option<f64> {
if image.is_form {
return None;
}
Some(matrix_ppi(image.matrix, image.width, image.height))
}
/// Pixels per inch of a `width` × `height` image drawn through `matrix`, or NaN when the matrix
/// is degenerate or not finite or the image has no pixels.
fn matrix_ppi(matrix: [f64; 6], width: u32, height: u32) -> f64 {
let [a, b, c, d, ..] = matrix;
let (sx, sy) = (a.hypot(b), c.hypot(d));
if width == 0 || height == 0 || !sx.is_finite() || !sy.is_finite() || sx <= 1e-6 || sy <= 1e-6 {
return f64::NAN;
}
let ppi = (f64::from(width) / (sx / 72.0)).min(f64::from(height) / (sy / 72.0));
if ppi.is_finite() { ppi } else { f64::NAN }
}
fn is_screen_profile(intent: &str) -> bool {
let id = intent.to_ascii_lowercase();
id.contains("srgb") || id.contains("adobe rgb") || id.contains("display")
}
fn is_press_profile(intent: &str) -> bool {
let id = intent.to_ascii_lowercase();
id.contains("cmyk") || id.contains("fogra") || id.contains("coated") || id.contains("swop") || id.contains("gracol")
}
fn mm(points: f64) -> f64 {
points * 25.4 / 72.0
}
fn wh_mm(rect: [f64; 4]) -> [f64; 2] {
[mm((rect[2] - rect[0]).abs()), mm((rect[3] - rect[1]).abs())]
}
fn size_mm(wh: [f64; 2]) -> String {
format!("{:.1} × {:.1}", wh[0], wh[1])
}
fn min_mm(sides: [f64; 4]) -> String {
let n = sides.into_iter().fold(f64::INFINITY, f64::min);
if n.is_finite() { format!("{n:.1}") } else { "0.0".into() }
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
fn image(doc: &mut Document, w: u32, h: u32) -> pdfcraft_cos::ObjRef {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w as i64));
d.set(b"Height".to_vec(), Object::Int(h as i64));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
doc.add(Object::Stream(Stream::flate(d, &vec![0; (w as usize) * (h as usize)])))
}
fn page(doc: &mut Document, name: &str, object: pdfcraft_cos::ObjRef, content: &str) -> pdfcraft_cos::ObjRef {
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let mut x = Dict::new();
x.set(name.as_bytes().to_vec(), Object::Ref(object));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec())));
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"Contents".to_vec(), Object::Ref(contents));
let r = doc.add(Object::Dict(p));
doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
r
}
#[test]
fn a_drawn_image_is_measured_where_it_is_placed() {
let mut doc = Document::new_empty();
// 100 px across 144 pt (2 in) is 50 ppi, not "100 px on a letter page".
let soft = image(&mut doc, 100, 100);
// 600 px across 72 pt (1 in) is 600 ppi.
let sharp = image(&mut doc, 600, 600);
page(&mut doc, "Soft", soft, "q 144 0 0 144 36 600 cm /Soft Do Q");
let sharp_page = page(&mut doc, "Sharp", sharp, "q 72 0 0 72 36 36 cm /Sharp Do Q");
doc.update_dict(sharp_page, |d| {
let inset = |n: i64| Object::Array(vec![n.into(), n.into(), (612 - n).into(), (792 - n).into()]);
d.set(b"TrimBox".to_vec(), inset(18));
d.set(b"BleedBox".to_vec(), inset(0));
})
.unwrap();
let report = inspect(&doc);
let soft = report.images.iter().find(|image| image.name == "Soft").unwrap();
let sharp = report.images.iter().find(|image| image.name == "Sharp").unwrap();
assert!((soft.ppi - 50.0).abs() < 0.5, "{}", soft.ppi);
assert!((sharp.ppi - 600.0).abs() < 0.5, "{}", sharp.ppi);
assert!(report.findings.iter().any(|f| f.id == "image-ppi" && f.severity == "fail" && f.page == Some(1)));
assert!(!report.findings.iter().any(|f| f.id == "image-ppi" && f.page == Some(2)));
// Page 1 has no TrimBox: one document-level note, not a bleed warning per page.
assert!(!report.findings.iter().any(|f| f.id == "page-bleed"), "{:?}", report.findings);
let trim: Vec<_> = report.findings.iter().filter(|f| f.id == "page-trim").collect();
assert_eq!(trim.len(), 1, "{:?}", report.findings);
assert_eq!((trim[0].severity, trim[0].page), ("info", None));
assert!(trim[0].message.starts_with("1 of 2 pages"), "{}", trim[0].message);
assert_eq!(report.resolution_unknown, 0);
assert!(report.text().contains("50 ppi"));
assert!(report.text().contains("verification only"));
}
#[test]
fn untrimmed_pages_get_one_note_and_a_tight_trim_gets_a_warning() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 600, 600);
for _ in 0..3 {
page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q");
}
let report = inspect(&doc);
let notes: Vec<_> = report.findings.iter().filter(|f| f.id.starts_with("page-")).collect();
assert_eq!(notes.len(), 1, "{notes:?}");
assert_eq!(notes[0].message, "No page has a TrimBox, so bleed can't be measured.");
// A TrimBox 1 pt inside the crop, with no BleedBox: 0.35 mm of bleed is a real warning.
let tight = page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q");
doc.update_dict(tight, |d| d.set(b"TrimBox".to_vec(), Object::Array(vec![1.into(), 1.into(), 611.into(), 791.into()]))).unwrap();
let report = inspect(&doc);
assert!(report.findings.iter().any(|f| f.id == "page-bleed" && f.severity == "warn" && f.page == Some(4)));
assert_eq!(report.findings.iter().filter(|f| f.id == "page-bleed").count(), 1);
}
#[test]
fn an_unmeasurable_placement_is_never_passed() {
assert!(matrix_ppi([f64::NAN, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([f64::INFINITY, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([0.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan());
assert!(matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 0, 600).is_nan());
assert!((matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600) - 600.0).abs() < 1e-9);
// A placement scaled past f64's range and then multiplied by zero has a NaN matrix. The
// content readers drop it before it is measured; it must never be reported as passing.
let mut doc = Document::new_empty();
let img = image(&mut doc, 100, 100);
let huge = format!("1{}", "0".repeat(400));
page(&mut doc, "Im", img, &format!("q {huge} 0 0 {huge} 0 0 cm 0 1 1 0 0 0 cm /Im Do Q"));
let report = inspect(&doc);
assert!(report.images.iter().all(|image| image.ppi.is_finite()), "{:?}", report.images);
assert!(!report.findings.iter().any(|f| f.id == "image-ppi"), "{:?}", report.findings);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Edit text on a scanned page: an OCR word is invisible text over the scan, so rewriting it must
//! also hide the scanned word. The paper colour around the word becomes the cover drawn over it
//! ([`pdfcraft_edit::BlockStyle::cover`]) and the colour of its ink becomes the new text's.
use std::sync::Arc;
use pdfcraft_edit::TextBlock;
use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, Tile};
/// Device pixels per point the word's surroundings are read at.
const SCALE: f32 = 3.0;
/// The most pixels read for one paragraph; a larger area is read at a lower resolution.
const MAX_PIXELS: f32 = 4_000_000.0;
/// How far around the word (points) the paper is read.
const RING: f32 = 4.0;
/// The paper and ink colours (RGB 0–1) of invisible paragraph `b` on `page`, or `None` when the
/// paragraph is visible text or the page can't be read.
pub(crate) fn sample(cos: &pdfcraft_cos::Document, info: &PageInfo, page: usize, b: &TextBlock) -> Option<([f64; 3], [f64; 3])> {
if !b.invisible {
return None;
}
// The word's box in view space (points, y down).
let corners = [[b.rect[0], b.rect[1]], [b.rect[2], b.rect[1]], [b.rect[0], b.rect[3]], [b.rect[2], b.rect[3]]]
.map(|[x, y]| info.user_to_view(x as f32, y as f32));
let (x0, x1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[0]), z.max(c[0])));
let (y0, y1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[1]), z.max(c[1])));
let word = [x0, y0, x1, y1];
let area = [x0 - RING, y0 - RING, x1 + RING, y1 + RING];
if word.iter().any(|v| !v.is_finite()) || x1 <= x0 || y1 <= y0 {
return None;
}
let pixels = (area[2] - area[0]) * (area[3] - area[1]) * SCALE * SCALE;
let scale = if pixels > MAX_PIXELS { SCALE * (MAX_PIXELS / pixels).sqrt() } else { SCALE };
let (page_w, page_h) = (info.width * scale, info.height * scale);
let clamp = |v: f32, max: f32| v.clamp(0.0, max.max(0.0)).floor() as u32;
let (tx0, ty0) = (clamp(area[0] * scale, page_w), clamp(area[1] * scale, page_h));
let (tx1, ty1) = (clamp(area[2] * scale, page_w), clamp(area[3] * scale, page_h));
if tx1 <= tx0 || ty1 <= ty0 {
return None;
}
let tile = Tile { x: tx0, y: ty0, w: tx1 - tx0, h: ty1 - ty0 };
// The working file as it is: written out only if the document holds unsaved changes, which
// it doesn't between edits (each one reopens the file it wrote).
let bytes = if cos.is_modified() {
Arc::new(pdfcraft_cos::write_incremental(cos, &pdfcraft_cos::SaveOptions::default()).ok()?)
} else {
cos.bytes().clone()
};
// The document's true colours, whatever the reading colours are set to.
let shot = PageRenderer::new(bytes, RenderConfig::default()).render(RenderRequest { page, scale, tile: Some(tile), ..Default::default() });
if shot.error.is_some() {
return None;
}
let pad = ((y1 - y0) * 0.1).clamp(0.5, 2.0);
let (mut paper, mut ink) = (Vec::new(), Vec::new());
for (i, px) in shot.rgba.as_chunks::<4>().0.iter().enumerate() {
let (col, row) = (i % shot.width.max(1) as usize, i / shot.width.max(1) as usize);
let x = (tx0 as f32 + col as f32 + 0.5) / scale;
let y = (ty0 as f32 + row as f32 + 0.5) / scale;
let rgb = [px[0], px[1], px[2]].map(|c| f64::from(c) / 255.0);
if x >= x0 && x <= x1 && y >= y0 && y <= y1 {
ink.push(rgb);
} else if x < x0 - pad || x > x1 + pad || y < y0 - pad || y > y1 + pad {
paper.push(rgb);
}
}
let luma = |c: &[f64; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
let by_luma = |a: &[f64; 3], b: &[f64; 3]| luma(a).total_cmp(&luma(b));
paper.sort_by(by_luma);
ink.sort_by(by_luma);
// The paper is the ring's median, so a table rule or a neighbouring letter in it doesn't count.
let paper = paper.get(paper.len() / 2).copied().unwrap_or([1.0; 3]);
// The ink is the average of the word's darkest pixels; a box without any is printed in black.
let darkest = ink.get(..ink.len().div_ceil(8)).unwrap_or_default();
let ink = if darkest.is_empty() {
[0.0; 3]
} else {
let sum = darkest.iter().fold([0.0; 3], |s, c| [s[0] + c[0], s[1] + c[1], s[2] + c[2]]);
sum.map(|v| v / darkest.len() as f64)
};
let ink = if luma(&paper) - luma(&ink) < 0.25 { [0.0; 3] } else { ink };
Some((paper, ink))
}
+38 -30
View File
@@ -118,6 +118,43 @@ pub struct SignatureImage {
size: [usize; 2],
}
/// Decode an imported PNG or JPEG within the source limits (file size, 32 megapixels, 8192 pixels
/// per side, bounded allocation), upright per its EXIF orientation and scaled down to at most
/// 4 megapixels and 4096 pixels per side. Shared by image signatures and scanned photos.
pub(crate) fn decode_source(bytes: &[u8]) -> Result<DynamicImage, SignatureImageError> {
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(SignatureImageError::Format);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_SOURCE_SIDE);
limits.max_image_height = Some(MAX_SOURCE_SIDE);
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
reader.limits(limits);
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
let mut decoder = reader.into_decoder().map_err(too_big)?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
return Err(SignatureImageError::Size);
}
let orientation = decoder.orientation()?;
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
image.apply_orientation(orientation);
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
if scale < 1.0 {
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
image = image.thumbnail(nw, nh);
}
Ok(image)
}
impl SignatureImage {
/// Read at most the file limit plus one byte, even if the file grows while being read.
pub fn read(reader: impl Read) -> Result<Self, SignatureImageError> {
@@ -127,36 +164,7 @@ impl SignatureImage {
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignatureImageError> {
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(SignatureImageError::Format);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_SOURCE_SIDE);
limits.max_image_height = Some(MAX_SOURCE_SIDE);
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
reader.limits(limits);
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
let mut decoder = reader.into_decoder().map_err(too_big)?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
return Err(SignatureImageError::Size);
}
let orientation = decoder.orientation()?;
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
image.apply_orientation(orientation);
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
if scale < 1.0 {
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
image = image.thumbnail(nw, nh);
}
let image = decode_source(bytes)?;
let rgba = image.into_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let mut png = Cursor::new(Vec::new());
+904
View File
@@ -0,0 +1,904 @@
//! Signatures scanned from a photo of paper (Fill & Sign ▸ Sign ▸ Scan).
//!
//! People sign a sheet of paper and photograph it, usually with a phone. Such a photo is far from
//! a clean signature image: it shows the table around the sheet, shadows of the phone or hand,
//! uneven light and sensor noise, and it is often larger than an image signature may be. This
//! module turns the framed part of the photo into a [`SignatureImage`]: the ink in its own colour
//! on a transparent background, trimmed, so it is placed, saved and reused like any image
//! signature.
//!
//! Everything is local and bounded: the photo is decoded within the same limits as an imported
//! image signature (a 4 MiB file, 32 megapixels, 8192 pixels per side), downscaled to at most
//! 4 megapixels and [`WORKING_MAX_SIDE`] pixels per side, and only that copy is kept. So one
//! scan of the whole frame touches at most 4 megapixels.
//!
//! The ink is separated from the paper in four steps:
//! 1. **Paper estimate.** A grayscale morphological closing (a maximum filter, then a minimum
//! filter, van Herk/Gil-Werman, O(1) per pixel) removes every dark detail narrower than the
//! window, i.e. the pen strokes, while following lighting gradients and sharp shadow edges.
//! Bright noise peaks are clipped first, and edge pixels are repeated outside the image, so a
//! table cut off by the frame keeps its size and stays background.
//! 2. **Darkness** of each pixel relative to that paper, slightly smoothed so that glossy
//! ballpoint strokes with a lighter centre come out solid. The border of large dark areas
//! that are not paper (the table) is cleared; shadows on the paper are kept.
//! 3. **Hysteresis.** Pixels darker than the threshold are ink; fainter pixels are kept only when
//! they belong to a blob with real ink, so faint stroke ends survive and stray marks don't.
//! Blobs with too little ink are dropped as dust.
//! 4. **Output.** The ink is trimmed with a small margin, drawn in its average colour with the
//! coverage as alpha, and normalized through [`SignatureImage`].
use std::io::{Cursor, Read};
use std::sync::Arc;
use image::{DynamicImage, ImageFormat, RgbImage};
use crate::signature_image::{SignatureImage, SignatureImageError};
/// Largest photo file accepted: the image signature limit (decoding shares its limits too).
pub const MAX_PHOTO_BYTES: usize = crate::signature_image::MAX_SIGNATURE_IMAGE_BYTES;
/// Longest side of the copy that is kept and scanned (it also holds at most 4 megapixels).
pub const WORKING_MAX_SIDE: u32 = 4096;
/// Longest side of the framed area that is cleaned for the final signature.
pub const SCAN_MAX_SIDE: u32 = 2400;
/// Longest side used for a quick preview, e.g. while the frame is being dragged.
pub const PREVIEW_MAX_SIDE: u32 = 900;
/// Longest side of the downscaled photo used to find the signature.
const DETECTION_MAX_SIDE: u32 = 1024;
/// Largest signature produced, in pixels (the image signature limit).
const MAX_OUTPUT_PIXELS: u64 = 4 << 20;
/// The default of the Ink sensitivity slider (0–100).
pub const DEFAULT_SENSITIVITY: u8 = 50;
/// Detection uses a higher sensitivity so that faint strokes are framed too.
const DETECTION_SENSITIVITY: u8 = 70;
/// Darkness threshold (0–255, relative to the paper) at sensitivity 0 and 100.
const MAX_THRESHOLD: f32 = 100.0;
const MIN_THRESHOLD: f32 = 12.0;
/// Fainter pixels down to this fraction of the threshold are kept when they touch ink.
const HYSTERESIS_RATIO: f32 = 0.5;
/// Paper estimate window, relative to the photo's longest side; wider than half a felt-tip stroke.
const BACKGROUND_RADIUS_RATIO: f32 = 0.012;
const MIN_BACKGROUND_RADIUS: usize = 4;
/// One pixel of smoothing per this many pixels of the longest side.
const SMOOTHING_SCALE: usize = 1000;
/// A background darker than this fraction of the nearby paper is not paper (e.g. the table).
const DARK_AREA_RATIO: f32 = 0.45;
/// Blobs with fewer ink pixels than this fraction of the area (at least the minimum) are dust.
const SPECKLE_AREA_RATIO: f64 = 0.000_02;
const MIN_SPECKLE_AREA: usize = 6;
/// Blobs with less ink than this fraction of the largest one are not part of the signature.
const DETECTION_MIN_INK_RATIO: f64 = 0.1;
/// A detected signature this much taller than wide was photographed sideways.
const UPRIGHT_RATIO: u32 = 2;
/// Transparent margin around the trimmed signature, in pixels.
const TRIM_PADDING: usize = 8;
#[derive(Debug, thiserror::Error)]
pub enum ScanError {
#[error("Choose a PNG or JPEG photo.")]
Format,
#[error("Choose a photo smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")]
Size,
#[error("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.")]
NoInk,
#[error("Couldn't read the photo: {0}")]
Read(#[from] std::io::Error),
#[error("Couldn't decode the photo: {0}")]
Decode(#[from] image::ImageError),
#[error(transparent)]
Signature(#[from] SignatureImageError),
}
/// A rectangle in photo pixels, top-left origin.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PhotoRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl PhotoRect {
/// Keep the rectangle inside a `width`×`height` photo, at least one pixel large.
pub fn clamped(self, width: u32, height: u32) -> Self {
let w = self.width.clamp(1, width.max(1));
let h = self.height.clamp(1, height.max(1));
Self { x: self.x.min(width.saturating_sub(w)), y: self.y.min(height.saturating_sub(h)), width: w, height: h }
}
/// The same area after the photo (`width`×`height` before turning) is turned a quarter.
pub fn turned(self, turn: Turn, width: u32, height: u32) -> Self {
match turn {
Turn::Clockwise => {
Self { x: height.saturating_sub(self.y.saturating_add(self.height)), y: self.x, width: self.height, height: self.width }
}
Turn::CounterClockwise => {
Self { x: self.y, y: width.saturating_sub(self.x.saturating_add(self.width)), width: self.height, height: self.width }
}
}
}
}
/// A quarter turn of the photo.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Turn {
Clockwise,
CounterClockwise,
}
/// A decoded, upright and downscaled photo of a signature on paper.
#[derive(Clone, Debug, PartialEq)]
pub struct SignaturePhoto {
rgb: Arc<RgbImage>,
}
impl SignaturePhoto {
/// Read at most the file limit plus one byte, even if the file grows while being read.
pub fn read(reader: impl Read) -> Result<Self, ScanError> {
let mut bytes = Vec::new();
reader.take(MAX_PHOTO_BYTES as u64 + 1).read_to_end(&mut bytes)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ScanError> {
if bytes.len() > MAX_PHOTO_BYTES {
return Err(ScanError::Size);
}
// The image signature decoder: same limits, EXIF orientation (phones store portrait
// photos as landscape pixels plus an orientation), scaled down to 4 megapixels.
let image = crate::signature_image::decode_source(bytes).map_err(|e| match e {
SignatureImageError::Size => ScanError::Size,
SignatureImageError::Format => ScanError::Format,
e => ScanError::Signature(e),
})?;
Ok(Self { rgb: Arc::new(image.into_rgb8()) })
}
/// Width and height in pixels.
pub fn size(&self) -> [u32; 2] {
[self.rgb.width(), self.rgb.height()]
}
/// The photo as RGB pixels, for showing it.
pub fn rgb(&self) -> &RgbImage {
&self.rgb
}
/// The photo downscaled so that its longest side is at most `max_side`, as RGBA for display.
pub fn thumbnail(&self, max_side: u32) -> image::RgbaImage {
let [w, h] = self.size();
let scale = (f64::from(max_side.max(1)) / f64::from(w.max(h).max(1))).min(1.0);
let nw = ((f64::from(w) * scale).round() as u32).max(1);
let nh = ((f64::from(h) * scale).round() as u32).max(1);
DynamicImage::ImageRgb8(image::imageops::resize(&*self.rgb, nw, nh, image::imageops::FilterType::Triangle)).into_rgba8()
}
/// Turn the photo a quarter, returning `rect` moved along with it.
pub fn turn(&mut self, turn: Turn, rect: PhotoRect) -> PhotoRect {
let [w, h] = self.size();
self.rgb = Arc::new(match turn {
Turn::Clockwise => image::imageops::rotate90(&*self.rgb),
Turn::CounterClockwise => image::imageops::rotate270(&*self.rgb),
});
rect.turned(turn, w, h)
}
/// Find the signature, or `None` when the photo shows no clear ink.
pub fn detect(&self) -> Option<PhotoRect> {
let [w, h] = self.size();
let small = scaled(&self.rgb, PhotoRect { x: 0, y: 0, width: w, height: h }, DETECTION_MAX_SIDE);
let (sw, sh) = (small.width(), small.height());
let region = detect_region(&luminance(&small), sw as usize, sh as usize)?;
let ratio = f64::from(w) / f64::from(sw.max(1));
let scale = |v: usize| ((v as f64) * ratio).round().clamp(0.0, f64::from(u32::MAX)) as u32;
Some(PhotoRect { x: scale(region.x), y: scale(region.y), width: scale(region.width), height: scale(region.height) }.clamped(w, h))
}
/// Frame the signature for the user: turn a sideways signature upright, then return its
/// frame, or a centred default frame when no signature was found.
pub fn auto_frame(&mut self) -> PhotoRect {
if let Some(rect) = self.detect() {
if rect.height > rect.width.saturating_mul(UPRIGHT_RATIO) {
// Signatures are written horizontally. The direction is a guess; the user can
// turn the photo further.
return self.turn(Turn::CounterClockwise, rect);
}
return rect;
}
let [w, h] = self.size();
PhotoRect { x: w / 7, y: h * 7 / 20, width: w * 5 / 7, height: h * 3 / 10 }.clamped(w, h)
}
/// Clean the framed area into a transparent signature. `max_side` bounds the processed area:
/// [`SCAN_MAX_SIDE`] for the result, [`PREVIEW_MAX_SIDE`] for a quick preview.
pub fn scan(&self, rect: PhotoRect, sensitivity: u8, max_side: u32) -> Result<SignatureImage, ScanError> {
let [w, h] = self.size();
let rect = rect.clamped(w, h);
let area = scaled(&self.rgb, rect, max_side.clamp(16, WORKING_MAX_SIDE));
let (aw, ah) = (area.width() as usize, area.height() as usize);
// Size the paper estimate on the whole photo, not on the frame, so that thick strokes in
// a tight frame are not taken for paper.
let scale = aw as f32 / rect.width.max(1) as f32;
let radius = (w.max(h) as f32 * BACKGROUND_RADIUS_RATIO * scale).round() as usize;
let ink = extract_ink(&luminance(&area), aw, ah, Options { sensitivity, despeckle: true, background_radius: Some(radius) });
let bounds = ink.bounds.ok_or(ScanError::NoInk)?;
let png = render(&area, &ink.alpha, bounds)?;
Ok(SignatureImage::from_bytes(&png)?)
}
}
/// The framed area of `rgb`, downscaled so that its longest side is at most `max_side`.
fn scaled(rgb: &RgbImage, rect: PhotoRect, max_side: u32) -> RgbImage {
let rect = rect.clamped(rgb.width(), rgb.height());
let view = image::imageops::crop_imm(rgb, rect.x, rect.y, rect.width, rect.height).to_image();
let longest = rect.width.max(rect.height);
if longest <= max_side {
return view;
}
let scale = f64::from(max_side) / f64::from(longest);
let nw = ((f64::from(rect.width) * scale).round() as u32).max(1);
let nh = ((f64::from(rect.height) * scale).round() as u32).max(1);
image::imageops::resize(&view, nw, nh, image::imageops::FilterType::Triangle)
}
/// Perceived luminance (ITU BT.601).
fn luminance(rgb: &RgbImage) -> Vec<u8> {
rgb.pixels().map(|p| (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round().clamp(0.0, 255.0) as u8).collect()
}
/// A rectangle in the pixels of a processed buffer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Region {
x: usize,
y: usize,
width: usize,
height: usize,
}
#[derive(Clone, Copy, Debug)]
struct Options {
sensitivity: u8,
despeckle: bool,
/// Paper estimate window; by default a fraction of the buffer's longest side.
background_radius: Option<usize>,
}
/// Ink coverage (0 = paper, 255 = solid ink) and the bounding box of the solid ink.
struct Ink {
alpha: Vec<u8>,
bounds: Option<Region>,
}
/// The darkness threshold for a sensitivity of 0–100; higher sensitivity keeps fainter strokes.
fn threshold(sensitivity: u8) -> f32 {
let s = f32::from(sensitivity.min(100)) / 100.0;
MAX_THRESHOLD - s * (MAX_THRESHOLD - MIN_THRESHOLD)
}
/// In-place separable box blur.
fn box_blur(values: &mut [f32], width: usize, height: usize, radius: usize) {
if radius == 0 || width == 0 || height == 0 || values.len() != width * height {
return;
}
let mut line = vec![0.0_f32; width.max(height)];
let mut blur = |get: &dyn Fn(usize) -> f32, len: usize, out: &mut dyn FnMut(usize, f32)| {
for (i, slot) in line.iter_mut().take(len).enumerate() {
*slot = get(i);
}
let (mut sum, mut count) = (0.0_f32, 0_u32);
for v in line.iter().take(radius.min(len)) {
sum += v;
count += 1;
}
for i in 0..len {
if let Some(v) = line.get(i + radius).filter(|_| i + radius < len) {
sum += v;
count += 1;
}
if let Some(v) = i.checked_sub(radius + 1).and_then(|j| line.get(j)) {
sum -= v;
count = count.saturating_sub(1);
}
out(i, if count == 0 { 0.0 } else { sum / count as f32 });
}
};
let mut row = vec![0.0_f32; width];
let mut tmp = vec![0.0_f32; width];
for y in 0..height {
let start = y * width;
let Some(src) = values.get(start..start + width) else { return };
row.copy_from_slice(src);
blur(&|x| row.get(x).copied().unwrap_or(0.0), width, &mut |x, v| {
if let Some(slot) = tmp.get_mut(x) {
*slot = v;
}
});
if let Some(dst) = values.get_mut(start..start + width) {
dst.copy_from_slice(&tmp);
}
}
let mut column = vec![0.0_f32; height];
let mut tmp = vec![0.0_f32; height];
for x in 0..width {
for (y, slot) in column.iter_mut().enumerate() {
*slot = values.get(y * width + x).copied().unwrap_or(0.0);
}
blur(&|y| column.get(y).copied().unwrap_or(0.0), height, &mut |y, v| {
if let Some(slot) = tmp.get_mut(y) {
*slot = v;
}
});
for (y, v) in tmp.iter().enumerate() {
if let Some(slot) = values.get_mut(y * width + x) {
*slot = *v;
}
}
}
}
/// Sliding-window maximum or minimum over `radius` pixels on each side (van Herk/Gil-Werman).
/// Edge values are repeated outside the line.
fn sliding_extremum(src: &[u8], dst: &mut [u8], radius: usize, max: bool) {
let len = src.len().min(dst.len());
if len == 0 {
return;
}
let pick = |a: u8, b: u8| if max { a.max(b) } else { a.min(b) };
let size = 2 * radius + 1;
let total = len + 2 * radius;
let at = |i: usize| src.get(i.saturating_sub(radius).min(len - 1)).copied().unwrap_or(0);
let mut g = vec![0_u8; total];
let mut h = vec![0_u8; total];
let mut prev = 0;
for (i, slot) in g.iter_mut().enumerate() {
let v = at(i);
prev = if i % size == 0 { v } else { pick(prev, v) };
*slot = prev;
}
let mut next = 0;
for (i, slot) in h.iter_mut().enumerate().rev() {
let v = at(i);
next = if i == total - 1 || (i + 1) % size == 0 { v } else { pick(next, v) };
*slot = next;
}
for (i, out) in dst.iter_mut().take(len).enumerate() {
if let (Some(&a), Some(&b)) = (h.get(i), g.get(i + 2 * radius)) {
*out = pick(a, b);
}
}
}
/// Separable square maximum (`max`) or minimum filter.
fn square_filter(src: &[u8], width: usize, height: usize, radius: usize, max: bool) -> Vec<u8> {
let mut rows = vec![0_u8; src.len()];
for (s, d) in src.chunks_exact(width.max(1)).zip(rows.chunks_exact_mut(width.max(1))) {
sliding_extremum(s, d, radius, max);
}
let mut out = vec![0_u8; src.len()];
let mut column = vec![0_u8; height];
let mut filtered = vec![0_u8; height];
for x in 0..width {
for (y, slot) in column.iter_mut().enumerate() {
*slot = rows.get(y * width + x).copied().unwrap_or(0);
}
sliding_extremum(&column, &mut filtered, radius, max);
for (y, v) in filtered.iter().enumerate() {
if let Some(slot) = out.get_mut(y * width + x) {
*slot = *v;
}
}
}
out
}
/// The brightness of the paper behind every pixel (see the module documentation).
fn estimate_background(gray: &[u8], width: usize, height: usize, radius: usize) -> Vec<u8> {
let r = radius.max(MIN_BACKGROUND_RADIUS);
// Clip bright noise peaks, which the maximum filter would otherwise pick, so that the faint
// halo around every stroke doesn't count as ink. Only clipping keeps shadow edges sharp.
let mut blurred: Vec<f32> = gray.iter().map(|&v| f32::from(v)).collect();
box_blur(&mut blurred, width, height, (r / 6).max(1));
let clipped: Vec<u8> = gray.iter().zip(&blurred).map(|(&g, &b)| f32::from(g).min(b).round().clamp(0.0, 255.0) as u8).collect();
let closed = square_filter(&square_filter(&clipped, width, height, r, true), width, height, r, false);
let mut smooth: Vec<f32> = closed.iter().map(|&v| f32::from(v)).collect();
box_blur(&mut smooth, width, height, (r / 4).max(1));
// The blur must never make the paper darker than the photo itself.
smooth.iter().zip(gray).map(|(&s, &g)| s.max(f32::from(g)).round().clamp(1.0, 255.0) as u8).collect()
}
/// Label 8-connected blobs of non-zero `mask` pixels; returns the labels (0 = none) and count.
fn label_components(mask: &[bool], width: usize, height: usize) -> (Vec<u32>, usize) {
let mut labels = vec![0_u32; mask.len()];
let mut stack: Vec<usize> = Vec::new();
let mut count = 0_usize;
for start in 0..mask.len() {
if !mask.get(start).copied().unwrap_or(false) || labels.get(start).copied().unwrap_or(1) != 0 {
continue;
}
count += 1;
let label = u32::try_from(count).unwrap_or(u32::MAX);
if let Some(l) = labels.get_mut(start) {
*l = label;
}
stack.push(start);
while let Some(index) = stack.pop() {
let (x, y) = (index % width, index / width);
for ny in y.saturating_sub(1)..=(y + 1).min(height.saturating_sub(1)) {
for nx in x.saturating_sub(1)..=(x + 1).min(width.saturating_sub(1)) {
let n = ny * width + nx;
if mask.get(n).copied().unwrap_or(false)
&& let Some(l) = labels.get_mut(n)
&& *l == 0
{
*l = label;
stack.push(n);
}
}
}
}
}
(labels, count)
}
/// Separate the ink from the paper (see the module documentation).
fn extract_ink(gray: &[u8], width: usize, height: usize, options: Options) -> Ink {
let n = width * height;
if n == 0 || gray.len() != n {
return Ink { alpha: vec![0; gray.len()], bounds: None };
}
let radius =
options.background_radius.unwrap_or_else(|| (width.max(height) as f32 * BACKGROUND_RADIUS_RATIO).round() as usize).max(MIN_BACKGROUND_RADIUS);
let background = estimate_background(gray, width, height, radius);
let mut darkness: Vec<f32> =
gray.iter().zip(&background).map(|(&g, &bg)| if g >= bg { 0.0 } else { 255.0 * f32::from(bg - g) / f32::from(bg.max(1)) }).collect();
let smoothing = width.max(height) / SMOOTHING_SCALE;
box_blur(&mut darkness, width, height, smoothing);
// Where the sheet meets a much darker area (the table) is not ink; a shadow on the paper is
// only moderately darker and is kept.
let edge = smoothing + 2;
let brightest = square_filter(&background, width, height, edge, true);
let darkest = square_filter(&background, width, height, edge, false);
for ((d, &lo), &hi) in darkness.iter_mut().zip(&darkest).zip(&brightest) {
if f32::from(lo) < f32::from(hi) * DARK_AREA_RATIO {
*d = 0.0;
}
}
let strong = threshold(options.sensitivity);
let weak = strong * HYSTERESIS_RATIO;
let candidates: Vec<bool> = darkness.iter().map(|&d| d >= weak).collect();
let (labels, count) = label_components(&candidates, width, height);
let mut strong_pixels = vec![0_usize; count + 1];
for (&label, &d) in labels.iter().zip(&darkness) {
if label != 0
&& d >= strong
&& let Some(c) = strong_pixels.get_mut(label as usize)
{
*c += 1;
}
}
let min_strong = if options.despeckle { MIN_SPECKLE_AREA.max((n as f64 * SPECKLE_AREA_RATIO).round() as usize) } else { 1 };
let mut alpha = vec![0_u8; n];
let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0, 0);
for (i, ((a, &label), &d)) in alpha.iter_mut().zip(&labels).zip(&darkness).enumerate() {
if label == 0 || strong_pixels.get(label as usize).copied().unwrap_or(0) < min_strong {
continue;
}
// Smooth ramp from the weak to the strong threshold.
let c = ((d - weak) / (strong - weak).max(f32::EPSILON)).clamp(0.0, 1.0);
*a = (255.0 * c * c * (3.0 - 2.0 * c)).round() as u8;
if *a >= 128 {
let (x, y) = (i % width, i / width);
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
let bounds = (min_x <= max_x && min_y <= max_y).then(|| Region { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 });
Ink { alpha, bounds }
}
/// Where the signature is in a (downscaled) photo of the whole sheet: nearby strokes are merged,
/// blobs touching the photo border (paper edges, table, fingers) are ignored, and the blob with
/// the most ink is framed together with the other words on its line.
fn detect_region(gray: &[u8], width: usize, height: usize) -> Option<Region> {
let ink = extract_ink(gray, width, height, Options { sensitivity: DETECTION_SENSITIVITY, despeckle: true, background_radius: None });
ink.bounds?;
let solid: Vec<u8> = ink.alpha.iter().map(|&a| if a >= 128 { 255 } else { 0 }).collect();
let radius = ((width.max(height) as f64) * 0.03).round().max(2.0) as usize;
let merged: Vec<bool> = square_filter(&solid, width, height, radius, true).iter().map(|&v| v != 0).collect();
let (labels, count) = label_components(&merged, width, height);
#[derive(Clone, Copy)]
struct Blob {
ink: usize,
min_x: usize,
min_y: usize,
max_x: usize,
max_y: usize,
border: bool,
}
let mut blobs = vec![Blob { ink: 0, min_x: usize::MAX, min_y: usize::MAX, max_x: 0, max_y: 0, border: false }; count + 1];
for (i, (&label, &s)) in labels.iter().zip(&solid).enumerate() {
let Some(blob) = blobs.get_mut(label as usize).filter(|_| label != 0) else { continue };
let (x, y) = (i % width, i / width);
if x == 0 || y == 0 || x + 1 == width || y + 1 == height {
blob.border = true;
}
if s != 0 {
blob.ink += 1;
blob.min_x = blob.min_x.min(x);
blob.min_y = blob.min_y.min(y);
blob.max_x = blob.max_x.max(x);
blob.max_y = blob.max_y.max(y);
}
}
let candidates = || blobs.iter().skip(1).filter(|b| !b.border && b.ink > 0);
let best = *candidates().max_by_key(|b| b.ink)?;
// Signatures often have several words: add every blob with a fair amount of ink on the same line.
let span = best.max_y - best.min_y;
let (top, bottom) = (best.min_y.saturating_sub(span), best.max_y + span);
let min_ink = best.ink as f64 * DETECTION_MIN_INK_RATIO;
let mut frame = best;
for b in candidates().filter(|b| b.ink as f64 >= min_ink && b.max_y >= top && b.min_y <= bottom) {
frame.min_x = frame.min_x.min(b.min_x);
frame.min_y = frame.min_y.min(b.min_y);
frame.max_x = frame.max_x.max(b.max_x);
frame.max_y = frame.max_y.max(b.max_y);
}
let pad = radius;
let (x, y) = (frame.min_x.saturating_sub(pad), frame.min_y.saturating_sub(pad));
Some(Region { x, y, width: (frame.max_x + 1 + pad).min(width) - x, height: (frame.max_y + 1 + pad).min(height) - y })
}
/// The trimmed ink in its average colour with the coverage as alpha, as PNG.
fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result<Vec<u8>, ScanError> {
let (w, h) = (area.width() as usize, area.height() as usize);
let x0 = bounds.x.saturating_sub(TRIM_PADDING);
let y0 = bounds.y.saturating_sub(TRIM_PADDING);
let x1 = (bounds.x + bounds.width + TRIM_PADDING).min(w);
let y1 = (bounds.y + bounds.height + TRIM_PADDING).min(h);
let (ow, oh) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
if ow == 0 || oh == 0 {
return Err(ScanError::NoInk);
}
// One ink colour: the average of the solid strokes, so blue ink stays blue and paper tint at
// the stroke edges doesn't show.
let (mut sum, mut solid) = ([0_u64; 3], 0_u64);
for (p, &a) in area.pixels().zip(alpha) {
if a == 255 {
for (s, &c) in sum.iter_mut().zip(p.0.iter()) {
*s += u64::from(c);
}
solid += 1;
}
}
let colour = if solid == 0 { [0, 0, 0] } else { sum.map(|s| (s / solid).min(255) as u8) };
let mut out = image::RgbaImage::new(u32::try_from(ow).unwrap_or(1), u32::try_from(oh).unwrap_or(1));
for (y, row) in out.rows_mut().enumerate() {
for (x, px) in row.enumerate() {
let a = alpha.get((y0 + y) * w + x0 + x).copied().unwrap_or(0);
*px = image::Rgba([colour[0], colour[1], colour[2], a]);
}
}
let mut out = DynamicImage::ImageRgba8(out);
let pixels = (ow as u64).saturating_mul(oh as u64);
if pixels > MAX_OUTPUT_PIXELS || ow > WORKING_MAX_SIDE as usize || oh > WORKING_MAX_SIDE as usize {
let scale = (MAX_OUTPUT_PIXELS as f64 / pixels as f64).sqrt().min(f64::from(WORKING_MAX_SIDE) / ow.max(oh) as f64);
let nw = ((ow as f64 * scale).floor() as u32).max(1);
let nh = ((oh as f64 * scale).floor() as u32).max(1);
out = out.resize_exact(nw, nh, image::imageops::FilterType::Triangle);
}
let mut png = Cursor::new(Vec::new());
out.write_to(&mut png, ImageFormat::Png)?;
// A PNG larger than a saved signature may be is scaled down by `SignatureImage::from_bytes`.
Ok(png.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`.
fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec<u8> {
let mut out = vec![0; width * height];
for y in 0..height {
for x in 0..width {
let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]);
out[y * width + x] = if inside { ink_value } else { paper(x, y) };
}
}
out
}
fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage {
RgbImage::from_fn(width as u32, height as u32, |x, y| {
let v = gray[y as usize * width + x as usize];
image::Rgb([v, v, v])
})
}
fn jpeg(image: &RgbImage) -> Vec<u8> {
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Jpeg).unwrap();
out.into_inner()
}
/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and
/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels.
#[test]
fn photos_keep_the_image_signature_limits() {
let png = |image: &RgbImage| {
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
};
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size)));
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size();
assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}");
assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}");
}
fn region(r: [usize; 4]) -> Region {
Region { x: r[0], y: r[1], width: r[2], height: r[3] }
}
fn opts() -> Options {
Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None }
}
#[test]
fn box_blur_averages_and_keeps_a_constant_signal() {
let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0];
box_blur(&mut values, 5, 1, 1);
assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]);
let mut flat = vec![7.0; 12];
box_blur(&mut flat, 4, 3, 2);
assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4));
}
#[test]
fn square_filters_match_a_naive_window_with_repeated_edges() {
let (w, h) = (13, 7);
let src: Vec<u8> = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect();
for radius in [1, 2, 5] {
for max in [true, false] {
let fast = square_filter(&src, w, h, radius, max);
for y in 0..h {
for x in 0..w {
let mut want = if max { 0 } else { 255 };
for dy in -(radius as i64)..=radius as i64 {
for dx in -(radius as i64)..=radius as i64 {
let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize;
let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize;
let v = src[sy * w + sx];
want = if max { want.max(v) } else { want.min(v) };
}
}
assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}");
}
}
}
}
}
#[test]
fn components_are_eight_connected() {
let mask = [true, false, false, false, false, true, false, true, false, false, false, false];
let (labels, count) = label_components(&mask, 4, 3);
assert_eq!(count, 2);
assert_eq!(labels[0], labels[5]);
assert_ne!(labels[7], labels[0]);
assert_eq!(labels[1], 0);
}
#[test]
fn sensitivity_lowers_the_threshold() {
assert!(threshold(0) > threshold(50));
assert!(threshold(50) > threshold(100));
assert_eq!(threshold(200), threshold(100));
}
#[test]
fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() {
let (w, h) = (160, 80);
let stroke = [20, 30, 120, 6];
let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[10 * w + 150], 0);
assert_eq!(ink.alpha[33 * w + 30], 255);
assert_eq!(ink.alpha[33 * w + 130], 255);
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn a_sharp_shadow_edge_is_not_ink() {
let (w, h) = (200, 120);
let stroke = [30, 52, 140, 4];
let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
for y in 58..63 {
assert_eq!(ink.alpha[y * w + 10], 0);
assert_eq!(ink.alpha[y * w + 190], 0);
}
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn a_table_at_the_edge_of_the_frame_is_not_ink() {
let (w, h) = (300, 120);
let stroke = [60, 60, 200, 4];
let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[5 * w + 150], 0);
assert_eq!(ink.alpha[60 * w + 5], 0);
assert_eq!(ink.alpha[11 * w + 150], 0);
assert_eq!(ink.bounds, Some(region(stroke)));
}
#[test]
fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() {
let (w, h) = (600, 200);
let stroke = [50, 90, 500, 18];
let gray = photo(w, h, |_, _| 225, &[stroke], 30);
let middle = 99 * w + 300;
// Sized on the tight frame alone, the window is narrower than the stroke.
assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0);
let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() });
assert_eq!(ink.bounds, Some(region(stroke)));
assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255));
}
#[test]
fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() {
let (w, h) = (200, 60);
let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40);
for y in 28..32 {
for x in (100..180).chain(10..15) {
gray[y * w + x] = 175;
}
}
let ink = extract_ink(&gray, w, h, opts());
assert!(ink.alpha[30 * w + 150] > 0);
assert_eq!(ink.alpha[30 * w + 12], 0);
assert_eq!(ink.bounds, Some(region([20, 28, 160, 4])));
}
#[test]
fn dust_is_removed_unless_despeckling_is_off() {
let (w, h) = (200, 100);
let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20);
assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0);
let ink = extract_ink(&gray, w, h, opts());
assert_eq!(ink.alpha[5 * w + 190], 0);
assert_eq!(ink.bounds, Some(region([20, 40, 150, 5])));
}
#[test]
fn faint_strokes_need_a_higher_sensitivity() {
let (w, h) = (120, 60);
let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165);
assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none());
assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some());
}
#[test]
fn blank_paper_has_no_ink() {
let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0);
assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none());
assert!(detect_region(&gray, 64, 64).is_none());
}
#[test]
fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() {
let (w, h) = (400, 200);
let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30);
let r = detect_region(&gray, w, h).unwrap();
assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}");
assert!(r.y > 20 && r.y + r.height < 185, "{r:?}");
}
#[test]
fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() {
let (w, h) = (400, 300);
let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 };
let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30);
let r = detect_region(&gray, w, h).unwrap();
assert!(r.x > 80 && r.x + r.width < 320, "{r:?}");
assert!(r.y > 70 && r.y + r.height < 230, "{r:?}");
}
#[test]
fn rects_turn_with_the_photo_and_back() {
let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 };
let turned = rect.turned(Turn::Clockwise, 100, 80);
assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 });
assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect);
assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 });
assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0);
}
#[test]
fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() {
let (w, h) = (600, 400);
let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h);
for (x, y, p) in image.enumerate_pixels_mut() {
if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) {
*p = image::Rgb([30, 50, 140]);
}
}
let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap();
let rect = photo.auto_frame();
assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}");
let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap();
let [sw, sh] = signature.size();
assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}");
let pixels = signature.rgba().as_chunks::<4>().0;
assert_eq!(pixels[0][3], 0, "the margin is transparent");
let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect();
assert!(!solid.is_empty());
assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]);
let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap();
assert!(quick.size()[0] <= sw);
}
#[test]
fn a_sideways_signature_is_turned_upright() {
let (w, h) = (400, 600);
let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30);
let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap();
let rect = photo.auto_frame();
assert_eq!(photo.size(), [600, 400]);
assert!(rect.width > rect.height * 2, "{rect:?}");
}
#[test]
fn an_empty_frame_reports_that_no_signature_was_found() {
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap();
let mut copy = photo.clone();
assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 });
assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk)));
}
#[test]
fn large_photos_are_downscaled_and_invalid_ones_rejected() {
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap();
let [w, h] = photo.size();
assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}");
assert!(photo.thumbnail(512).width() <= 512);
assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format)));
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err());
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
// A header claiming a huge image is rejected before decoding.
let mut png = Vec::new();
png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR");
png.extend_from_slice(&20_000_u32.to_be_bytes());
png.extend_from_slice(&20_000_u32.to_be_bytes());
png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]);
assert!(SignaturePhoto::from_bytes(&png).is_err());
}
#[test]
fn degenerate_buffers_do_not_panic() {
assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none());
assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none());
assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none());
assert!(detect_region(&[255; 4], 2, 2).is_none());
let mut empty: Vec<f32> = Vec::new();
box_blur(&mut empty, 0, 0, 3);
assert!(square_filter(&[], 0, 0, 2, true).is_empty());
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap();
let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0);
}
}
+740 -5
View File
@@ -430,15 +430,134 @@ fn combine_extract_split_and_insert_from_file() {
let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap();
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
// Insert pages from a file, undoably, into the open document.
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: other,
pages: Some(vec![1]),
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
assert_eq!(page_texts(&s, id).len(), 3);
// Garbage sources fail cleanly.
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
let bad = s.apply(
id,
Edit::InsertPagesFrom {
name: "junk.pdf".into(),
bytes: Arc::new(b"nope".to_vec()),
pages: None,
at: 0,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
);
assert!(matches!(bad, Err(EditError::Source(_))));
}
/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field).
fn form_with_field(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 4
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
/// Inserting pages of a file whose form names a field as the open document's: the incoming
/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is
/// not Combine), and nothing about renaming lands in the repair log.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log());
}
/// field is renamed to keep the two separate, the repair log says so (warning that scripts
/// were not rewritten), the undo step's label counts the renames, and undo puts the names back.
#[test]
fn insert_from_file_renames_colliding_fields_and_notes_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: Arc::new(form_with_field("Text1")),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
assert!(
doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")),
"the note names the rename and the scripts caveat: {:?}",
doc.repair_log()
);
let label = s.undo(id).unwrap();
assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1"], "undo restores the single original field");
}
/// A combine reports the fields it renamed — the tool result tells callers what their scripts
/// must call the field now — and honours a caller's choice to merge instead.
#[test]
fn combine_reports_renamed_fields_and_can_merge() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> =
vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)];
let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
let cid = s.open_new("Combined.pdf", out.bytes).unwrap();
let doc = s.get(cid).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty(), "merge renames nothing");
}
/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename
/// in each. A file that cannot be opened previews as nothing (its row already shows the problem).
#[test]
fn combine_field_conflicts_previews_over_bytes() {
let s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> = vec![
("a.pdf".into(), Arc::new(form_with_field("Text1")), None),
("b.pdf".into(), Arc::new(form_with_field("Text1")), None),
("junk.pdf".into(), Arc::new(b"nope".to_vec()), None),
];
let preview = s.combine_field_conflicts(&sources, &[]);
assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]);
}
/// A minimal baseline JPEG (headers only; the data is embedded as is).
fn jpeg_bytes() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
@@ -525,7 +644,8 @@ fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() {
#[test]
fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() {
let s = Session::new();
// The two combines are compared byte for byte, and each stamps the clock: pin it.
let s = Session::new().with_clock(|| 1_700_000_000);
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
let sources: Vec<CombineSource> =
vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))];
@@ -693,12 +813,13 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld");
assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images")));
assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying"));
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"];
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"];
let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id));
for c in extracting {
assert!(!enabled(&s, id, c), "{c} is disabled");
}
assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy");
assert!(s.get(id).unwrap().autotag_preview(0).is_ok(), "like a11y.check, the auto-tag preview is allowed");
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_copying());
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
@@ -1144,6 +1265,67 @@ fn protection_is_validated_undoable_and_never_logged() {
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
/// Protect a fresh document with `p` as the app does: the edit's result and, when it is
/// accepted, how the saved file authenticates with no password, the open password and the
/// permissions password (`None`: it doesn't open).
fn protect_and_reopen(p: &Protection) -> (Result<(), EditError>, Option<[Option<pdfcraft_cos::Auth>; 3]>) {
let (mut s, id) = session_with(1);
let result = s.apply(id, Edit::Protect(p.clone()));
let reopened = result.is_ok().then(|| {
let saved = s.save_bytes(id).unwrap();
let auth = |pw: Option<&str>| pdfcraft_cos::Document::open_with_password(saved.clone(), pw).ok()?.security().map(|h| h.auth());
[auth(None), auth(p.open_password.as_deref()), auth(p.permissions_password.as_deref())]
});
(result, reopened)
}
#[test]
fn passwords_that_encryption_would_make_empty_or_equal_are_refused() {
use pdfcraft_cos::Algorithm::{Aes128, Rc4_128};
use pdfcraft_cos::Auth::{Owner, User};
let empty = "passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens";
let same = "the open password and the permissions password must be different (PDF readers treat these two as the same password)";
// AES-256 keys on the SASLprep form of a password (soft hyphens dropped, then NFKC), RC4 and
// AES-128 on its first 32 bytes. Written as typed, these would make a file that opens
// without a password, or whose open password lifts the restrictions.
let (a31, a32) = ("a".repeat(31), "a".repeat(32));
let (long1, long2) = (format!("{a32}1"), format!("{a32}2"));
// The 32-byte padding starts with "(": 31 characters and the same followed by "(" are one key.
let padded = format!("{a31}(");
let cases = [
("soft hyphen open password", protection(Some("\u{AD}"), None), empty),
("soft hyphen permissions password", protection(None, Some("\u{AD}")), empty),
("soft hyphen permissions password beside an open one", protection(Some("0"), Some("\u{AD}")), empty),
("fullwidth A, then A", protection(Some("\u{FF21}"), Some("A")), same),
("A, then fullwidth A", protection(Some("A"), Some("\u{FF21}")), same),
("fi ligature, then fi", protection(Some("\u{FB01}"), Some("fi")), same),
("fi, then fi ligature", protection(Some("fi"), Some("\u{FB01}")), same),
("RC4-128, same first 32 bytes", Protection { algorithm: Rc4_128, ..protection(Some(&long1), Some(&long2)) }, same),
("AES-128, same first 32 bytes", Protection { algorithm: Aes128, ..protection(Some(&long1), Some(&long2)) }, same),
("RC4-128, same once padded", Protection { algorithm: Rc4_128, ..protection(Some(&a31), Some(&padded)) }, same),
("AES-128, same once padded", Protection { algorithm: Aes128, ..protection(Some(&a31), Some(&padded)) }, same),
];
let got: Vec<_> = cases.iter().map(|(case, p, _)| (*case, protect_and_reopen(p))).collect();
let want: Vec<_> = cases.iter().map(|(case, _, message)| (*case, (Err(EditError::Protection(message.to_string())), None))).collect();
assert_eq!(got, want, "each is refused, so no file is written");
// Passwords that are empty or equal as typed keep their messages.
let (mut s, id) = session_with(1);
assert_eq!(s.apply(id, Edit::Protect(protection(Some(""), None))), Err(EditError::Protection("passwords can't be empty".into())));
assert_eq!(
s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))),
Err(EditError::Protection("the open password and the permissions password must be different".into()))
);
// Passwords that stay different are fine: only the permissions password gives owner rights.
let distinct = [
protection(Some("A"), Some("\u{FF21}\u{FF22}")),
Protection { algorithm: Rc4_128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) },
Protection { algorithm: Aes128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) },
];
for p in &distinct {
assert_eq!(protect_and_reopen(p), (Ok(()), Some([None, Some(User), Some(Owner)])), "{:?}", p.algorithm);
}
}
/// A one-page form: a text field and a check box (with appearance states).
fn form_fixture() -> Vec<u8> {
let objs = [
@@ -1170,6 +1352,34 @@ fn form_fixture() -> Vec<u8> {
out
}
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
/// viewers to redraw it.
#[test]
fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
let fill = |s: &mut Session, name: &str, v: FieldValue| s.apply(id, Edit::SetFieldValue { name: name.into(), value: v });
fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.form[0].value, ["张三"]);
let n = &d.edit_notices;
assert_eq!(n.len(), 1, "{n:?}");
assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true));
assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]);
fill(&mut s, "ok", FieldValue::Check(true)).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing");
fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap();
assert_eq!(s.get(id).unwrap().edit_notices.len(), 1, "a new value is reported again");
assert!(fill(&mut s, "missing", FieldValue::Text("x".into())).is_err());
assert!(s.get(id).unwrap().edit_notices.is_empty(), "no stale notice after a failed edit");
fill(&mut s, "name", FieldValue::Text("Ada".into())).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty());
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_forms::need_appearances(&saved));
}
#[test]
fn filling_a_form_shows_saves_and_undoes() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -1482,7 +1692,17 @@ fn create_and_reduce() {
// A file with the same page imported twice shares nothing until reduced.
let (mut s2, id2) = session_with(1);
let src = Arc::new(fixture(1));
s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap();
s2.apply(
id2,
Edit::InsertPagesFrom {
name: "x".into(),
bytes: src.clone(),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let full = s2.save_full_bytes(id2).unwrap();
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
@@ -1828,6 +2048,12 @@ fn links_from_urls_and_link_edits() {
assert_eq!(d.links.len(), 1);
assert_eq!(d.info.links.len(), 1, "the viewer follows it");
assert!(s.find_urls(id).is_empty(), "already linked");
// Remove all links and undo.
s.apply(id, Edit::RemoveLinks { pages: None }).unwrap();
assert!(s.get(id).unwrap().links.is_empty());
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove all links"));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().links.len(), 1);
// Link Properties and delete.
let l = s.get(id).unwrap().links[0].clone();
s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap();
@@ -1837,6 +2063,42 @@ fn links_from_urls_and_link_edits() {
assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove");
}
#[test]
fn appearance_only_edit_preserves_gotor_action_semantics() {
let objects = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F (target.pdf) /D [2 /FitH 180] /NewWindow true >> >>".to_vec(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
let mut session = Session::new();
let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap();
let before = session.get(id).unwrap().info.links[0].target.clone();
let mut style = session.get(id).unwrap().links[0].style;
style.visible = true;
style.width = 2.0;
session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap();
let after = &session.get(id).unwrap().info.links[0].target;
assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged");
assert_eq!(session.get(id).unwrap().links[0].style, style, "the appearance edit is applied");
}
#[test]
fn comments_lock_take_checkmarks_hide_and_summarize() {
let (mut s, id) = session_with(2);
@@ -1889,6 +2151,21 @@ fn comment_summary_includes_replies_to_replies() {
assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}");
}
#[test]
fn reading_colors_set_and_invalidated() {
let (mut s, id) = session_with(1);
// Every open document holds a value; the default is Off.
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK));
assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK));
assert!(!s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK), "unchanged");
assert!(s.set_reading_colors(id, ReadingColors::default()), "back to Off");
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
// No such document.
assert_eq!(s.reading_colors(DocId(u64::MAX)), None);
assert!(!s.set_reading_colors(DocId(u64::MAX), ReadingColors::WHITE_ON_BLACK));
}
#[test]
fn signing_saving_trusting_and_commenting_afterwards() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
@@ -2085,6 +2362,49 @@ fn recognize_text_makes_a_scanned_page_searchable() {
assert!(again[0].skipped.is_some());
}
/// #1028: a page with its own text and a picture of text. By default it is skipped, as before;
/// with `skip_text_pages: false` the picture is read and the page's own text is not added again.
#[test]
fn recognize_text_reads_a_picture_on_a_page_that_has_text() {
if !ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
// Real text in the empty lower part of the picture.
let (w, h) = {
let p = &s.get(id).unwrap().info.pages[0];
(p.width as f64, p.height as f64)
};
let note = AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text: note }).unwrap();
let count = |s: &Session, word: &str| page_texts(s, id)[0].to_lowercase().matches(word).count();
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 0));
let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
assert!(skipped[0].skipped.is_some(), "pages with text are still skipped by default");
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text"), "nothing was added");
let hybrid = ocr::OcrSettings { skip_text_pages: false, ..Default::default() };
let found = s.recognize_text(id, &[], hybrid.clone()).unwrap();
assert_eq!(found[0].skipped, None);
assert!(found[0].already_on_page >= 2, "the page's own words were recognised and left out: {:?}", found[0]);
let added = found[0].text().to_lowercase();
assert!(added.contains("brown") && added.contains("lazy") && !added.contains("invoice"), "{added}");
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1), "{:?}", page_texts(&s, id));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text"));
// Reading the page again adds nothing: every word is there now.
let again = s.recognize_text(id, &[], hybrid).unwrap();
assert!(again[0].words.is_empty(), "{:?}", again[0]);
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1));
}
/// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a
/// document-level function), shown with a custom format; qty is validated; a button script.
fn scripted_form() -> Vec<u8> {
@@ -2287,6 +2607,8 @@ fn exporting_office_files_keeps_images() {
assert_eq!(d.export_pages()[0].images.len(), 1);
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(16).any(|w| w == b"word/media/image"));
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.windows(15).any(|w| w == b"ppt/media/image"));
if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") {
std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap();
}
@@ -2350,10 +2672,33 @@ BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body s
let d = s.get(id).unwrap();
let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap();
assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}");
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.starts_with(b"PK"));
assert!(pptx.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml"));
let blocks = &d.export_pages()[0].blocks;
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
}
#[test]
fn office_export_positions_follow_the_displayed_crop_rotation_and_units() {
use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full};
let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap();
let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone();
page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec()));
page.set("Rotate", Object::Int(90));
page.set("UserUnit", Object::Int(2));
pdf.set(ObjRef::new(4, 0), page);
let mut session = Session::new();
let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap();
let doc = session.get(id).unwrap();
let pages = doc.export_pages();
let b = &pages[0].blocks[0];
assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}");
assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}");
assert_eq!(b.size, 48.0);
assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0));
}
#[test]
fn guard_turns_a_panic_into_an_error() {
assert_eq!(guard(|| 7), Ok(7));
@@ -2605,6 +2950,196 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
}
#[test]
fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
use pdfcraft_xfa::som_to_path;
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
// Export: XML is the datasets document, XDP wraps it; the fields' current values are in it.
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
assert!(xml.starts_with("<?xml") && xml.contains("<form") && xml.contains("<country>CA</country>") && !xml.contains("<xfa:datasets"), "{xml}");
let xdp = String::from_utf8(s.export_data(id, DataFormat::Xdp, false, true).unwrap()).unwrap();
assert!(xdp.contains("<xdp:xdp") && xdp.contains("<country>CA</country>") && xdp.contains("href=\"fields.pdf\""), "{xdp}");
// Import: the data file's values replace the form's; the calculation runs; undo brings
// the old values back.
let file = "<form><page1><country>FR</country><langs>French</langs><pin>1234</pin></page1></form>";
s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
assert_eq!(field(&s, "langs").value, vec!["French".to_string()]);
assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]);
assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id));
s.undo(id).unwrap();
assert_eq!(field(&s, "country").value, vec!["CA".to_string()]);
s.redo(id).unwrap();
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
// A file in another encoding, and one with a long comment before its root, import too.
let latin1: Vec<u8> =
"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><form><page1><other>Société</other></page1></form>".chars().map(|c| c as u32 as u8).collect();
s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["Société".to_string()]);
let commented = format!("<!-- {} --><form><page1><other>after</other></page1></form>", "x".repeat(5000));
s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["after".to_string()]);
let utf16: Vec<u8> = "<form><page1><other>wide</other></page1></form>".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
let shift_jis = b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><form/>".to_vec();
assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err());
// A data file for another form is refused.
assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"<other><x>1</x></other>".to_vec()) }).is_err());
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
// XFDF still goes the usual way on an XFA form.
let xfdf =
"<?xml version=\"1.0\"?><xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><fields><field name=\"pin\"><value>9999</value></field></fields></xfdf>";
s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]);
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
// A picture chosen for the image field goes into the data and onto the page.
let png = pdfcraft_xfa::fixtures::tiny_png();
assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture");
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh"));
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\""));
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo");
// Only pictures the form can show; only image fields.
assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err());
assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err());
// One datasets stream per edit, however many steps it took.
let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len();
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap();
let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let datasets = after
.object_numbers()
.into_iter()
.filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"<xfa:datasets"))))
.count();
assert!(datasets <= 3, "{datasets} datasets streams (one per edit at most), {before} objects before");
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(png) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("iVBOR"));
// Reopened: the picture and the imported values are what they were.
let id2 = s.open("again.pdf", None, s.save_bytes(id).unwrap(), None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.form.iter().find(|f| f.name == "country").unwrap().value, vec!["FR".to_string()]);
assert_eq!(d2.page_images(0).len(), 2);
}
#[test]
fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("static.pdf", None, original, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]);
assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]);
// The file names only `name`: `agree` is emptied too, since the data was replaced.
s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"<form1><page1><name>Bob</name></page1></form1>".to_vec()) }).unwrap();
assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]);
assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]"));
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob"));
assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty));
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
assert!(xml.contains("<name>Bob</name>") && !xml.contains("<agree>1</agree>"), "{xml}");
}
#[test]
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1);
let file = "<form><page1><qty>3</qty><table><row><item>a</item><amount>10</amount></row><row><item>b</item><amount>32</amount></row><row><item>c</item><amount>0.5</amount></row></table></page1></form>";
s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s));
assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]);
// More rows than the layout shows: the document says so.
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
let file = format!("<form><page1><qty>1</qty><table>{rows}</table></page1></form>");
s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}");
// A file with one row takes the others away.
let file = "<form><page1><qty>1</qty><table><row><item>only</item><amount>7</amount></row></table></page1></form>";
s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s));
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
// Not an XFA data file on an XFA form: refused with the usual message, nothing changed.
assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"<a><b></a>".to_vec()) }).is_err());
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
// The automation tool's view: export then import through a file round-trips the rows.
let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap();
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
}
#[test]
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
let (sections, rows) = (10, 20);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("many.pdf", None, bytes, None).expect("opens");
let elapsed = started.elapsed();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
let form = &s.get(id).unwrap().form;
let totals: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("total")).collect();
assert_eq!(totals.len(), sections * rows);
assert!(totals.iter().all(|f| !f.value.is_empty()), "every row's total was calculated");
let sums: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("sum")).collect();
assert_eq!(sums.len(), sections);
// 2 × (1 + 2 + … + rows)
let expect = (rows * (rows + 1)).to_string();
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
// A change recalculates them all again, well within the event budget.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, vec![(rows * (rows + 1) - 2 + 200).to_string()]);
eprintln!("recalculated after a change in {:?}", started.elapsed());
}
#[test]
fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
// In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate
// reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list
// by its shown text: the next script reads the saved value the engine kept.
let tpl = pdfcraft_xfa::fixtures::fields_template("<form><page1><country>JP</country></page1></form>").replace(
r#"<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
r#"<field name="a" w="1in" h="0.3in"><ui><numericEdit/></ui><value><integer>1</integer></value></field>
<field name="b" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue = 99; throw new Error("boom");</script></calculate></field>
<field name="c" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue + 1</script></calculate></field>
<field name="d" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">country.rawValue = "Canada"; "set"</script></calculate></field>
<field name="e" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script>country</script></calculate></field>
<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("iso.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}");
assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing");
assert_eq!(value(&s, "c"), vec!["2".to_string()]);
assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value");
assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value");
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
@@ -2620,6 +3155,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
assert_eq!(value(&s, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["0".to_string()]);
assert!(!s.get(id).unwrap().xfa_scripts_off());
assert!(!s.get(id).unwrap().dirty);
assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden");
// A change recalculates.
@@ -2805,6 +3341,7 @@ fn xfa_scripts_stay_off_with_javascript_off() {
let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone();
assert!(qty.is_empty(), "no initialize script ran");
assert!(s.run_javascript(id, "", Some("addRow")).is_err());
assert!(!s.get(id).unwrap().xfa_scripts_off(), "the preference, not a script that ran away");
}
#[test]
@@ -2926,11 +3463,13 @@ fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors);
assert!(s.get(id).unwrap().xfa_scripts_off());
// The scripts stay off: a change runs nothing more.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
assert!(s.get(id).unwrap().xfa_scripts_off());
}
/// The page view's raster of `page` at 1 px/pt (the document's own render pool).
@@ -3237,6 +3776,61 @@ fn recovered_user_passwords_reach_the_renderer_byte_for_byte() {
assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok");
}
/// AES-256 passwords as typed, each with its SASLprep form, which ISO 32000-2 keys the file on:
/// a decomposed é (NFKC composes it), a no-break space (mapped to a space) and fullwidth letters
/// (what a Japanese IME types in full-width mode; NFKC narrows them). Precomposed "café" is the
/// control: SASLprep leaves it as it is.
const SASLPREP_CHANGES: [(&str, &str); 4] =
[("cafe\u{301}", "caf\u{E9}"), ("a\u{A0}b", "a b"), ("\u{FF41}\u{FF42}", "ab"), ("caf\u{E9}", "caf\u{E9}")];
#[test]
fn aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed() {
for (typed, prepared) in SASLPREP_CHANGES {
let (mut s, id) = session_with(1);
// The renderer used to take the password as typed, so the protected file didn't reopen
// and the edit was rolled back: "the password is incorrect".
s.apply(id, Edit::Protect(protection(Some(typed), None))).unwrap_or_else(|e| panic!("{typed:?}: {e:?}"));
assert!(s.get(id).unwrap().info.encrypted, "{typed:?}");
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?}");
let saved = s.save_bytes(id).unwrap();
for pw in [typed, prepared] {
let mut again = Session::new();
let reopened = again.open("x.pdf", None, saved.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&again, reopened), ["Page 1"], "{typed:?} as {pw:?}");
}
assert!(matches!(Session::new().open("x.pdf", None, saved, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
#[test]
fn aes256_files_whose_passwords_saslprep_changes_open_as_typed() {
// A fullwidth O: SASLprep makes the owner password "Owner".
let (owner, owner_prepared) = ("\u{FF2F}wner", "Owner");
for (typed, prepared) in SASLPREP_CHANGES {
// pdfcraft-cos keys the file on the SASLprep forms, as ISO 32000-2 asks.
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: typed,
owner_password: owner,
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut s = Session::new();
// pdfcraft-cos accepts the password as typed (it applies SASLprep); the renderer used to
// refuse it, so the session called it a wrong password.
for pw in [typed, prepared, owner, owner_prepared] {
let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?} as {pw:?}");
assert!(s.get(id).unwrap().info.encrypted, "{typed:?} as {pw:?}");
}
assert!(matches!(s.open("x.pdf", None, bytes, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
/// A synthetic phone photo, 400 × 300 px: a white sheet, tilted, on a grey table, with a black
/// mark in the sheet's top-left corner. Returns the PNG and the sheet's corners in pixels.
fn camera_photo() -> (Vec<u8>, [[f64; 2]; 4]) {
@@ -3461,3 +4055,144 @@ fn fonts_added_by_a_content_edit_show_after_refresh_fonts() {
session.refresh_fonts(id);
assert_eq!(format!("{:?}", session.get(id).unwrap().info.fonts), fonts);
}
/// Gap 13: every edit writes a new working file (the original plus one revision). Before the
/// next one is written, the current one is let go of by the working bytes, the display bytes and
/// the renderer: on a 1 GiB file, keeping it is a whole extra gigabyte at the peak of each edit.
#[test]
fn the_working_file_is_let_go_of_before_the_next_is_written() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let doc = s.doc_mut(id).unwrap();
let (working, original) = (Arc::downgrade(&doc.bytes), doc.editor.as_ref().unwrap().cos.bytes().clone());
assert!(!Arc::ptr_eq(&doc.bytes, &original), "an edited working file is a buffer of its own");
Session::release_working(doc);
assert!(Arc::ptr_eq(&doc.bytes, &original) && Arc::ptr_eq(&doc.display, &original));
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while working.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "the working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// The refresh that follows brings everything back.
Session::refresh(doc).unwrap();
assert_eq!(s.get(id).unwrap().info.pages[1].rotation, 90);
let _ = shown(&s, id, 1);
}
/// Every refresh path (full, comments, content, undo, redo) lets go of the previous working file
/// and still shows the current state.
#[test]
fn edits_let_go_of_the_previous_working_file() {
let (mut s, id) = session_with(2);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
let comment = || {
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape: shape.clone(),
contents: String::new(),
author: "Test".into(),
})
};
let watermark =
|| Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false };
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
// A full refresh, a comment refresh, a content refresh, then undo and redo.
type Step<'a> = Box<dyn Fn(&mut Session) + 'a>;
let steps: [(&str, Step<'_>); 5] = [
("rotate", Box::new(|s: &mut Session| s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap())),
("comment", Box::new(move |s: &mut Session| s.apply(id, comment()).unwrap())),
("watermark", Box::new(move |s: &mut Session| s.apply(id, watermark()).unwrap())),
("undo", Box::new(|s: &mut Session| drop(s.undo(id).unwrap()))),
("redo", Box::new(|s: &mut Session| drop(s.redo(id).unwrap()))),
];
for (name, step) in steps {
let (bytes, display) = {
let doc = s.get(id).unwrap();
(Arc::downgrade(&doc.bytes), Arc::downgrade(&doc.display))
};
step(&mut s);
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while bytes.upgrade().is_some() || display.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "{name}: the previous working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
// What is shown is still the current state.
let doc = s.get(id).unwrap();
assert_eq!(doc.info.annotations.len(), 1);
let _ = shown(&s, id, 0);
}
#[test]
fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
let mut s = Session::new();
let mut px = image::RgbaImage::new(3, 2);
px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
px.put_pixel(2, 1, image::Rgba([0, 0, 255, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
let id = s.open("p.pdf", None, pic, None).unwrap();
let d = s.get(id).unwrap();
let r = d.page_images(0)[0].rect;
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
assert_eq!(d.page_image_at(0, [r[2] + 10.0, r[3] + 10.0]), None);
assert_eq!(d.page_image_at(7, [0.0, 0.0]), None);
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(rgba, px.into_raw());
assert!(d.page_image_rgba(0, 1).is_err());
}
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
/// them), with a caption over it and an empty full-page Form on top.
#[test]
fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
let jpeg = jpeg_bytes();
let content = b"q 100 0 0 100 50 50 cm /Fm1 Do Q BT /F1 12 Tf 60 100 Td (Caption over the picture) Tj ET /Fm2 Do".to_vec();
let form = b"/Im1 Do".to_vec();
let stream = |dict: &str, data: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
o.extend_from_slice(data);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Fm1 6 0 R /Fm2 9 0 R >> >> >>".to_vec(),
stream("", &content),
stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 7 0 R /Tiny 8 0 R >> >>", &form),
stream("/Type /XObject /Subtype /Image /Width 3 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", &jpeg),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", &[255, 0, 0]),
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 300]", b""),
];
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());
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(out), None).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.page_images(0).len(), 2, "{:?}", d.page_images(0));
// Under the caption, inside the picture: the Form holding it, not the empty Form on top.
assert_eq!(d.page_image_at(0, [100.0, 100.0]), Some(0));
assert_eq!(d.page_image_at(0, [10.0, 10.0]), None, "the empty full-page Form holds no picture");
// The largest picture in the group, as the JPEG stored in the PDF.
assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg));
assert!(d.page_image_copy_file(0, 1).is_err());
}
+78
View File
@@ -0,0 +1,78 @@
//! Explicit document-font discovery. Interface fallbacks never enter this catalogue.
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct FontFile {
pub path: PathBuf,
pub family: String,
pub face: String,
}
/// Standard user-installed font folders; discovery is performed only on request.
pub fn installed_directories() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
let mut dirs = vec![PathBuf::from("/Library/Fonts")];
if let Some(home) = home {
dirs.push(home.join("Library/Fonts"));
}
dirs
} else if cfg!(windows) {
let mut dirs = std::env::var_os("WINDIR").map(|p| PathBuf::from(p).join("Fonts")).into_iter().collect::<Vec<_>>();
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(local).join("Microsoft/Windows/Fonts"));
}
dirs
} else {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
if let Some(home) = home {
dirs.extend([home.join(".local/share/fonts"), home.join(".fonts")]);
}
dirs
}
}
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
/// The caller controls roots (automation resolves confinement before calling this).
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
use std::io::Read;
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
let mut files = Vec::new();
let mut entries = 0usize;
let mut budget = 64u64 << 20;
let mut seen = std::collections::HashSet::new();
while let Some((path, depth)) = pending.pop() {
if entries >= 4096 || budget == 0 {
break;
}
entries += 1;
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
if meta.file_type().is_symlink() || !seen.insert(path.clone()) {
continue;
}
if meta.is_dir() && depth < 4 {
if let Ok(children) = std::fs::read_dir(&path) {
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
}
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
if meta.len() > budget {
continue;
}
budget = budget.saturating_sub(meta.len());
let mut bytes = Vec::new();
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
continue;
}
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
files.push(FontFile { path, family, face });
}
}
}
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
files
}
fn font_extension(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
}
+66
View File
@@ -0,0 +1,66 @@
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage};
use crate::{Document, guard};
pub struct TextEditPreview {
page: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
}
impl TextEditPreview {
pub fn renderer(&self) -> RenderPool {
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
}
impl Document {
/// Measure a draft without changing bytes, dirty state, or undo history.
pub fn text_edit_layout(
&self,
page: usize,
block: usize,
text: &str,
style: &pdfcraft_edit::BlockStyle,
) -> Result<pdfcraft_edit::text::TextEditLayout, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_edit::text::text_edit_layout(&editor.cos, page, block, text, style).map_err(|e| e.to_string())
})?
}
/// Embedded source faces for a text editor, bounded and validated as sfnt programs.
/// This is read-only; no source font is installed or exported.
pub fn text_editor_fonts(&self, page: usize) -> Vec<pdfcraft_edit::text::TextEditorFont> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text::text_editor_fonts(&e.cos, page).ok()).unwrap_or_default()
}
/// Preparing the background never changes working bytes, dirty state or undo history.
pub fn text_edit_preview(&self, page: usize, block: usize) -> Result<TextEditPreview, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::clear_block(&mut cos, page, block).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
}
+109 -18
View File
@@ -8,8 +8,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_js::formcalc::run_formcalc_within;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within};
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
use pdfcraft_js::xfa_session::{Script, XfaSession};
use pdfcraft_xfa::model::Template;
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
@@ -261,6 +261,10 @@ struct Runner<'a, 'b> {
fields: Option<HashMap<String, FieldInfo>>,
/// The live form as last built; dropped when a script changed the form.
cached: Option<LiveForm>,
/// The script engine for this event: one thread for all its scripts, each run in a fresh
/// context on the form as it is then (`cached`, kept up to date in place for values and
/// rebuilt when the structure changed).
session: Option<XfaSession>,
changes: Changes,
}
@@ -287,10 +291,39 @@ impl<'a, 'b> Runner<'a, 'b> {
by_som: None,
fields: None,
cached: None,
session: None,
changes: Changes::default(),
}
}
/// The live form no longer matches the data (its structure changed): rebuilt for the next
/// script.
fn invalidate(&mut self) {
self.cached = None;
}
/// The script engine for this event.
fn session(&mut self) -> &mut XfaSession {
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
self.session.get_or_insert_with(|| XfaSession::new(doc_info))
}
/// Give the object at `som` the value `text` in the data and in the live form (refreshed
/// in place, as the tree builder would set it; a value changes nothing else, so the tree
/// is not rebuilt for it).
fn value_op(&mut self, som: &str, path: pdfcraft_xfa::DataPath, text: String) {
let op = pdfcraft_xfa::DataOp::Value { path, text };
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
let refreshed = match self.cached.as_mut() {
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
None => true,
};
if !refreshed {
self.invalidate();
}
}
fn live(&mut self) -> &LiveForm {
if self.cached.is_none() {
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
@@ -346,11 +379,12 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
/// Change the data (and the in-memory copy scripts read).
/// Change the data's structure (and the in-memory copy scripts read): the form is rebuilt
/// for the next script.
fn data_op(&mut self, op: pdfcraft_xfa::DataOp) {
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
self.cached = None;
self.invalidate();
}
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
@@ -363,7 +397,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) {
return false;
}
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: value.to_string() });
self.value_op(som, path, value.to_string());
self.changes.values += 1;
return true;
};
@@ -399,7 +433,7 @@ impl<'a, 'b> Runner<'a, 'b> {
let text = data_text(&updated);
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name, new_value);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.value_op(som, path, text);
self.changes.values += 1;
true
}
@@ -432,7 +466,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if wrote {
// What the packet now holds (a value it could not take is reported above).
self.data = pdfcraft_xfa::data_of(self.doc);
self.cached = None;
self.invalidate();
}
Ok(())
}
@@ -453,9 +487,10 @@ impl<'a, 'b> Runner<'a, 'b> {
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
self.console(report.warnings);
crate::xfa_redraw(self.doc)?;
self.console(crate::undrawn_field_warnings(self.doc));
self.by_som = None;
self.fields = None;
self.cached = None;
self.invalidate();
Ok(())
}
@@ -489,7 +524,7 @@ impl<'a, 'b> Runner<'a, 'b> {
self.changes.values += 1;
}
}
self.cached = None;
self.invalidate();
Ok(())
}
@@ -529,7 +564,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.cached = None;
self.invalidate();
relayout = true;
}
}
@@ -537,7 +572,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if self.ov.access.insert(som.clone(), access.clone()).as_ref() != Some(access) {
self.ov_dirty = true;
self.changes.visibility += 1;
self.cached = None;
self.invalidate();
relayout = true;
}
}
@@ -585,17 +620,23 @@ impl<'a, 'b> Runner<'a, 'b> {
return Ok(None);
}
self.runs += 1;
#[cfg(test)]
self.check_live();
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
let root_js = to_js(&self.live().root);
let (limits, timeout) = (limits_for(&ev.activity), timeout_for(&ev.activity));
let o = if ev.formcalc {
run_formcalc_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
} else {
run_xfa_within(&ev.script, &event, &doc_info, root_js, limits, timeout)
let root = to_js(&self.live().root);
let script = Script {
source: ev.script.clone(),
formcalc: ev.formcalc,
event,
root,
limits: limits_for(&ev.activity),
timeout: timeout_for(&ev.activity),
};
let o = self.session().run(script);
if o.abandoned {
self.run.ran_away = true;
// The thread is left to the runaway script: nothing more is sent to it.
self.session = None;
}
self.console(o.console.iter().cloned());
for n in &o.notes {
@@ -634,12 +675,15 @@ impl<'a, 'b> Runner<'a, 'b> {
/// End the event: write what is left, lay the form out if a capped relayout is owed, and
/// report what the limits cut.
fn finish(mut self) -> Result<Changes, String> {
#[cfg(test)]
self.check_live();
if self.relayouts_skipped {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout()?;
}
self.flush()?;
self.session = None;
if self.skipped > 0 {
let why = if self.run.ran_away {
"one ran too long".to_string()
@@ -657,6 +701,42 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
impl Runner<'_, '_> {
/// The live form kept up to date in place must be the form a fresh build gives: every
/// field's SOM, value, presence and access (tests run this before every script and at the
/// end of every event). A difference is a bug in the in-place refresh, so the test binary
/// is ended at once: a panic here would be caught by the engine's guard and turned into a
/// script error the test might not look at.
#[cfg(test)]
fn check_live(&mut self) {
let Some(live) = self.cached.as_ref() else { return };
let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
let (mut have, mut want) = (Vec::new(), Vec::new());
collect_fields(&live.root, &mut have, 0);
collect_fields(&fresh.root, &mut want, 0);
if have != want {
let differ: Vec<String> =
have.iter().zip(want.iter()).filter(|(a, b)| a != b).take(5).map(|(a, b)| format!("live {a:?} vs fresh {b:?}")).collect();
eprintln!("the live form diverged from a fresh build ({} vs {} fields): {}", have.len(), want.len(), differ.join("; "));
std::process::abort();
}
}
}
/// Every field's (SOM, value, presence, access), in document order.
#[cfg(test)]
fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, depth: usize) {
if depth > 64 {
return;
}
if n.kind == Some(NodeKind::Field) {
out.push((n.som.clone(), n.value.clone(), n.presence.clone(), n.access.clone()));
}
for c in &n.children {
collect_fields(c, out, depth + 1);
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
@@ -692,6 +772,17 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
r.finish().map(|_| ())
}
/// The form's data changed outside its scripts (a data file imported, a picture chosen): lay
/// the form out again from the data, then, when `calculate`, run every calculation.
pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, calculate: bool, run: &mut XfaRun) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
r.relayout()?;
if calculate {
r.calculates()?;
}
r.finish().map(|_| ())
}
/// A button's click script (or any event, by SOM path and activity), then calculations.
pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
+100
View File
@@ -0,0 +1,100 @@
//! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs.
mod support {
pub mod font_fixture;
}
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
use pdfcraft_engine::{BlockStyle, Edit, Session};
use pdfcraft_render::{PageRenderer, RenderRequest};
use std::sync::Arc;
fn document() -> Arc<Vec<u8>> {
let mut d = Document::new_empty();
let pages = d.add(Object::Dict(Dict::new()));
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 18 Tf 30 160 Td (Source) Tj ET")));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(font));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"Contents".to_vec(), Object::Ref(contents));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 300, 240].map(Object::Int).to_vec()));
let page = d.add(Object::Dict(p));
d.update_dict(pages, |p| {
p.set(b"Type".to_vec(), Object::name("Pages"));
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
p.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
let mut root = Dict::new();
root.set(b"Type".to_vec(), Object::name("Catalog"));
root.set(b"Pages".to_vec(), Object::Ref(pages));
let root = d.add(Object::Dict(root));
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
}
fn pixels(bytes: Arc<Vec<u8>>) -> Vec<u8> {
let mut renderer = PageRenderer::new(bytes, Default::default());
let p = renderer.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
p.rgba.to_vec()
}
fn exercise(font: Vec<u8>, text: &str, expected_family: &str) {
let mut s = Session::new();
let id = s.open("source.pdf", None, document(), None).unwrap();
let original = pixels(s.get(id).unwrap().display.clone());
s.apply(id, Edit::RegisterTextFont { page: 0, bytes: Arc::new(font.clone()) }).unwrap();
let resource = s.get(id).unwrap().text_editor_fonts(0).into_iter().find(|f| f.bytes == font).unwrap().resource;
let style = BlockStyle { resource: Some(resource), width: Some(240.0), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: text.into(), style: style.clone() }).unwrap();
let edited = s.get(id).unwrap();
let lines = edited.text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>(), text);
assert!(lines.iter().all(|l| l.base_font == expected_family));
assert!((lines[0].baseline()[1] - 160.0).abs() < 0.01);
let current = pixels(edited.display.clone());
assert_ne!(current, original);
let saved = s.save_bytes(id).unwrap();
let re = s.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(pixels(s.get(re).unwrap().display.clone()), current);
assert_eq!(s.get(re).unwrap().text_blocks(0)[0].editor_text, text);
let twice = format!("{text} {text}");
s.apply(re, Edit::EditTextBlock { page: 0, block: 0, text: twice.clone(), style: style.clone() }).unwrap();
assert_eq!(s.get(re).unwrap().text_blocks(0)[0].editor_text, twice);
let again = s.save_bytes(re).unwrap();
let next = s.open("twice.pdf", None, again, None).unwrap();
assert_eq!(s.get(next).unwrap().text_blocks(0)[0].editor_text, twice);
assert_eq!(pixels(s.get(next).unwrap().display.clone()), pixels(s.get(re).unwrap().display.clone()));
s.apply(re, Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style }).unwrap();
assert!(s.get(re).unwrap().text_lines(0).is_empty(), "every contextual glyph is deleted");
let empty = pixels(s.get(re).unwrap().display.clone());
assert!(empty.as_chunks::<4>().0.iter().all(|p| p[..3] == [255; 3]));
s.undo(id).unwrap();
assert_eq!(pixels(s.get(id).unwrap().display.clone()), original);
}
#[test]
fn opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen() {
exercise(support::font_fixture::cff_font(), "A A", "RectFont-Regular");
}
#[test]
fn contextual_arabic_marks_ligatures_and_mixed_bidi_keep_logical_text_after_reopen() {
if let Some(path) = std::env::var_os("PDFCRAFT_TEST_ARABIC_FONT") {
exercise(std::fs::read(path).unwrap(), "سَلَام 123", "NotoSansArabic-Regular");
return;
}
let Some(font) = pdfcraft_fonts::document_arabic_font() else {
eprintln!("set CRAFT_FONTS_DIR to test full Arabic glyph programs");
return;
};
if pdfcraft_fonts::embedding::validate(font.bytes).is_err() {
eprintln!("set PDFCRAFT_TEST_ARABIC_FONT to an openly licensed static Noto Sans Arabic face");
return;
}
exercise(font.bytes.to_vec(), "سَلَام 123", "NotoSansArabic-Regular");
}
+1 -1
View File
@@ -27,7 +27,7 @@ fn one_page() -> Document {
fn ink_span(doc: &Document) -> (u32, u32) {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false });
assert!(p.error.is_none(), "{:?}", p.error);
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
let mut ink = false;
+209
View File
@@ -0,0 +1,209 @@
//! Edit text on a scanned page: rewriting an OCR word (invisible text over the scan) shows the
//! new text and hides the scanned word. Synthetic image only.
use pdfcraft_engine::ocr::PlacedWord;
use pdfcraft_engine::{Edit, Session, export};
use pdfcraft_render::{PageRenderer, RenderRequest};
const PAPER: [u8; 3] = [230, 225, 200];
const INK: [u8; 3] = [20, 30, 90];
/// The scanned "word": a block of ink, in image pixels (72 dpi, so points), y down.
const WORD: [u32; 4] = [40, 40, 100, 60];
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
fn scanned() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![PlacedWord { text: "John".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }])
}
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
let (w, h) = (200u32, 100u32);
let mut rgba = Vec::new();
for y in 0..h {
for x in 0..w {
let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
rgba.extend(if ink { INK } else { PAPER });
rgba.push(255);
}
}
let png = export::encode_png(w, h, &rgba).unwrap();
let mut s = Session::new();
let bytes = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, bytes, None).unwrap();
s.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
(s, id)
}
fn word_pixels(s: &Session, id: pdfcraft_engine::DocId) -> Vec<[u8; 3]> {
let doc = s.get(id).unwrap();
let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let mut px = Vec::new();
for y in WORD[1] + 2..WORD[3] - 2 {
for x in WORD[0] + 2..WORD[2] - 2 {
let i = ((y * out.width + x) * 4) as usize;
px.push([out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]);
}
}
px
}
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 24)
}
#[test]
fn editing_an_ocr_word_covers_the_scan_and_shows_the_new_text() {
let (mut s, id) = scanned();
let blocks = s.get(id).unwrap().text_blocks(0);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].invisible, "the OCR layer is invisible text");
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jane".into(), style: Default::default() }).unwrap();
let blocks = s.get(id).unwrap().text_blocks(0);
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Jane"]);
assert!(!blocks[0].invisible, "the new text is drawn");
let px = word_pixels(&s, id);
let paper = px.iter().filter(|p| near(**p, PAPER)).count();
let ink = px.iter().filter(|p| near(**p, INK)).count();
// The scanned word is covered in the paper's colour; the new letters are in the ink's.
assert!(paper * 10 > px.len() * 4, "mostly paper now: {paper} of {}", px.len());
assert!(ink * 100 > px.len() * 3, "the new text shows in the scan's ink colour: {ink} of {}", px.len());
}
#[test]
fn an_explicit_colour_is_kept_for_the_new_text() {
let (mut s, id) = scanned();
let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jane".into(), style }).unwrap();
let px = word_pixels(&s, id);
assert!(px.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60), "red text");
assert!(!px.iter().any(|p| near(*p, INK)), "no scanned ink is left");
}
/// A word as OCR boxes it on a scan set at `size` on `baseline`: tight around its ink, from the
/// descender line when it has descenders, up to the cap height.
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
PlacedWord { text: text.into(), origin: [x0, baseline - low], across: [x1 - x0, 0.0], up: [0.0, 0.718 * size + low] }
}
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0] }
}
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and
/// the line below are others, so rewriting one never moves the rest.
#[test]
fn ocr_words_group_into_phrases_that_edit_in_place() {
let (mut s, id) = scanned_with(vec![
word("EMPLOYEE", 10.0, 60.0, 70.0),
word("NAME", 64.0, 90.0, 70.0),
word("John", 120.0, 145.0, 70.0),
word("Smith", 120.0, 148.0, 56.0),
]);
let texts = |s: &Session| s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>();
assert_eq!(texts(&s), ["EMPLOYEE NAME", "John", "Smith"]);
let john = s.get(id).unwrap().text_blocks(0)[1].rect;
s.apply(id, Edit::EditTextBlock { page: 0, block: 1, text: "Jane".into(), style: Default::default() }).unwrap();
assert_eq!(texts(&s), ["EMPLOYEE NAME", "Jane", "Smith"]);
let blocks = s.get(id).unwrap().text_blocks(0);
assert!(blocks[0].invisible && blocks[2].invisible, "the other OCR words are left alone");
assert!(!blocks[1].invisible);
assert!((blocks[1].rect[0] - john[0]).abs() < 1.0, "the new word starts where the old one did: {:?} vs {john:?}", blocks[1].rect);
}
/// Several words of one OCR phrase rewrite as one line, without wrapping onto the line below.
#[test]
fn a_longer_ocr_phrase_grows_rightwards() {
let (mut s, id) = scanned_with(vec![
scanned_word("Project", 20.0, 55.0, 70.0, 11.0),
scanned_word("Lead", 59.0, 80.0, 70.0, 11.0),
scanned_word("Testing", 20.0, 56.0, 56.0, 11.0),
]);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Project Coordinator Lead".into(), style: Default::default() }).unwrap();
let blocks = s.get(id).unwrap().text_blocks(0);
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Project Coordinator Lead", "Testing"]);
assert_eq!(blocks[0].rows.len(), 1, "one line");
}
/// OCR words differ in size and are stretched to fit the scan: a rewritten phrase keeps each
/// word's width, so the words don't run into one another.
#[test]
fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
let (mut s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 10.5),
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
scanned_word("Example", 82.0, 124.0, 70.0, 11.5),
]);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["John", "Smith", "Exam"]);
assert!((lines[0].rect[2] - 45.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
for pair in lines.windows(2) {
assert!(pair[1].rect[0] >= pair[0].rect[2] - 0.5, "{:?} runs into {:?}", pair[1].text, pair[0].text);
}
}
/// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write.
fn stretched_visible() -> std::sync::Arc<Vec<u8>> {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
let mut d = Document::new_empty();
let pages = d.add(Object::Dict(Dict::new()));
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 12 Tf 3 0 0 1 20 60 Tm (Source) Tj ET")));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(font));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"Contents".to_vec(), Object::Ref(contents));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 400, 120].map(Object::Int).to_vec()));
let page = d.add(Object::Dict(p));
d.update_dict(pages, |p| {
p.set(b"Type".to_vec(), Object::name("Pages"));
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
p.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
let mut root = Dict::new();
root.set(b"Type".to_vec(), Object::name("Catalog"));
root.set(b"Pages".to_vec(), Object::Ref(pages));
let root = d.add(Object::Dict(root));
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
}
/// Visible text is not OCR: a styled rewrite of stretched text keeps its width, draws no cover
/// and stays visible.
#[test]
fn visible_stretched_text_rewrites_unchanged() {
let mut s = Session::new();
let id = s.open("stretched.pdf", None, stretched_visible(), None).unwrap();
let before = s.get(id).unwrap().text_blocks(0);
assert_eq!(before.len(), 1);
assert!(!before[0].invisible);
let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Source".into(), style }).unwrap();
let after = s.get(id).unwrap().text_blocks(0);
assert_eq!(after.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Source"]);
assert!(!after[0].invisible);
let (w0, w1) = (before[0].rect[2] - before[0].rect[0], after[0].rect[2] - after[0].rect[0]);
assert!((w0 - w1).abs() < 1.0, "the stretched word keeps its width: {w0} → {w1}");
assert!((before[0].rect[0] - after[0].rect[0]).abs() < 0.5);
// No paper-coloured cover: outside the glyphs the page is still white.
let doc = s.get(id).unwrap();
let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none());
let r = after[0].rect;
let (x, y) = ((r[0] - 1.0).max(0.0) as u32, (120.0 - (r[1] + r[3]) / 2.0) as u32);
let i = ((y * out.width + x) * 4) as usize;
assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]);
}
+110
View File
@@ -0,0 +1,110 @@
//! Opt-in acceptance checks. Private files, pixels and font programs never become fixtures.
use std::sync::Arc;
use pdfcraft_engine::{Edit, ImageEdit, Session};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
fn pixels(bytes: Arc<Vec<u8>>, page: usize) -> pdfcraft_render::RenderedPage {
let mut renderer = PageRenderer::new(bytes, Default::default());
let rendered = renderer.render(RenderRequest { page, kind: RequestKind::Pixels, scale: 1.5, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
assert!(rendered.warnings.is_empty());
rendered
}
fn near(actual: [f64; 4], expected: [f64; 4]) {
assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{actual:?} vs {expected:?}");
}
#[test]
#[ignore = "requires an explicitly supplied local PDF with cropped artwork"]
fn private_cropped_images_resize_save_reopen_and_undo() {
let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path");
let original = Arc::new(std::fs::read(&path).unwrap());
let mut session = Session::new();
let id = session.open("private.pdf", None, original.clone(), None).unwrap();
let pages = session.get(id).unwrap().info.pages.len();
let mut checked = 0;
for page in 0..pages {
let images = session.get(id).unwrap().page_images(page);
let original_pixels = pixels(session.get(id).unwrap().display.clone(), page);
let info = session.get(id).unwrap().info.pages[page].clone();
let text = || {
session
.get(id)
.unwrap()
.text_lines(page)
.into_iter()
.map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline()))
.collect::<Vec<_>>()
};
let original_text = text();
for (index, image) in images.iter().enumerate() {
let Some(visible) = image.visible_rect else { continue };
for factor in [0.7, 1.04] {
// Keep the visible upper-left corner fixed; transform the same cropped pixels.
let transform = |r: [f64; 4]| {
[
visible[0] + (r[0] - visible[0]) * factor,
visible[3] + (r[1] - visible[3]) * factor,
visible[0] + (r[2] - visible[0]) * factor,
visible[3] + (r[3] - visible[3]) * factor,
]
};
session.apply(id, Edit::EditPageImage { page, index, change: ImageEdit::Move(transform(image.rect)) }).unwrap();
let doc = session.get(id).unwrap();
let after = doc.page_images(page);
near(after[index].visible_rect.unwrap(), transform(visible));
near(after[index].rect, transform(image.rect));
assert_eq!(after[index].object, image.object, "source pixels remain embedded unchanged");
let current_text = doc
.text_lines(page)
.into_iter()
.map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline()))
.collect::<Vec<_>>();
assert_eq!(current_text, original_text, "all Chinese and Latin labels stay put");
for (other, (a, b)) in after.iter().zip(&images).enumerate().filter(|(other, _)| *other != index) {
assert_eq!(
(a.rect, a.visible_rect, a.matrix, a.object),
(b.rect, b.visible_rect, b.matrix, b.object),
"neighbor image {other} unchanged"
);
}
let live = pixels(doc.display.clone(), page);
assert_eq!((live.width, live.height), (original_pixels.width, original_pixels.height));
assert_ne!(live.rgba, original_pixels.rgba, "the actual rendered image changes size");
let dest = after[index].visible_rect.unwrap();
let union = [visible[0].min(dest[0]), visible[1].min(dest[1]), visible[2].max(dest[2]), visible[3].max(dest[3])];
let a = info.user_to_view(union[0] as f32, union[1] as f32);
let b = info.user_to_view(union[2] as f32, union[3] as f32);
for (offset, (before, after)) in original_pixels.rgba.as_chunks::<4>().0.iter().zip(live.rgba.as_chunks::<4>().0.iter()).enumerate() {
let x = (offset % live.width as usize) as f32 / 1.5;
let y = (offset / live.width as usize) as f32 / 1.5;
if x < a[0].min(b[0]) - 2.0 || x > a[0].max(b[0]) + 2.0 || y < a[1].min(b[1]) - 2.0 || y > a[1].max(b[1]) + 2.0 {
assert_eq!(before, after, "pixels outside the moved crop stay identical");
}
}
let saved = session.save_bytes(id).unwrap();
let scratch = std::env::temp_dir().join(format!("pdfcraft-private-resize-{}.pdf", std::process::id()));
std::fs::write(&scratch, saved.as_slice()).unwrap();
let disk = Arc::new(std::fs::read(&scratch).unwrap());
let mut reopened = Session::new();
let re = reopened.open("saved.pdf", None, disk, None).unwrap();
near(reopened.get(re).unwrap().page_images(page)[index].visible_rect.unwrap(), dest);
assert_eq!(
pixels(reopened.get(re).unwrap().display.clone(), page).rgba,
live.rgba,
"saved/reopened rendering exactly matches the applied edit"
);
std::fs::remove_file(scratch).unwrap();
session.undo(id).unwrap();
assert_eq!(pixels(session.get(id).unwrap().display.clone(), page).rgba, original_pixels.rgba, "undo restores original rendering");
checked += 1;
}
}
}
assert!(checked > 0);
assert_eq!(&std::fs::read(path).unwrap(), original.as_ref(), "the supplied file is untouched");
println!("verified {checked} cropped-image grow/shrink save/reopen/undo cycles on {pages} pages");
}
@@ -0,0 +1,82 @@
//! Private acceptance input stays outside the repository; no private pixels/fonts are emitted.
use pdfcraft_engine::{BlockStyle, Edit, Session};
use pdfcraft_render::{PageRenderer, RenderRequest};
use std::sync::Arc;
fn pixels(bytes: Arc<Vec<u8>>, page: usize) -> Vec<u8> {
let mut renderer = PageRenderer::new(bytes, Default::default());
let rendered = renderer.render(RenderRequest { page, scale: 1.5, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
assert!(rendered.warnings.is_empty(), "{:?}", rendered.warnings);
rendered.rgba.to_vec()
}
#[test]
#[ignore = "requires a private Chinese PDF and its full approved face in craft-fonts"]
fn private_new_chinese_glyphs_keep_face_position_save_reopen_and_undo() {
let path = std::env::var("PDFCRAFT_CHINESE_TEXT_PDF").expect("provide a local PDF");
let original = Arc::new(std::fs::read(&path).unwrap());
let mut session = Session::new();
let id = session.open("private.pdf", None, original.clone(), None).unwrap();
let pages = session.get(id).unwrap().info.pages.len();
let all: String = (0..pages).flat_map(|p| session.get(id).unwrap().text_lines(p)).map(|l| l.text).collect();
let new: String = "测试编辑删除龘".chars().filter(|c| !all.contains(*c)).take(4).collect();
assert!(new.chars().count() >= 2, "the test must insert characters not already in this PDF");
for page in 0..pages {
let before = session.get(id).unwrap();
let blocks = before.text_blocks(page);
let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.editor_text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))).unwrap();
let source_lines = before.text_lines(page);
let anchor = source_lines[block.lines[0]].baseline();
let original_pixels = pixels(before.display.clone(), page);
let source_font_bytes: Vec<_> =
before.text_editor_fonts(page).into_iter().filter(|f| !f.resource.starts_with("PCApproved")).map(|f| (f.resource, f.bytes)).collect();
let draft = format!("{}{new}", block.editor_text);
let generation = before.edit_generation();
let layout = before.text_edit_layout(page, index, &draft, &BlockStyle::default()).unwrap();
assert!(layout.draft_rect.is_some());
assert_eq!(before.edit_generation(), generation);
session.apply(id, Edit::EditTextBlock { page, block: index, text: draft.clone(), style: BlockStyle::default() }).unwrap();
let edited = session.get(id).unwrap();
let lines = edited.text_lines(page);
let added: Vec<_> = lines.iter().filter(|l| l.text.chars().any(|c| new.contains(c))).collect();
assert!(!added.is_empty());
for line in added {
assert_eq!(line.base_font, "DroidSansFallback", "same family, not a substitute");
assert!(line.size > 0.0);
assert_eq!(line.color, source_lines[*block.lines.last().unwrap()].color);
}
let first = lines.iter().find(|l| l.text.starts_with(source_lines[block.lines[0]].text.trim())).unwrap();
assert!(first.baseline().iter().zip(anchor).all(|(a, b)| (a - b).abs() < 0.01));
let editor_fonts = edited.text_editor_fonts(page);
for (resource, bytes) in &source_font_bytes {
assert_eq!(&editor_fonts.iter().find(|f| &f.resource == resource).unwrap().bytes, bytes, "source subset is untouched");
}
let live = pixels(edited.display.clone(), page);
assert_ne!(live, original_pixels);
let saved = session.save_bytes(id).unwrap();
let scratch = std::env::temp_dir().join(format!("pdfcraft-private-glyphs-{}.pdf", std::process::id()));
std::fs::write(&scratch, saved.as_slice()).unwrap();
let reopened = session.open("reopened.pdf", None, Arc::new(std::fs::read(&scratch).unwrap()), None).unwrap();
let reopen = session.get(reopened).unwrap();
assert_eq!(pixels(reopen.display.clone(), page), live);
assert!(reopen.text_lines(page).iter().any(|l| l.text.contains(&new)));
let content = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
assert_eq!(content(&reopen.text_blocks(page)[index].editor_text), content(&draft));
let shortened = reopen.text_blocks(page)[index].editor_text.replace(&new, &new.chars().skip(1).collect::<String>());
session.apply(reopened, Edit::EditTextBlock { page, block: index, text: shortened.clone(), style: BlockStyle::default() }).unwrap();
let saved = session.save_bytes(reopened).unwrap();
let after_delete = pixels(session.get(reopened).unwrap().display.clone(), page);
let twice = session.open("deleted-reopened.pdf", None, saved, None).unwrap();
assert_eq!(pixels(session.get(twice).unwrap().display.clone(), page), after_delete);
assert_eq!(content(&session.get(twice).unwrap().text_blocks(page)[index].editor_text), content(&shortened));
session.close(twice);
session.close(reopened);
std::fs::remove_file(scratch).unwrap();
session.undo(id).unwrap();
assert_eq!(pixels(session.get(id).unwrap().display.clone(), page), original_pixels);
// The app conservatively stays dirty after Undo; the source bytes remain exact.
assert_eq!(session.get(id).unwrap().bytes.as_slice(), original.as_slice());
}
assert_eq!(std::fs::read(path).unwrap(), original.as_slice());
}

Some files were not shown because too many files have changed in this diff Show More