* M4: document the directly accessible page overview
Part of the organize-page-overview change.
Co-authored-by: Codex <[email protected]>
* M4: open Organize in the page grid with selection and per-page actions
Show the overview from the tool catalogue, Home, palette and control channel. Add checkbox selection and local rotate/delete actions; preserve thumbnail aspect ratios.
Co-authored-by: Codex <[email protected]>
* M4: translate page selection and local actions
Co-authored-by: Codex <[email protected]>
* M4: cover overview entry, selection and page actions
Co-authored-by: Codex <[email protected]>
* M4: record the organizer workflow improvement
Co-authored-by: Codex <[email protected]>
* M4: link page overview regression evidence
Co-authored-by: Codex <[email protected]>
---------
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Viewer: touchpad scrolling tracks the finger 1:1 (#759)
A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.
The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.
Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Viewer: Continuous scrolling is an independent checkbox over the base layout (#752)
Page display matches Acrobat: the base layout (Single page or Two-page
view) is a radio choice, and Continuous scrolling is a separate checkbox,
so every combination is reachable. This also adds the previously missing
"Two-page view, one spread at a time" mode.
`PageLayout` now has just `Single` and `TwoUp`; a separate `continuous`
flag on the view (and on the default view preferences) decides whether it
scrolls. A single `display_token` ("single" | "continuous" | "two-up" |
"two-page") serialises both for settings and the control channel, keeping
the earlier `continuous`/`single`/`two-up` names working and adding
`two-page` for the new mode. The PDF's own page-layout request now maps
to the right (layout, continuous) pair, including plain TwoUp.
- View ▸ Page display and the rail menu show two radios plus a Continuous
scrolling checkbox; Preferences gains a default Continuous checkbox.
- `view.continuous` toggles the checkbox; `view.layout.single` /
`view.layout.two_up` switch the base layout without touching it. The
former `view.layout.continuous` command is gone (its state is Single +
continuous); the `layout`/`default-layout` options and a new
`continuous` option cover scripting.
- Not scrolling, the wheel and the arrow/Page keys turn whole spreads in
Two-page view, and only the current page or spread is laid out.
Tests: new `notches_turn_two_page_spreads`,
`two_page_view_shows_one_spread_at_a_time_until_continuous`, and updated
layout/command/persistence tests; the `view()` helper takes `continuous`.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Parity: the continuous-scrolling feature points at view.continuous
---------
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Issue #568: honour the document's copy permission
ISO 32000-2 §7.6.4.1 has readers restrict access to an encrypted file
according to its permissions, and Table 22 bit 5 covers "Copy or otherwise
extract text and graphics". PdfCraft reported the bit (Document Properties
shows "Content copying: Not allowed") but ⌘C copied selected text anyway.
- Engine: Document::allows_copying / check_copying, and Needs::Copying for
Take a snapshot, Export to image/text/Word/HTML/RTF and Export all images.
Save Image As refuses too.
- UI: ⌘C moves from canvas::shortcuts to the app, which knows the document.
Refused copies, snapshots and exports say why. The snapshot tool and the
exports check again when they act, since the tool stays selected across tabs.
- Automation: text_extract (and the MCP text resources), text_paragraphs,
doc_export_text/images/all_images, doc_export_office and image_save refuse.
Find, read aloud and the accessibility tools keep working: the spec exempts
assistive technology, and searching isn't copying. Page extraction stays with
the assemble bit, as before. Owners and unencrypted files are unaffected.
* Refresh catalog hashes
---------
Co-authored-by: Brandon Thomas <[email protected]>
* M9: visible digital signatures can show a drawn, typed or image graphic (#193)
A visible certificate 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, typed or image) or an imported
PNG/JPEG. Sign as previews it and remembers the choice; sign_document
takes `graphic` (image, text or strokes).
pdfcraft-sign gains `Graphic` and `SignOptions::graphic`. Graphics are
checked and size-capped before drawing; images keep alpha as a soft
mask and are encrypted with the rest of the update in encrypted files.
Refs #193
* Refresh catalog hashes
---------
Co-authored-by: Brandon Thomas <[email protected]>
* M2: overlapping text runs stay whole, laid out in arrange
Two text runs that overprint each other on one baseline were merged into one segment and sorted by x, so their letters came out interleaved ("ADleplhtaa ebpestialo gna zmemtaa"). Runs are now ranges of the filtered glyph list that arrange() already works on. Glyphs of runs that collide (overlap by more than a quarter of the line height) stay whole and are placed by their left edges; runs that only interleave along the line keep glyph-by-glyph order. Glyph placement happens when a segment is emitted, into scratch storage that every segment reuses.
layout() is unchanged: it still calls arrange() and takes the original glyphs by the returned indices. Turned text needs no separate path, because arrange_flow() already lays it out in its own baseline frame.
Tests: overprinted runs at 0 and 37 degrees, a run drawn over a line, a combining mark drawn last, and runs that only touch stay as they were or come out whole.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: a run that redraws an earlier run is printed once
Doubled, shadowed or fake-bold text draws a whole line again at (almost) the same place. The per-glyph fake-bold filter only looks four glyphs back, so a redraw of a whole run got through and printed twice.
After runs are identified, a run is dropped when an earlier retained run has the same glyph texts over the same places (each glyph within a fifth of its height). Runs are keyed by a fingerprint of their glyph texts, with each text hashed with its length so glyph boundaries count, and by a grid cell. Every earlier candidate with the same key is checked, so a run that does not match cannot hide a later redraw. Dropping a run compacts the segment list only; glyphs are never removed from the input, so the returned indices are still mapped through the fake-bold filter's `kept`.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: merge zero-width marks into a segment in one pass
Each zero-width mark was inserted with a search from the start of the placed glyphs and a Vec::insert, so a segment of m marks and n glyphs cost O(m·n) comparisons and shifts. Marks are visited in x order, and the position each one goes to never moves back, so a single cursor over the placed glyphs puts them all in. The output is unchanged, including when a colliding component leaves the placed order non-monotonic in x, because the cursor stops at the same first glyph whose x is greater than the mark's.
A 16000-mark segment takes 225 ms, the same as main (608 ms with the per-mark insert).
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: a dense overprint joins its runs instead of sweeping every pair
The collision sweep compared each glyph with every later glyph that started before its right edge. When many glyphs start at one x, as in a heavily overprinted line, that is quadratic in the glyphs of the segment.
The sweep now has a budget of COLLIDE_PAIRS_PER_GLYPH (16) pair tests per glyph of the segment. Ordinary text overlaps each glyph with about one neighbour, so it never reaches the budget and its output is unchanged. A segment that needs more is dense: its text runs are joined into one component and placed run by run, which keeps every run whole. The fallback is one pass over the runs, so the work for a page is O(glyphs) pair tests plus the sorts.
On a page of 2000 overprinting runs (12000 glyphs) the sweep took 157 ms, against 53 ms on main; with the budget it takes 65 ms. The output is identical to the sweep's, since every run in these pages overprints every other.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: look up forced word breaks by binary search
Each glyph of a segment was checked against every forced word break with breaks.contains(), so a segment with many overlapping rows cost O(glyphs x breaks). The breaks are sorted once, after collision placement, and looked up by binary search.
The lookup returns the same answers, so the output is unchanged. The new test has 60 overlapping rows, each of which starts a word.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: find each word gap's glyph by a position table, not a scan
For every word gap, the glyph that takes the space was located by scanning the segment's output (idx.iter().position), so a long line with many gaps cost O(glyphs x gaps). This was already on main; the change makes it linear.
Each glyph's place in its segment's output is now written into a page-sized table as the segment is emitted, and the gap reads it. The table is never cleared: a segment writes every entry it reads before reading it, and the glyphs of a segment are a permutation of the same set it wrote. The space goes to the same glyph as before, so the output is unchanged.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: check a redraw against at most 32 earlier runs
A run is checked against every earlier run with the same fingerprint and grid cell. Runs that share a key but do not redraw each other are all kept in that chain, so a page of N such runs costs O(N²) checks. The chain is new in this change, not in main.
Each run now checks at most REDRAW_CANDIDATES (32) candidates, newest first. A run past the limit is kept, as it would be if no earlier run matched. Runs that redraw one of the newest candidates are still dropped, and a test checks both sides of the limit: a redraw of the newest of 40 look-alike runs is found, and a redraw of the oldest is kept.
On 4096 non-redrawing runs (69 632 glyphs, all kept by both versions) the check cost 200 ms, and the bound brings the page back to 286 ms, against 272 ms on main. Output is identical.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* xfa, xfdf: refuse XML nested past 1024 levels (roxmltree recurses with nesting depth)
roxmltree parses by recursive descent, so its stack use grows with the nesting depth of the
document it is handed. Ten thousand nested elements overflow the main thread's stack and abort
the process ("fatal runtime error: stack overflow, aborting", SIGABRT, core dumped); three
thousand still parse on the default stack. A stack overflow is `abort()`, so `catch_unwind`
cannot intercept it and the whole application dies.
Two of our XML entry points hand attacker-supplied text straight to it:
- XFDF/FDF form data import. A 1.4 MB file with 50,000 nested `<field>` elements aborted
`pdfcraft-cli run doc_import_data` in 288 ms (SIGABRT, core dumped). The same file now returns
"the file nests XML elements 50003 deep, past the 1024 the reader accepts" in 54 ms, at 19 MB.
- The XFA template parser (`crates/xfa/src/parse.rs`), reached when a document with an XFA form
is opened or laid out. A 10,000-deep template aborted this crate's own test run before the fix.
`crates/xfa/src/data.rs` parses the datasets packet and the data written back into it, so it is
guarded the same way.
The bound has to be applied before parsing, not while walking the result: the overflow happens
inside the parser, so a depth cap on our own walks (64, unchanged) cannot prevent it. Both
crates measure the nesting with a small scanner that skips comments, CDATA, processing
instructions, the DOCTYPE and quoted attribute values, and counts a tag that closes itself
(`<draw/>`) as one level while it is read rather than as a level left open, so a template full of
styling elements is two levels deep rather than two thousand. It cannot under-count a real
element; a stray `<` in text is invalid XML and only makes it over-count, so it errs towards
refusing a file rather than trusting it.
The limit is 1024 because of measurement, not taste: 3000 levels still parse on the default stack
and 10000 abort, and `hostile_templates_fail_without_panicking` keeps asserting that a 500-deep
template parses, so the bound has to sit well above that.
Tests: `deeply_nested_fields_are_refused_instead_of_overflowing_the_stack` and
`deeply_nested_template_is_refused_instead_of_overflowing_the_stack` both abort with a stack
overflow before this change and pass after it; `nesting_depth_scanner_counts_what_matters` and
`self_closing_elements_do_not_accumulate_in_the_depth` cover the scanner (comments, CDATA, a
quoted attribute holding `<`, and 2,000 sibling `<draw/>` elements, which a scanner that leaves
self-closing tags open counts as 2,000 deep);
`many_self_closing_elements_do_not_accumulate_into_the_bound` parses a template holding two
thousand of them; and `nested_fields_within_the_limit_still_read` proves a 16-level field tree
still reads with the same dotted names. Both suites pass: xfa 36, xfdf 18.
The parse options also carry `nodes_limit` now, which was already the shape the XFA parser used:
a 93 MB XFDF with two million sibling elements went from 1082 MB and 2323 ms to 289 MB and
1299 ms ("nodes limit reached").
The scanner is duplicated between the two crates because nothing between them owns XML. If it
should live in one place, say where and it moves.
* xfa: the depth limit holds on a default-stack worker thread
---------
Co-authored-by: Brandon Thomas <[email protected]>
* render: don't let a hostile page-tree /Count abort inspection
Found by cargo xtask fuzz on Windows (seed 42): a 355-byte PDF whose
/Type /Pages node carries /Count 4294967295 aborted pdfcraft-cli
check-one with a 48 GiB allocation (4294967295 x 12 bytes) - an abort,
which the engine's catch_unwind guard cannot catch. AGENTS.md §4
requires a malformed PDF to produce an actable error, never a crash.
The lopdf Structure impl delegated get_pages to lopdf's own page-tree
walk, which trusts /Count and pre-allocates by it. The trait's default
walk (depth cap, seen-set, checked page numbering) handles the same
input fine, so the override is simply dropped and the bounded walk
counts the real pages.
Comes with a synthetic regression test (/Count 4294967295, one real
page); cargo test -p pdfcraft-render, clippy -D warnings, fmt --check
and an end-to-end check-one run all pass.
* Drop the duplicated use super::* in the structure tests
---------
Co-authored-by: pechnikov2026-star <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056)
* Run cargo fmt
---------
Co-authored-by: Brandon Thomas <[email protected]>
Release builds have no Simplified Chinese face: craft-fonts' only Hans face
is Noto Sans CJK SC, which build.rs skips (AGENTS.md §1.1). The Chinese UI
therefore fell through to BIZ UDPGothic, which lacks 226 of the 771 Han
characters in zh-hans.tsv (欢, 导, 签, 编, 创 ...) and 12 in zh-hant.tsv.
In a Chinese interface built without a Hans face, the desktop app now
loads one installed Chinese face (Microsoft YaHei/JhengHei, PingFang,
Hiragino Sans GB, STHeiti, WenQuanYi, Droid Sans Fallback; never Noto CJK
or Source Han) and places it before the Japanese faces so a line keeps one
face and one baseline. Other languages keep the existing Arabic-capable
fallback, last in every family. Still one installed font, read at runtime,
never embedded; PDFCRAFT_SYSTEM_FONTS=0 still turns it off.
Claude-Session: https://claude.ai/code/session_01Rsrc3tE4vB8UrMazUAkSkU
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Outlook opens an attachment from its own cache (INetCache\Content.Outlook\<random>\ for
classic Outlook, the Microsoft.OutlookForWindows package folder for the new one). Save wrote
back to that copy: it either fails because Outlook still holds the file, or appears to work
while the edits never reach the email and are deleted with the cache.
Save on such a file now opens the Save As dialog, as for a new document. After that the chosen
file is the document's file and Save writes it directly. Save As, autosave and the agent tools
are unchanged.
* test(xtask): add measure geometry fuzz target
* fix(xtask): cap dense measure fuzz seed
* docs: update robustness audit log
* test(measure): check snap geometry bounds in measure integration tests
Adds crates/measure/tests/snap_property.rs with two deterministic #[test] cases: a fixed-seed, 1,000-case mutation check of geometry extraction and snapping, and a high-curvature regression that asserts the segment cap is reported. Panics are not caught, so a panic, abort or stack overflow ends the test process and cargo test reports it.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* chore(xtask): drop fuzz-measure command and its ROADMAP entry
The measure property test replaces the xtask command. Removes xtask/src/fuzz_measure.rs, its registration, the xtask dependencies on pdfcraft-content, pdfcraft-cos and pdfcraft-measure (and their Cargo.lock edges), and the robustness-audit ROADMAP paragraph, which describes other PRs.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
* docs: register the MCP server from an installed release
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Fix#624: preserve hard edges for non-interpolated images
Select nearest-neighbour for Hayro's image pre-minification when /Interpolate is absent or false, retaining Catmull-Rom for true. Add a synthetic regression covering default, false, and true behaviour and record the vendored patch.
Co-authored-by: Codex <[email protected]>
* M2: test non-interpolated minification with exact colours
The #624 regression used alternating black and white columns. Nearest 8→4 sampling reads only white columns from that pattern, so an image skipped entirely still passed, and the check only asked for near-black or near-white pixels.
Replace it with 2×2 colour blocks whose exact device output is asserted, with /Interpolate omitted equal to false, and /Interpolate true still blending. Add exact cases for gray, a non-integer 10→6 minification, and a matched soft mask. All pass on this head. On origin/main the same block image renders with blended mid-tones (for example 12eb12ff where pure green is expected), so these tests fail without the #624 change.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: plan nearest-neighbour pre-resize without filter limits
Non-interpolated images are pre-resized with nearest-neighbour, which copies source pixels and needs no filter tables, crossed intermediate or scratch. The preflight still applied the Catmull-Rom checks to it: a source wider than 2^20 pixels, or a filter plan over 128 MiB, was refused, and once such a source was wider than the backend's u16 limit the image was skipped.
resampling_byte_len and image_resampling_size take the image's interpolation flag. Nearest keeps the source and target buffer bounds and refuses any scratch it reports. Filtered images keep their limits unchanged, and the existing image_resampling_size tests pass true.
Measured: a 1.5M-pixel-wide RGB row drawn at 750×4 device pixels renders 0 non-white pixels on origin/main (the image is skipped) and all 3000 pixels with this commit. The 1.1M-pixel gray case is covered by wide_non_interpolated_sources_draw_after_shrinking.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: keep non-interpolated Type 3 glyph images nearest
Minified images inside Type 3 glyphs were drawn with ImageQuality::High, a bicubic sampler, whatever their /Interpolate. A non-interpolated image had already been pre-resized with nearest-neighbour, and bicubic sampling of that result blended it again. High is now used only for interpolated images, as elsewhere.
Before this commit the new test saw an interior glyph pixel of [28, 28, 27] (not a palette colour) for a 4.6 × 2.3 px glyph of a palette image; after it, every interior pixel is a palette colour. /Interpolate true still blends.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: nearest-sample masked minified images straight into premultiplied RGBA
A non-interpolated image with a matching soft mask used to build a full-resolution RGBA copy, nearest-resize it, then premultiply the target. For a minified image most of that copy was discarded.
nearest_premultiplied_rgba reads colour and mask from the same source pixel, selected with PicScale's nearest index (same fixed-point centre sampling as ResampleNearestPlan) and premultiplies only the target pixels. It falls back to the previous path if its target buffer cannot be allocated. Interpolated images, unmasked images and magnification are unchanged.
New test: a ten-column gray ramp with a soft mask drawn six pixels wide must pick the same columns for colour and mask.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: record the #624 image patch in vendor/README.md
The hayro row listed the #624 regression test but not the patch itself. Add patch item (10) covering the nearest pre-resize and its planning, the Type 3 bicubic restriction and the premultiplied soft-mask path, and list every regression test that covers them.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
"100%" in Preferences ▸ Interface size multiplied the scale the display
reports, so on a 1080p screen whose system reports 200 % it still drew at
200 % (#456). A chosen size is now absolute, as PhotoCraft's UI Scale is: the
display's scale is divided back out, so "100%" is one screen pixel per point.
Auto is unchanged (the display's scale times GNOME's text scaling), and its
menu entry now shows the size it draws at. The menu offers PhotoCraft's sizes,
75–300 %.
Co-authored-by: Brandon Thomas <[email protected]>
text_find extracted (and cached) the text of every page before looking
for the first match, so `limit: 1` on a 9,000-page manual meant a whole
document extraction. It now reads pages in batches (8, doubling to 128)
and stops as soon as it has one match past the limit. The result says
`complete`, and a capped result carries `next` ({start_page, skip}),
which the new `start_page` and `skip` arguments accept to continue.
Fixes items 1 and 3 of #971 (and item 4 for pages after the limit).
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* cos: decode only the first filter of a stream, strictly
Stream::decoded_first_filter_strict decodes the outer filter of a chain and leaves the rest of the chain encoded, within a caller bound. The optimizer uses it to remove an ASCII85 or ASCIIHex wrapper without inflating the inner data.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* optimize: strip redundant ASCII wrappers from filtered streams
A stream whose filter array starts with ASCII85Decode or ASCIIHexDecode loses that outer layer when the serialized replacement is smaller. The inner encoded bytes are kept exactly, and the decoded stream is unchanged. Streams with DecodeParms, image, object stream, or Crypt filters are left alone, as are streams whose wrapper fails strict decoding.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* optimize: document redundant ASCII-wrapper cleanup
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Create Multiple (UI and automation, combine mode) and the page-grid drop refused or
truncated selections above MAX_CREATE_FILES (1,000). The cap was the only bound on how
many sources were held at once, and the combine still held every source together: all
picked files were read, converted and opened before anything was combined.
organize::Combiner adds one source at a time and keeps only what the rest of the combine
needs: the source's bookmarks, its attachments, and its document only when it declares
output intents. Session::combine_each drives it from a lazy iterator, so a source is read,
copied and released before the next one is read. The desktop picker and the grid drop read
files as their turn comes. The cap is removed from the engine, the automation schema and the
UI. Output is byte-identical for ordinary inputs.
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Token identities that macOS exposes through CryptoTokenKit (a CAC or PIV
card in a reader) were not listed: the Keychain search only covered the
file-based keychains. keychain::identities now also searches the token
access group, de-duplicates by certificate, and skips card certificates
whose key usage allows neither digitalSignature nor nonRepudiation (the
CAC encryption certificate, which would produce a signature that fails
validation). Card identities are labelled "(smart card)" in the sign
dialog. The key never leaves the card; macOS asks for the PIN.
Claude-Session: https://claude.ai/code/session_0158vdscYZsFFCPLjokXoyzM
Co-authored-by: sfv9 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
An added Arabic item was rejected once its distinct glyphs exceeded
GLYPHS_PER_FONT * MAX_ARABIC_FONTS (3,840). The glyphs already split
into 240-glyph Type 3 fonts, so that item-wide count was an allocation
policy, not a limit of the format. Remove it: an item now gets one font
per 240 distinct glyphs it needs.
Per-font limits, the outline reads that happen before any object is
added, and the output below the old limit are unchanged. The drawing
core takes its outline source as a parameter, so the tests can run
without the craft-fonts Arabic face.
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Dancing Script, the typed-signature face, is Latin and Vietnamese only, so
script_outline skipped every Cyrillic or Greek character and a Serbian
Cyrillic name came out empty. Characters the script lacks are now drawn from
the bundled Inter Regular, slanted about 12 degrees and scaled to the
script's capital height. Characters neither face has are still skipped.
Inter's bytes are now exported as pdfcraft_fonts::INTER_REGULAR and the UI
uses that static, so the font is still embedded once.
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
A Japanese replacement with more than 240 unique characters was refused. One
generated Type 3 font holds at most 240 glyphs, so the text is now split into
several fonts, each within that bound. The font changes only where the text
moves to another font, and a single font gets the same operators as before.
The reader joins runs across the subset fonts of one face, only for fonts
written by this code (Type 3, /Name PCJapanese). Other documents are unchanged.
A paragraph whose move or width is refused no longer writes its fallback fonts
before the refusal, so the document is left as it was.
Co-authored-by: Claude Haiku 5.5 <[email protected]>
* demo-pdf: build a working file:// URL on Windows
Windows Path::canonicalize returns a verbatim \?\C:\... path. After the
backslash swap that is //?/C:/..., which the Unix/Windows branch then
misreads as an absolute Unix path, producing file:////?/C:/... Chrome
fails to load that URL and prints one blank page, so on Windows
cargo xtask demo-pdf and cargo xtask screenshots always fail with
"Chrome produced 1 pages but the showcase layout expects 12".
Strip the //?/ prefix before classifying, and add a regression test.
* demo-pdf: don't accept Skia's incremental %%EOF writes as a finished PDF
Chrome writes the print-to-pdf output incrementally and every increment
ends with its own %%EOF, so the completion poll accepted the first
~29 KB increment (one page of twelve) as final and killed Chrome
mid-print. Require the file size to stay quiet for 10 consecutive
500 ms polls before treating the file as finished; waiting for process
exit alone is not enough because some Chrome builds linger after
writing.
---------
Co-authored-by: pechnikov2026-star <[email protected]>
* fix(measure): include closing edges of every filled subpath
* fix(measure): include closing edges of every filled subpath
* Run cargo fmt
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Read mode: the rail's Bookmarks and other pane buttons open their panels (#932)
Read mode kept the right rail but never drew the right panel, so its
buttons did nothing. Read mode now hides the tools, not the navigation
panes: the panels open from the rail as in the other workspaces. Entering
Read mode closes the open pane and leaving it reopens it, and a document
opened into Read mode starts with the panes closed unless its Initial View
asks for one (/PageMode /UseOutlines).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M7: a pane closed in Read mode stays closed when leaving it (#932)
Review on #947: opening or closing a pane in Read mode now discards the
pane saved on the way in, so leaving Read mode no longer reopens a pane
the user closed there (and Comments keeps its close preference).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The parity and roadmap docs were last refreshed before a batch of fixes
merged, so they still described closed issues as open problems.
- #774 (encrypted output rejected by Acrobat, fixed by #836): gap 1
removed from gaps.md; its remaining interop-suite work moved to gap 12.
Gap numbers are kept so existing "gap N" references stay valid.
- #756 (Windows printing, #847): Printers row, Print area and roadmap
rank 1 now name web printing as the remaining gap.
- #740 column select (#765), #759 1:1 touchpad scrolling (#868),
#810 Shift-Cmd-G (#835), #825 (#822).
- #844 multi-object move (#907): marked partial, same page only.
- #799, #800, #807, #809, #811, #815-#819, #821 (incl. the #816 panic):
gap 6 and the area tables list them as fixed.
Scores, readiness percentages and the stage are not re-judged; only the
rank-0 estimate drops by gap 1's 8-15 h (40-70 -> 32-55).
Co-authored-by: Brandon Thomas <[email protected]>
* UI: theme picker with light and dark theme cards
* UI: update the icon count and the theme test for the cycling theme button
---------
Co-authored-by: Brandon Thomas <[email protected]>
InDesign writes fades as luminosity soft masks whose group is one
extended axial `sh` over the whole page. hayro 0.7 sampled every such
shading pixel by pixel through the shading function, so a report page
with 40 of them took 10-15 s at 144 dpi.
Backport hayro 0.8.0's `gradient` module: an axial or radial shading
that a native gradient draws exactly becomes sRGB stops (within 0.01
per channel, a stop at every stitching bound), painted as a vello_cpu
gradient. Upstream's half-pixel shift is left out: it suits vello_cpu
0.3 but moves 0.0.8's gradients off pixel centres.
Page 4 of the reported file: 10.8 s -> 1.0 s.
Co-authored-by: Claude Opus 5.5 <[email protected]>
System Settings > Desktop & Dock > "Close windows when quitting an application"
is the system's own version of Preferences > "Reopen the files that were open
when PdfCraft last closed". Read it once at launch (defaults read -g, 500 ms
guard, as text_scale.rs does for gsettings) and use it as the preference's
starting value, before the saved preferences are restored. A choice made in
Preferences is persisted and still wins; other systems keep starting off.
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
The bookmark filter, the Comments panel filter and the command palette
lowercased with str::to_lowercase, which follows no locale: "İSTANBUL"
became "i̇stanbul" (a combining dot added) and "ı" has no case pair, so
"istanbul" never found the bookmark "İSTANBUL ŞUBESİ" and "imzalayın" never
found a comment "Lütfen İMZALAYIN".
They now fold with pdfcraft_render::text::fold_case, the folding Find and
Search & Redact use (made public here): I, i, İ and ı count as one letter
and every other character folds to its own lowercase.
egui scrolls 40 points per wheel line on the desktop. winit reports one line
per notch on Windows and Linux, where browsers scroll about 100 pixels, so
the wheel felt slow there. Preferences ▸ Documents and view gets "Mouse wheel
scroll speed": Slow / Normal / Fast (0.5×, 1×, 2× of the platform's normal:
100 pt per line on Windows and Linux, 40 on macOS and 8 on the web, both
egui's defaults). It is saved, and `set_option("scroll_speed", …)` sets it
over the control channel.
- Applied in `raw_input_hook`, before egui reads the frame's input (and in
`logic` for hosts without the hook).
- ⌘/Ctrl-wheel zoom keeps egui's rate: those line deltas are scaled back,
since egui scales lines before turning them into zoom.
- Point deltas (most trackpads) keep their speed; Single page wheel paging
still turns one page per notch (its notch size is fixed).
- Labels translated (fr, ru, zh-hans, zh-hant, es, ja, bg, de), the speed names in
a "scroll speed" context; catalog hashes updated.
Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
AppKit centres the window buttons in its standard 28 pt title-bar band and
nothing in winit/eframe moves them (AppKit calls would need unsafe, which the
workspace forbids). The integrated title bar was 38 pt with 30 pt tabs, so the
lights sat 5 pt above its centre. On macOS the strip is now 28 pt, tabs 26 pt,
icon buttons 24 pt, and content starts after the lights plus the gap they keep
from the top (MAC_CONTENT_LEFT), as storytold/wordcraft#255 did for WordCraft.
Other platforms are unchanged. Unit test for the constants, macOS kittest for
the strip's contents staying inside the band.
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
* M9: let the structure reader take an element limit for tests
Tree::read keeps MAX_ELEMENTS; Tree::read_with_limit takes the limit, so a unit test can reach the cut-off with a few elements. The characterisation test records today's behaviour: a figure past the limit is not checked.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M9: stream structure-tree checks without a total-element cutoff
The structure checks built the whole tree as a Vec of elements, and stopped adding elements at 2,000,000, so later elements were never checked. One walk now reads the tree with an explicit stack and reports each element as it opens and closes. Each rule keeps only what it needs about the open elements and, for tables, the row spans that reach into later rows. Findings stay in document order, and the report still shows the first 200 per rule.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
* M10: enhance camera images: straighten a page from four corners (ocr.camera-perspective)
Four corners on a page (any order, a convex quadrilateral) are warped to an
upright rectangle that replaces what the page shows, read at the page
picture's own resolution (at most 600 dpi) and kept at that physical size.
One undoable step through Replace Pages, so comments, links and bookmarks
stay.
- geom: a pure 3x3 homography solve and corner ordering (no dependencies);
collinear, concave and non-finite input gives None.
- engine: camera::warp (bilinear, over white, capped at 40 MP) and
Session::enhance_camera_page.
- automation: page_enhance_camera (corners in top-left-origin points).
- parity: ocr.camera-perspective is partial; the UI handles, automatic edge
detection and contrast cleanup are still missing.
Refs #1008.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M10: Enhance camera image: drag four corners on the page, then Enhance page (ocr.camera-perspective)
Scan & OCR ▸ Enhance camera image (ocr.enhance_camera) shows four corner
handles on the current page, inset 5% from its edges, with what will be cut
away dimmed. Enhance page straightens what they enclose through
Session::enhance_camera_page (one undoable step); Cancel and Esc leave the
page alone. The buttons float at the top right, clear of a centred page's
corners, and the instructions are a notice, as for Crop.
A command of its own, beside Enhance scanned file (ocr.enhance), which
stays for scan correction (#1012).
- ui-egui: camera_ui (handles, dimming, bar), QuickTool::EnhanceCamera,
quick_tool "enhance-camera" for ui.state and the shot example.
- Catalogs: the three new labels in every language with UI coverage
(plus es, pl, zh-hant); ATTRIBUTION.toml hashes updated.
- automation: page_enhance_camera is linked to the command.
- parity: ocr.camera-perspective lists the command and the UI test.
Refs #1008.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M10: Enhance camera image finds the document's edges (ocr.camera-perspective)
The corners now start on the photographed document's edges when they can
be found, else inset 5% as before.
camera_edges::detect_document, pure Rust and deterministic: shrink to 400
px, blur, Sobel edges thinned and thresholded against the 98th percentile;
a Hough transform where each edge votes within 6 degrees of its own
normal; pairs of near-horizontal and near-vertical lines make convex
quadrilaterals (at least 12% of the picture), kept when every side lies on
a matching edge along at least 55% of its length and scored by area times
the square of the mean support; each side is then refitted through its
edge points (total least squares). None when nothing convinces.
- Session::detect_camera_corners (user space); page_enhance_camera finds
the corners itself when `corners` is omitted and reports the corners it
used and whether they were detected; the UI command starts from them.
- Tests on synthetic photos: plain table, text on the sheet, wood grain
with a shadow, strong perspective, a 30 degree turn, a sheet running off
the picture, no document, white on white, bad input; end to end through
the engine and the tool.
- Parity notes: what is still missing (turning the result upright, rounded
corners, clean-up).
Refs #1008.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* M10: after Enhance page, rotate, crop or organize the page (ocr.camera-perspective)
Enhance camera image no longer closes on Enhance page: the bar then offers
rotate counterclockwise and clockwise for the enhanced page (one undo step
each), Crop pages and Organize pages (which leave the tool for those
tools), and Done; Esc leaves too. A photo of a card often comes out a
quarter turn off until the page is turned upright automatically.
The two icon buttons are named for screen readers. Every label already
exists in the catalogs.
Refs #1008.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: magdale76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The Save button's tooltip and accessibility name read "Save (M4)", a
leftover milestone tag, while Print and Document properties beside it
name their shortcuts. It now uses command_tip with file.save, so it reads
"Save (Ctrl+S)" on Windows/Linux and "Save (⌘S)" on macOS. The catalog
key becomes "Save ({key})" in the 15 catalogs that had it (ATTRIBUTION.toml
hashes updated). New test commands::the_top_bar_save_button_names_its_shortcut_not_a_milestone.
Claude-Session: https://claude.ai/code/session_01UngnNXzwMCF7ksQ8tNucDe
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Clicking a tool in the tool panel or palette that needs a document, with
none open, used to show only "Open a document first". It now shows the
open picker (titled with the tool's name), opens the chosen file and runs
the tool on it. Encrypted files run the tool once the password is in;
cancelling runs nothing. Menus and shortcuts keep the old message. The web
build opens the file only.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* XFA: forms in one wrapped block lay out across pages, with their rows and growing fields
A dynamic form whose body is a single block that can't be flowed (left-to-right
wrapped, positioned or fixed-height) laid out to one page, the rest drawn past
the bottom edge with no warning.
- A block taller than a content area is spread over the following ones. Breaks
fall between fields, draws and lines of text; fills and lines are cut at the
break; a field or draw moves whole, border and all.
- Repeating subforms in a left-to-right wrapped container get the instances the
data holds, as in the other layouts (only the first was drawn).
- A multi-line text field without a fixed height grows to hold its text. Past
the height of a content area it is drawn as text that continues across pages
(not editable; reported as a warning) instead of one widget cut off.
- Text too long for the fixed height it was given is drawn smaller, down to
70%, rather than over what lies below (the standard faces are wider than the
template's).
- Nested measurements carry their SOM path, so instance counts and field values
are found below the first level.
Synthetic tests cover each. Run: fmt, clippy and tests for pdfcraft-xfa, and
the pdfcraft-engine tests; the workspace-wide gates were not run.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* XFA: a form opens in the state it was saved in, and wrapped sections are as tall as their content
The form packet's saved presence, access and captions are applied to the
template, so rows a script showed before saving appear, locked fields stay
locked and a button keeps the label it was saved with.
A flowed subform no longer stretches to a leftover minH: a public form whose
own form packet records 3 pages laid out as 4 and now lays out as 3.
Also: a text field without a border of its own has no box, and a CR LF pair
is one line break.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* XFA: a signed signature box shows the signature the file holds
A laid-out signature box takes the value and the appearance of the signed
field of the same SOM name that the signing application left in the
AcroForm, and is read-only. The engine no longer redraws a signed box as an
empty one, on open or on a script's re-layout.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623)
Page content was decoded whole, and the decode stopped at
MAX_DECODED_STREAM (256 MiB), so the last ~10 MB of the #623 sample
was dropped without a warning. Top-level page content is now read a
window at a time, so the decoded content is never all in memory:
- the content tokenizer reads windows and holds back an instruction
that runs past one; Reader records reads that touch the end of the
data, including reads made through clones, so an instruction is
only decided once it has been seen whole;
- the Flate decoder is resumable and keeps its own 32 KiB history, so
it pauses at any output size and back-references do not depend on
what the caller has kept;
- page content may decode to max(256 MiB, 64 x its compressed size),
counting each distinct stream once; content that reaches that
budget is cut and the page reports ContentTruncated, as it did not
before;
- forms, tiling patterns, Type 3 glyph procedures and generic streams
keep their existing budgets and caps.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: Avoid duplicate page content raw reads
* M2: Document page content byte reuse
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>