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>
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>
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>
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>
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>
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>
* 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>
* 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>
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>
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.
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>
* 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>
* 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>
* 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>
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>
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.
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.
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.
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.
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.
* 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>
* 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>
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.
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.
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.
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.
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.
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.
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.
* 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>
* 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>
* 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>
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.
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.
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.
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.
`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>
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.
* 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>
* 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>
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>