A selected image gets a rotate knob (Shift: 15 degree steps); image_edit
gains rotate_by (degrees). The turn is one extra cm about the visible centre.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`CLAUDE.md` asks for a progress-log line and a status line at the end of a session.
- The Annex D.2 alias codes, in both tables (`13e3154a`).
- Reading order in the text oracle (`50cbf5fa`), with gap 12's revision history.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
add_text_replacement built the StrikeOut and the Caret from one Meta, and
add_annotation stamps /NM from meta.id, so the pair shared a single name. XFDF
names a reply by the /NM of what it replies to, so the exporter wrote the
caret's inreplyto as the caret's own name. Importing that file found a comment
replying to itself, and the Replace Text comment did not arrive: the reporter's
second document listed none. FDF was unaffected because it keeps the pair by
object structure rather than by name.
Take a second Meta for the caret. The ids stay supplied by the caller, as the
rest of the crate does so edits remain deterministic, and the one production
caller already has a generator and calls it twice. /IRT still points at the
strikeout, so the group still moves, lists and deletes as one.
Tests: the annotation test now asserts the two carry different names and that
the caret's /IRT resolves to the strikeout's. A new XFDF test exports a Replace
Text pair and imports it into another document, asserting both comments arrive
with their thread intact; it fails if the two are given one id again.
`cargo xtask text-oracle` reported one number: word F1 against `pdftotext`. F1 is a bag of
words. It catches a character read wrong, a glyph not read at all, a font we cannot map -- and
it is blind to order, so a page emitted back to front scores a perfect 1.000. Reading order is
most of what text extraction has left to get wrong: a column read into the middle of its
neighbour (#2387), a figure's labels threaded through a paragraph (#2586), a form's value parted
from its label.
The hole is not hypothetical. Reviewing #2431, a change that moves text and alters nothing else,
**word F1 called 26 of the 29 files whose output changed identical**, while scoring the word
sequence separated 18 improvements from 6 regressions and named the shape of the regressions
(forms: `prefilled_f1040.pdf` 0.672 -> 0.481, a checkbox's number parted from its label). The
harness could not see the only thing that change does.
So a second score per file: the longest common subsequence of the two word sequences, as a share
of the longer one. It shows up in the first three corpus files -- `Test-plusminus.pdf` scores F1
1.000 and reading order 0.875, the same forty words in a title block that we read down the
column and `pdftotext` reads across.
Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages
is plenty of signal; the run says how many files that bit, so the cap cannot quietly become the
measurement. It reports; it does not gate, and like the pixel score it is a *disagreement* with
Poppler rather than a verdict -- in the #2431 measurement the same split scored as a win on one
file and a loss on another purely because `pdftotext` makes it in one place and not the other.
The function's doc comment, `crates/oracle/README.md` and `docs/gaps.md` gap 12 all say so.
`words` now takes the sequence rather than re-splitting the text, so both scores read the same
tokens. `docs/file-format-parity.md` claimed plain-text export was good "(word-F1 0.98)" in a row
whose own column is reading order; it now says the two are scored separately.
Verified: `cargo xtask text-oracle --limit 60` end to end; `cargo test -p xtask` 49 passed; fmt
and clippy clean. The unit test was watched failing with the score blinded to order (sorting both
sides made it 1.000 where it must be below 0.7). The corpus-wide first reading is not in this
commit: the machine is shared with a parallel build and the full sweep was stopped twice for
memory.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Regression test for the /URI percent-encoding fix: round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII with existing escapes preserved.
Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
A link annotation whose target contains non-ASCII characters (for example https://example.com/ação) was written into the PDF /URI string as raw UTF-8 bytes, but URIs in PDF are 7-bit ASCII strings (ISO 32000 §12.6.4.8). The saved file then carried an out-of-spec URI that strict readers can reject or misparse.
Percent-encode every byte outside the printable ASCII range when writing the /URI entry. Bytes already inside 0x21..=0x7E pass through unchanged, so existing percent-escapes like %20 are preserved.
Adds a regression test that round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII.
Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
Found while testing crates/js/src/formcalc.rs:2169 (Str) and :2258 (Format number picture)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#2305 fixed WinAnsi 0xAD in `crates/fonts`, and closed with two loose ends it named: the
user-visible extraction path goes through hayro, not that table, so it still reported the
invisible character; and the same Annex D.2 note makes 0xA0 the `space` glyph. This finishes
both, and makes the two tables in the product agree about the same byte.
WinAnsiEncoding gives codes 0xA0 and 0xAD the glyph names `space` and `hyphen` -- the names it
also gives 0x20 and 0x2D -- and MacRomanEncoding gives 0xCA `space`. The tables called them
`nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are invisible
formatting characters, so extraction hid ink the reader can see: "well-known" drawn with the
0xAD byte matched no search and no redaction pattern, and a non-breaking space did not match the
space a reader typed.
Evidence. Two sources transcribe the table the same way (an Annex D implementation's docs, and
the WinAnsi.enc encoding vector R ships, which gives `/space` at octal 240 and `/hyphen` at
255), and a black-box check settles the rest: on a synthetic fixture pdftotext reads the
base-encoded codes as `a-b a b`, and keeps U+00AD for a code an explicit `/Differences` names
`sfthyphen`. We now do both. Over the 989-file pdf.js corpus the soft hyphens we extract fall
from 117 to 13 and the non-breaking spaces from 30 to 26; everything left comes from `/ToUnicode`
maps, which say what they mean and are not touched.
Scope. The base table only, in the text direction only.
- A `/Differences` entry naming `sfthyphen` or `nbspace` is a deliberate statement about one
code and keeps its glyph list value, as pdftotext does.
- Glyph lookup is untouched, so a font program's own `sfthyphen` program is still found first.
In hayro that is why the alias sits in the Unicode path rather than in the generated encoding
table: `code_to_ps_name` is shared with lookup.
- Writing is unaffected: `encode` takes the lowest code showing a character, so ' ' and '-'
still write 0x20 and 0x2D, and a caller holding a literal U+00A0 still writes 0xA0.
Tests. `fonts` pins both tables and the whole decode/encode path including the `/Differences`
case; `render` pins the extraction a reader actually gets, over WinAnsi, MacRoman and
`/Differences`. Each was watched failing with each cell and each match arm reverted in turn, so
no assertion is passing by accident. fonts 70, render 296+4, redact 49, a11y 43 and edit 156 are
green, with fmt, clippy, `xtask layers` (37 crates) and `xtask assets` clean.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
CUPS drivers ship their names in other languages as PPD localization lines
(`*cupsLanguages`, `*de.Translation KMDuplex/Druckart: ""`, `*de.KMDuplex
Double/2-seitig: ""`), which the system print dialog uses. PdfCraft showed the
PPD's default (English) names in every language. `spool::printer_options_in`
now applies the lines for the UI language (`pt-br` → `pt_BR`, then `pt`;
`zh-hans` → `zh_CN`) to option, group and choice names. Each line is decoded as
UTF-8 on its own, as drivers write them in otherwise Latin-1 files; missing or
empty translations keep the driver's name. `printer_options` (automation) is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared dialog footer laid its buttons out right-to-left in all the
height left, centred. After a taller Document Properties tab the modal
kept that tab's height, so Close floated far below a shorter tab's last
row. The footer is now one row high.
Page hovers (form fields, comments, the link tool, image editing) set the
cursor from the raw pointer position, so a hand or text cursor showed
over fields under a dialog's backdrop, where nothing takes clicks. While
a modal is open the cursor starts from the arrow each frame and only the
modal's own widgets change it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents fixed bugs that were already reported, or already fixed on main,
because nothing told them to look first. Say to search issues and PRs
(open and closed), check whether a merged fix is on main, and build on
an open PR rather than opening a competing one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Properties ▸ Space Audit shows the same bytes-by-kind table as the PDF
Optimizer's Audit Space Usage, which now shares its table code. The
audit walks the whole file, so it runs when the tab opens and again only
after an edit (keyed on the document and its edit generation). The tab
is also reachable as `--dialog space-audit`.
The audit table no longer overflows its dialog: egui's default minimum
column width stretched the narrow Bytes column past the computed widths,
widening the card by about 5 points.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Description tab's plain values (File, Application, PDF producer) now
start where the text inside the Title/Author fields does, the fields use
the dialog's full width instead of a fixed 420 points, and Subject and
Keywords wrap onto more lines instead of clipping mid-word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bar beside selected text closed after Highlight, Underline, Strikethrough,
Add comment and Replace text because those consume the selection. Copy kept the
selection, so the bar stayed open. Remember the copied selection and hide the
bar until the selection changes; the words stay selected for ⌘C or another
action.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Explorer reads /select,"<path>". The path went in as one ordinary
argument, which Rust quotes whole when it contains a space
("/select,C:\a b.pdf"), and Explorer answers that by opening Documents.
A file saved to a folder or name with a space (often after Save As) was
therefore never shown. reveal_command now quotes the path after the
comma and the Windows runner passes the arguments as written; a path
containing a quote, which Windows doesn't allow, is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
With PdfCraft as the default PDF program, Windows painted the program's
icon, the lion, over the lower-right corner of every PDF thumbnail and of
the preview pane, covering part of the page. Thumbnail overlays come from
the ProgID's TypeOverlay value. When it is absent, Windows uses the default
program's icon, and an empty string means no overlay ("Thumbnail Handlers",
Microsoft Learn). The installer now writes TypeOverlay = "" on
PdfCraft.Document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar's expand button was labelled "Expand", the same as the Combine
grid's per-file Expand on its hover bar. With both on screen, the Combine
grid tests' lookup by that label found two buttons and 13 of them failed.
The buttons are now "Expand all tools" and "Collapse all tools", translated
in all 22 catalogues, and the labels are tl! literals the coverage scan sees.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Combine grid stopped at 200%, too small to read a value off an invoice
while reordering the pages of a statement. The main viewer already reached
6400%, so only the card grid was capped.
- The range is 60–500%, and the buttons and keys step
60/80/100/125/150/175/200/250/300/400/500%.
- MAX_SIDE, the thumbnail texture cap, goes 768 → 1536, so a card near the top
re-renders sharper instead of upscaling a 768px texture. It only binds near the
top: a card below it asks for less and costs no more than it did. Up there one
thumbnail is a few megabytes, so TEXTURE_BUDGET and UPLOAD_BYTES_PER_FRAME keep
the cache at a screenful plus one frame's uploads — and a screenful at the top
of the range is a card or two, because a card is wider than most windows.
- The control channel's range message is derived from the constant rather than
repeating "60 and 200", so it cannot drift from the range again.
Found while testing crates/automation/src/lib.rs:1747
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
With ?host=parent, saves are posted to the parent with the app's own
origin as the target. A framing page on another origin never gets them:
the browser drops each message without an error, post() returns Ok, and
the document is marked saved and the leave-page warning cleared (#2167).
Bridge::new now returns None unless the parent's origin is the app's
own (reading another origin's location throws, which is None too). The
app then runs as if unframed and saves download, and a console warning
says why. Same-origin embedding is unchanged.
The jump scrolls to MARGIN - GAP above the page. A page that fits has
nothing to scroll, and egui clamps the offset only after drawing the
frame, so the page showed 14 pt too high for a frame and then hopped
down. Clamp the jump to the scroll range before handing it to egui.
A text edit that needs glyphs the source font's embedding lacks writes
the face's full program once. It was written uncompressed, and the
document-wide search that lets another page reuse it compared the
stream's raw bytes, so it only matched an uncompressed copy.
Write the program with Flate, and compare its decoded bytes (after the
/Length1 check) both on the page and across the document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At Fit Page in single-page view, every wheel turn forward drew one
blank frame before the new page appeared.
- Lay out the shown row again after the wheel is read. The jump to the
new page was measured from the old row's place and scrolled both out
of view; going back it came out negative and was clamped, so only
forward turns flickered. The edge-turn deferral that worked around
this goes.
- Prefetch the rows either side of the shown one, so the next page is
rendered before the turn instead of showing blank paper.
AES-256 files (R5 and R6) store the permissions twice: in clear in /P,
and in /Perms, encrypted with the file key (ISO 32000-2, Algorithm 13).
We wrote /Perms but never read it, so a document opened with the user
password got whatever /P said, even when /P no longer matched /Perms.
permissions() now decrypts /Perms and, when it holds a valid block
(marked "adb"), counts a permission only if both /P and /Perms grant
it. Without a readable /Perms, /P applies as before. R2 to R4 need no
check: /P is part of their file key, so an edited /P does not open.
A turned paragraph's width came from its rows' upright bounding boxes,
turned into the baseline's frame. On a slant that box is wider than the
row by the row's height times |sin 2θ| (about 12 pt for 12 pt text at
30°), so a rewrite let a word that doesn't fit stay on its row.
Each run now also records its box along and across its baseline, from
the same corners as its upright box, and a line unions those. Grouping
and the rewrite's width read that box. Upright and quarter-turned text
are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register Catalan (`ca`) as an interface and date language with a
2,592-entry contributor-original catalog written from the meaning of the
English labels. Plurals use the one/other rule; every `ca-*` locale,
including Valencian (`ca_ES@valencia`), resolves to this catalog.
Tests cover registration and persistence, every command, menu, tool group
and section, every `tl!` literal in the UI source, and every key in the
other bundled catalogs. The Latin-script glyph test in tests/fonts.rs now
includes Catalan (the interpunct of l·l is covered by the bundled faces).
Catalan dates elide the preposition before a vowel (7 de març, 7 d'abril,
8 d'octubre), so the date language sets months_with_day, as Czech and Russian
do; a unit test pins it.
Update localization docs, the parity table and the roadmap log, and add the
ATTRIBUTION.toml entry (ATTRIBUTION.md regenerated). The contributor, a
native Catalan speaker, has reviewed the translation.
Validation: cargo fmt --check; clippy -D warnings and tests on
pdfcraft-ui-egui (i18n, fonts); pdfcraft-engine dates tests;
cargo xtask assets; cargo xtask layers.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Fix main UI regressions in paging, fonts and test expectations
* Wait for asynchronous dropped-document preparation in regression
---------
Co-authored-by: Codex <codex@openai.com>