Find and the renderer place glyphs of a simple font that has no /Widths
with the embedded font program's own advances, but redaction guessed the
widths from the font's name (a standard-14 face, Helvetica by default).
When the guess was wider than the real font, the end of a line under a
mark seemed to lie past it and was kept in the content stream, and the
verify pass, using the same guess, agreed.
Metrics now fills the width table from an embedded sfnt program
(FontFile2, or OpenType FontFile3) when /Widths is missing, looking the
glyph up as the renderer does. A subset's unmapped codes get width 0 so
has_glyph stays false for them. Fonts with /Widths, and bare CFF or
Type 1 programs, are unchanged.
Fixes#2130
* fix(engine): a new or recovered document stays unsaved through undo
#1435 made undo back to the last saved bytes clean. For a document
created by Combine, Extract or Create, or restored from recovery, those
bytes were never written where the document belongs, so edit then Undo
made it clean: Close no longer asked to save and autosave stopped keeping
it. Editor::unsaved_file, set by open_new and mark_recovered and cleared
by mark_saved, keeps such a document unsaved until it is saved; Revert
follows the same rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* fix(engine): test that doc_close refuses a recovered or new document after undo
Review follow-up on #2346: a recovered document and a new one, rotated
and undone, are refused by doc_close without discard_changes, and close
with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Moving an image whose crop is its own (`q re W n cm Do Q`) as part of a
group put a fresh `cm` before the clip path on every move, so twenty
moves left twenty `cm` operators in front of one `re`. It never nested
deeper, but the content stream grew with every drag.
When a `cm` already sits right before that clip (one an earlier move
added, or the file's own), `translate_images` now replaces it with the
product of the new and old matrices. The CTM at the clip is
`c · prev · outer` either way (ISO 32000-1 8.3.4, 8.4.4). A previous
`cm` that is not finite, or a product larger than 1e12, keeps the old
behaviour and inserts a separate `cm`.
The new test moves an image cropped smaller than itself twenty times and
checks that the image and its visible crop both moved by the full offset,
that the operator count stays at its value after the first move, and that
the caption after it is untouched. It fails without this change
(32 operators against 13).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The engine caches a page's text blocks for opening a paragraph and
applying its edit, but only while the document is unmodified: it was
keyed by the source identity, which edits keep. After the first edit the
cache never hit again, and the page was grouped again on every call (the
inline editor and the panel each call it after an apply).
pdfcraft_cos::Document::read_state() returns what the document reads as:
its source identity, its trailer and every changed object, compared as
allocations that the state keeps alive. A clone or a snapshot restored by
undo has the same state; any set, add, free or trailer change makes a
different one. The cache is keyed by it, so it hits after edits and never
returns another state's blocks.
On a page of 120 rows, text_blocks after an edit took 0.215 ms per call
and now 0.006 ms (release build, best of three; a throwaway timing test).
Tests: a read state matches a clone and not a document changed by set,
free or a trailer change, nor another document; after an edit the engine
groups the page once and undo and redo get their own blocks. The engine
test fails without this change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M8: redaction removes the replies of the annotations it removes (#2659)
A reply (/IRT) placed away from the mark survived, and its /IRT kept the
removed annotation - its comment text and the QuadPoints of the redacted
words - in the saved file. Replies to a removed annotation, and replies to
those, now go with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M8: test replies listed before their parent, with a pop-up on a reply (#2659)
Review of #2697: the replies precede the annotation they answer in
/Annots, deepest first, and a reply has a pop-up. A single pass in
/Annots order would keep them; the test fails with the loop cut to one
pass, which the first test does not notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A /ToUnicode CMap with any unparsable part made the reading path
discard the whole map and fall back to the font encoding, so search
and redaction missed text the edit layer could read. Vendor
hayro-cmap 0.1.0 and patch it to skip malformed bfchar/bfrange
entries, unresolvable usecmap references and unparseable tails,
keeping every readable entry, matching MuPDF/pdftotext and
PdfCraft's own edit-layer parser.
Fixes#3177.
Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
Review follow-up. The config packet is decoded as UTF-16 (byte order mark,
or `<` written as two bytes) or UTF-8 before the scan, as crates/xfa reads
packets. The scan walks tags, skipping comments, CDATA sections, processing
instructions and declarations, and takes the value only at
config/acrobat/acrobat7/dynamicRender, so the same name in form data, in a
comment or elsewhere in the config no longer counts.
The engine test now adds the element to the fixture's own config packet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Field detection read a path as a rectangle only when it had exactly four
corners, and gave up on a subpath once it reached five points. The French
income tax form 2042 draws its letter cells with a midpoint on each
vertical side (six points), so most of its cells were not found.
A subpath now keeps up to 16 points, and a point lying on a straight side
between its neighbours is dropped before the four-corner test. A point
that turns back along a side is not between its neighbours and stays, so
a path that doubles back is still not a box.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On wasm, detect_system_lang() always returned English, so Auto showed
English whatever the browser's language. It now reads
navigator.languages in order, then navigator.language, and takes the
first tag with a catalog, as the desktop builds do with the system
languages and as VectorCraft and FilmCraft do in their browser builds.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The Hand tool scrolled by the page widget's drag delta from inside the
page view's ScrollArea. egui applies that delta when the area ends, after
the frame's pages are laid out and drawn, so the page showed each move a
frame late and trailed behind the pointer. (Until the page view stopped
animating its scrolls, the delta was also eased in over several frames,
which is the stutter #924 and #1992 describe.) On top of that, the points
the pointer moved before egui tells a drag from a click were dropped, so
the page never caught up with the grab point.
Pan before the area lays the pages out instead: when the page widget is
being dragged with the Hand tool, set the scroll offset from the last
offset minus this frame's pointer delta, or minus everything moved since
the press on the frame the drag is recognised. The page now moves point
for point with the pointer in the frame it moves, and still stops where
it is let go (no fling). Other scrolls (go to page, links, zoom anchors,
compare sync) are untouched and still win when asked for in the same
frame.
* Control channel: don't lose the refusal reply to a TCP reset
When the control channel refused an unauthenticated connection (deadline
passed, overlong line, bad first request) it wrote the error line and
closed the socket at once. If the client had sent input the server had
not read, Linux answers that close with a reset instead of a FIN, and the
reset can discard the reply before the client reads it. That is the
flake in an_unauthenticated_connection_is_closed_at_the_deadline: the
dripping client's bytes were still unread when the server closed.
Now the server shuts down its write side after the reply and reads and
discards input until the client closes or a 1 s linger passes, and only
then closes. The new test a_client_refused_with_input_unread_gets_the_reason
pins it deterministically.
* Control channel: refused connections linger outside the auth places
A connection refused before auth used to keep its place among
max_unauthenticated while it lingered (up to 1 s), so refusals could
keep real clients out. Now `refuse` gives that place back and takes one
of a separate, smaller set of lingering places (Limits::max_lingering,
8) before the reply is written. When none is free the connection is
closed right after the FIN instead of lingering, so threads stay
bounded as #1806 intended.
Tests: a_lingering_connection_holds_no_place_to_authenticate and
only_so_many_refused_connections_linger.
* Control channel tests: a 5 s linger for the two linger tests
Their timing checks (the auth round trips finishing within the linger,
and a non-lingering connection failing a write within half of it) had
only 1 s and 500 ms of margin with TEST_LIMITS. Each now serves with its
own Limits and a 5 s linger; the assertions are unchanged and neither
test waits for the linger on its passing path.
text_layer wrote '/OCR BMC BT 3 Tr /PCHelv 1 Tf ... ET EMC'. Tr and the
font are graphics state, and ET doesn't reset them, so every content
stream after the layer on the page inherited 3 Tr: a header or footer
added after OCR was in the file but drew invisibly, in PdfCraft and in
MuPDF. The layer is now wrapped in q ... Q inside its marked content.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The print-ready PDF copied the document's /OCProperties as they are, so it
printed the layers in their default state: a layer hidden in the Layers
panel disappeared on screen and came back on paper. Print as image already
used the view's layer choices (it renders with the document's config).
Once the layers have been changed, the copy imposed for printing now has
its default configuration (/D) set to them: /BaseState /ON, /ON the
visible layers, /OFF the hidden ones, its other entries kept. With the
layers untouched, the document's own configuration prints as before.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Section titles are drawn upper case through to_uppercase(), and tool names
inside sentences lowercased through to_lowercase(). Rust's case mapping knows
no language, so in Turkish "Gelişmiş" became "GELIŞMIŞ" and "İmza" became
"i̇mza" (i plus a combining dot). Turkish maps i ↔ İ and ı ↔ I.
upper_label() translates and upper-cases a title with the Turkish rule when
the label has a Turkish row (an untranslated label is still English and keeps
the English rule); in_sentence() lowercases with it. Other languages are
unchanged. The test failed before the fix ("GELIŞMIŞ").
An XFA form with no AcroForm fields was always classified as dynamic, so
opening it laid the template out into new pages and appended them as a
revision: the pages the user saw, and any later save, were PdfCraft's
layout rather than the file's own pages, with nothing saying so. Adobe
Designer writes <dynamicRender>forbidden</dynamicRender> in the config
packet of a static PDF form; such a form is now static and its pages are
kept. /NeedsRendering still makes a form dynamic, and with no word from
the config a form without fields stays dynamic as before.
The config packet is found in an /XFA array or a whole-XDP stream, read
with a 16 MiB decode cap, and scanned for the one element.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
- Remove Crop rebuilds the image's placement from its drawn CTM instead of
pattern-matching cm ops. After an Arrange under a translated CTM it used to
keep the absolute placement and draw the image displaced.
- Arrange refuses a move that would change which clips apply to the image
(moving it past a top-level clip it isn't under, or out from under one
without redrawing it), instead of silently cropping it.
- The exact-clip test now checks the moved copy carries its own clip and
hit-tests both images.
- Cargo.lock: add pdfcraft-edit's pdfcraft-render dev-dependency, which the
previous lockfile commit left out (CI failed --locked).
The test dragged a selection after a fixed four frames, but the page's text
layer arrives from the render pool in the background; under load it was
sometimes not in yet, the drag selected nothing and the assertion failed
(3 of 20 runs of the editing binary locally). Wait until DocView reports
the page's text instead, via a new has_page_text accessor.
Seven tests failed at random on slow or busy machines, main included
(FreeBSD run 38834256439 at 5fdd820b). They now count the work or wait
for the state they check; no assertion is loosened and nothing retries.
- spot_colour_images_run_their_tint_transform_once_per_sample_value
counts tint-transform runs (exactly 256, not one per pixel) through
vendored hayro-interpret patch (34), color::tint_transform_runs, a
thread-local counter, instead of a 10 s deadline.
- shadings_with_large_sampled_functions_paint_quickly: no counter tells
the flat table from the hash map (both read every sample per build),
so the build each paint does is timed against reading its samples on
the same thread, fastest of 8 rounds each, and must stay under 10x
(flat table 1.1x, hash map 109x), instead of a 10 s deadline.
- page_contents_and_nested_forms_share_one_budget: the budget is
checked by its warnings; the deadline is only a hang guard, now 600 s.
- The pen-stroke tests hold finished renders back from the view
(DocView::hold_rasters, for tests) while they look at the settling
stroke, then wait by state for its raster.
- files_and_quit_from_the_operating_system waits for the tab before
deleting the file the open worker reads.
- image_pixels_follow_the_frame_while_a_corner_is_dragged waits for the
page raster on screen before each drag.
Product fix (#1858): a released drawing stayed painted only until any
raster of its page arrived. In full screen its edit is applied a frame
later, so a raster already under way could end the preview first. A
settling drawing is now marked committed when its AddAnnotation lands,
only rasters after that end its preview, and a failed edit drops it.
image_pixels_follow_the_frame_while_a_corner_is_dragged drags before the
asynchronous page raster has arrived on a slow runner, and the preview
(drawn from that raster) then draws no picture: 'not only at []' on
windows-latest for #3070 and #3071 (a deny.toml-only change). With a
300 ms render delay added locally, main's test fails the same way and
this one passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz