A rewritten paragraph is drawn before its text object and restated only the
font, spacing and fill. Anything the text object set before its first show was
lost: `1 Tr 1 0 0 RG 2 w` inside BT turned red outline text into filled black
text, and `/GS1 gs` inside BT made semi-transparent text opaque, even when the
text was unchanged.
The interpreter now tracks stroke colour, line width and the ExtGStates
selected (bounded to 32 distinct names), and the rewrite restates whatever
differs from the state in force where it is drawn. Styled runs restate it per
run, too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewriting a paragraph in one style split its text at white space and
joined the words with one space, so a run of spaces, an indent or a
trailing space typed in the editor was saved as one space or none. The
text is now kept as typed (other white space becomes a space, and CR or
CRLF become line breaks), and the wrapper keeps each run of spaces,
dropping only the spaces where a row wraps.
A justified (stretched) row still drops its indent and the spaces after
its last word, as before, so word spacing reaches only the spaces
between its words; keeping an indent on a justified row is a possible
follow-up. Centred and right-aligned rows, and the editor's draft box,
are measured to the last word, and a paragraph at 0 Tz is not justified.
Fixes#4199Fixes#3797
Agents read the tool description to plan a call. The description of
ocr_recognize_files now says that two inputs which would write the
same output file are refused before anything is read or written, so
an agent does not expect a partial batch.
Checked: cargo test -p pdfcraft-automation --test automation -- tools
(86 passed, 0 failed). The text is the only change in this commit.
The ocr_recognize_files tool wrote every input's searchable copy to
the same name in the output folder. Two inputs with one file name
(a/scan.pdf and b/scan.pdf) therefore wrote the same output: the
second replaced the first while both results reported success.
Reproduced on the unchanged tree with the OCR models installed: both
results pointed at out/scan.pdf, and the folder held one file.
The whole batch is now refused before anything happens. The output
folder is not created, nothing is read, and the OCR engines are not
loaded. The error names the colliding file names and says what to do.
Names are compared the way the default filesystem compares them:
without regard to case on Windows and macOS, exactly elsewhere, and on
Windows a trailing dot or space is not part of the name. The compared
name is the one the output is written under, after the separator
replacement the writer already applies. A request without collisions
runs as before.
Not changed here: the batch dialog in crates/ui-egui (ocr_ui.rs) writes
its outputs the same way and needs its own change.
Checked: the three ocr_recognize_files tests in
crates/automation/tests/automation.rs and the unit test
shared_output_names_follow_the_default_filesystem, run with the models
installed and without them. Each pinned line was reverted in turn and
the test that pins it failed; the file was restored after each step.
Catalan, Finnish, Hindi, Korean, Indonesian, Romanian and Albanian get the strings this PR adds
(AI-assisted translations from English, not separately native-reviewed), so the catalogue coverage
tests pass. ATTRIBUTION.toml's sha256 for each file is refreshed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Optimize reads a JPEG's header again for every group of fragments it tries,
and one image can be drawn many times. The scan skipped stray bytes between
segments one at a time, so a JPEG with megabytes of them after SOI cost a
full walk on every call: 64 references to one such image delayed the first
progress call by seconds.
The scan now treats a stray byte as an unreadable header, which leaves the
image as it is, and stops after 4096 markers and fill bytes. Each step jumps
over a whole segment, so the work no longer grows with the stream's size.
The covered text walks every run of a covered line, and a run can draw
no words (`( ) Tj`), so a line now costs its word boxes or its runs,
whichever are more. A word followed by many empty runs can no longer
be walked once per cover outside the 50-million-comparison bound.
Review on #4198 (CodeRabbit).
crates/organize/src/import.rs: upstream's name_tree_entries (seen-node set, each node read once) kept whole; this PR's only change to it, pub(crate) for attachments.rs, applied on top.
crates/organize/src/attachments.rs: its name_tree_entries call passes the new seen set.
crates/automation/tests/attachment_key.rs, crates/ui-egui/tests/attach_picker_target.rs: moved into tests/it/ and registered in main.rs, following upstream's one-binary layout (organize keeps its single test file, as upstream's rule says).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TextLine::runs also says whether joining put a space before each run,
and covered_words uses it: a word shown in two runs with nothing
between them (`SE` `CRET`) is reported as one word, not `SE CRET`.
Runs with uncovered runs left out between them are still spaced.
The regression test writes its content streams on one line each.
Review on #4198 (CodeRabbit).
Review on #3943, point 1. has_password_values ran a full fields() walk
on every save and recovery snapshot, and clear_password_values walked
every field's ancestors again for each ancestor of each target.
has_password_flag now answers first, reading only /Ff, /Kids and /Parent:
the field tree from /Fields (seen set, 32 levels) and, as fields() does,
the widgets on the pages with the fields above them. A form without such
a field returns before any Field is built. clear_password_values finds
each field's ancestors once.
The test puts a page-only widget, a flag inherited from an unlisted
parent and a nested field whose widget is on no page each alone in a
document. Mutants that drop the page scan, the parent chain, the tree
walk or the descent into kids each fail it, as does treating every
ancestor as exclusive. Dropping the seen set or the early-out changes
only speed (equivalent).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #3943. A black-box test of Acrobat (synthetic form, Save As and
Ctrl+S, every decoded object searched) showed how it saves a Password
field: the typed value is nowhere in the file; a field with /DV is saved
holding /DV, one without is left empty; the asterisks are drawn from the
saved value (5 for a 5-letter default, not 13 for the typed password);
/DV and /AA are kept.
clear_password_values now does the same: it restores the (inherited)
/DV as the value, or leaves the field empty, and redraws the widgets, so
the appearance no longer shows how long the typed password was. A field
already at its default no longer counts as holding a password, so such
a form needs no cleaned copy. The parity note for form.fill-text says so
and lists the two limits Acrobat shares (a script copying the password
into an ordinary field, and an earlier revision kept by an incremental
save).
Mutants, each failing a test: no /DV restore, no redraw, the default
ignored in password_fields, emptiness ignored (an empty string in an
array). Dropping the empty-default filter was equivalent, so the filter
is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit on #3943: sign, sign_with_timestamp, timestamp_document and dss::embed
each write a new incremental file from editor.cos, outside the writable() boundary
saves use, so a typed Password value was signed into the file. Each now writes from
the writable() copy. The new test signs a form with a typed value and checks the
signed file holds the masked appearance but not the value; the three TSA/DSS paths
need a timestamp server or validation data, and use the same one-line change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit on #3943: Extract Pages copies the fields of the pages it takes, and
write_new (Combine, Extract, Split, Create) wrote that copy without the writable()
boundary saves use, so the new file kept a typed Password value. write_unchecked, which
write_new and Split's size measuring both go through, now writes the sanitized copy. The
other write_full/write_incremental calls in the engine build in-memory bytes (display,
previews, the working copy, print rasters), not files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up: the helpers checked only the field dictionary's own /V. A value
inherited through /Parent or carried by a separate widget was missed, so the
document could be written as it was. Detection now uses the resolved value and
the widgets; clearing removes /V from the field, its widgets and every ancestor
only password fields inherit from, and gives the field an empty value of its own
where a shared ancestor value has to stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text field with the Password flag kept its typed value in /V, so Save, a full
rewrite, Reduce File Size and the recovery snapshots all wrote it in plain text.
ISO 32000-2 §12.7.5.3, Table 231 (note to the Password flag): PDF processors
should never store the value in the PDF file. Each of those paths now writes a
copy without the values; the open document keeps them in memory, and the masked
appearance is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text line joins runs painted on one baseline, and the audit reported
the line's whole text for a cover over part of it, including words
painted beside the cover that stay visible (`SECRETtail` for a cover
over `SECRET`).
TextLine now keeps each joined run's text with the end of its word
boxes (TextLine::runs), and the audit reports the runs whose words
the cover overlaps. A single run, a line reordered for reading
(right-to-left or visual-order Indic), or a cover that overlaps the
line's box but none of its words still reports the whole line.
Fixes#4164
When the whole page left the screen, `composer` returned before it set
`focus`, so the editor didn't take the keyboard again when the page came
back (review on #4095). That path now asks for the focus too.
Test `the_composer_takes_the_keyboard_back_after_its_page_was_off_screen`
scrolls page 1 off the screen and back; it fails without the change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The floating comment card (new note, text box, edit comment) was an egui
Area constrained to the whole window. When the page scrolled its comment
under the mode bar, the card was pushed back into the window and sat over
the mode bar and toolbar until the whole page left the screen.
It now shows only while its anchor is inside the document view, and is
kept inside that view. While the comment is out of sight the draft is
kept, and the editor takes the focus again when it comes back.
Test: tests/comments.rs the_composer_hides_while_its_comment_is_scrolled_away
scrolls a sticky note's anchor above the document with its page still on
screen; it fails without the change (the card sat at y=43, over the
toolbar, with the document starting at y=134) and passes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recognize text (one document) and Recognize text in multiple files ran
their worker unguarded. A panic in the recognizer (third-party inference)
or a worker that died holding the progress lock left the run in place: the
progress notice stayed, and every later run was refused with "Text
recognition is already running" until restart.
- Both workers run inside `pdfcraft_engine::guard`; a panic becomes the
run's error ("Recognize text failed: …", in the UI's language).
- `poll_ocr` treats a poisoned progress lock as a failure: it clears the
run or batch and says so.
The batch's loop moved unchanged into `recognize_files` so it can run
inside the guard. A thread that can't be spawned is #1871's (#1819), and
Export and the Action Wizard are #4043's; this leaves both alone.
Tests (crates/ui-egui/src/ocr_ui.rs): a panicking recognizer ends with
the error; a page run and a batch whose worker died holding the lock are
cleared with a notice. Both fail without the change. The OCR integration
tests pass with the real models (PDFCRAFT_MODELS).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishing a box trimmed its text before comparing it with the item, so
an unchanged box whose text was saved with trailing spaces finished as
an edit. Before an Undo or Redo step that edit was applied, clearing
the document's Redo. A box matching its item is now no edit.
Review follow-up (CodeRabbit on #3488): a form an earlier PdfCraft made
should work in Acrobat once it has been filled here too, not only once a
field is added or edited. set_value now runs the same conversion (shared
with ensure_form through inline_fonts_as_objects and store_form_fonts);
it adds nothing, only changes where the existing fonts are stored.
Not in a form a signature covers (a signature field with /V): there the
file stays as the signer saw it apart from the value. PdfCraft's own
change check counts a /DR change after a form-fill certification as form
fill, but another validator may not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text field PdfCraft added showed its typed text in Acrobat only while
the field had focus: as soon as the user clicked elsewhere the text
vanished (it was still stored, and printed). PDF-XChange Editor and
PDFgear were fine, and the same field made by MuPDF was fine in Acrobat.
Bisected in Acrobat with one-change variants of the same file: only the
variants whose /DR fonts are objects (or that have no /DR) keep the text.
The appearance stream's own fonts, /P, the appearance layout and /DA 0
made no difference. PdfCraft wrote Helv, TiRo and Cour inline in /DR.
ensure_form now adds them as objects, and turns fonts an earlier save
wrote inline into objects too (same dictionaries). An indirect /DR or
/Font stays the object it is. The XFA-to-AcroForm writer does the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor is at least 60 points wide on screen, so a short box can be
clicked into. With the box's alignment applied, right-aligned or centred
text in a box narrower than that was placed against the wider editor's
edge, past the box's right edge. The editor now reaches past a narrow
box away from the edge its text keeps, so the text stays where the page
draws it.
With typing undone in a paragraph or added text box, its text matches
the page again, so Undo and Redo went to the document. Redo then closed
the box and redid a document change (or nothing), instead of putting the
typing back as the box's own Redo did before. And Shift+Cmd+Z is also
Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a
document Undo.
Redo stays with the box while its own history has typing to put back
(the test egui makes when it handles the key), and Shift+Cmd+Z is never
a document Undo.
Opening a centred or right-aligned Add text box drew its text at the left
edge, a layout the box never had, and Ctrl+Z inside the box (which goes to
the text, not the document) left it showing that way. The editor now lays
the text out with the box's alignment. Justified text is still shown
left-aligned while editing (TextEdit can't justify).
Ctrl+Z is left to a focused text field, so in the paragraph editor it
went to the box's own Undo history. That history still held the
paragraph's text from before the last formatting change, so Ctrl+Z put
it back into a box laid out for the page after the change, a layout
the page never had, and the document kept the change. The box shared
its id across paragraphs too, so it could put back another paragraph's
text.
The history of the paragraph editor and the added-text box now starts
at what the page has. Undo and Redo keep the keys only while the box
holds typing the page doesn't have; then they reach the document. An
open box is applied (as clicking away applies it) and closed before an
Undo or Redo step, so it never shows a state the document has left;
text it can't apply keeps it open and stops the step.
fix(xfdf, sign, preflight, engine, forms, measure, annot): bound repeated work in form exchange, preflight and export, and check what imports and time stamps claim
Review on #2957, point 1. Both entry points already trim the whole name
before splitting it, and a space beside a period stays in its part,
which matches black-box observation of Acrobat and ISO 32000-2 12.7.4.2
(only a period is barred from a partial name). The test pins that down;
three mutants (no trim in add_field, no trim in set_props, untrimmed
empty-part check) each fail it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>