* i18n(es): translate the new print and Add text strings, Type on the signature pad, tú register
Seven tl! literals reached the Spanish UI in English: the PDF page size,
Match page at 100%, PDF scale and the two scale hints in the print dialog,
and the Done / Discard buttons of Add text. The signature pad's Type
button read "Tipo" (the noun) because Spanish had no `signature pad`
entry; it now reads "Escribir", beside Dibujar, Imagen and Escanear.
Four rows used the formal usted register the catalog header rules out
(Elija, Cámbielo, the signature-photo hint, the no-signature notice);
they now use tú like the rest.
Spanish gains `spanish_covers_ui_literals`, the check Japanese, Italian,
French, German and others already have, and its command test checks
menu names too, so a new string can't ship in English by accident.
`contextual_labels_fall_back_to_the_plain_translation` used Spanish's
missing `signature pad` entry as its example; it now uses a context no
catalog defines, so it keeps testing the fallback as catalogs fill in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M2: heavy-sheet generator and page timing harness (item 8 of storytold/pdfcraft#623)
Adds two examples to pdfcraft-render. synth_sheet writes a synthetic A1 sheet whose content
follows the sample's layout, so timings on it can be compared with the sample. page_timing
replays the canvas's requests for page 1 and prints times, counts and an output hash. The
renderer is unchanged. Budgets are reported, not enforced.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* M2: sort sweep results by tile size too (item 8 of storytold/pdfcraft#623)
Tiles at the same origin but different sizes compared equal under the sort key, so the
sweep hash depended on completion order. Including the size makes the key unique.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
document_scripts_named walked the catalog's /Names /JavaScript tree by
resolving and deep-cloning every kid onto its stack before looking at it.
The 10,000 cap counted nodes taken off the stack, not what went on it, so a
node whose /Kids lists itself k times pushed k full copies of itself (each
holding its k-entry /Kids array) per visit. A 6.5 KB file with k = 1000 made
`pdfcraft-cli run` (doc_open, js_document_scripts) exhaust an 8 GB address
space in 8 seconds and abort; the same walk runs when a field edit sets up
the script runner. A tree that lists a leaf twice returned its script twice.
Keep kids unresolved on the stack and skip references already read, as the
/Dests walker in organize does. The cap stays.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Highlight appearances written by other editors are a transparency group whose
content is filled with `/BM /Multiply`. Every `/Group` form got a layer of its
own, so the Multiply blended with that layer's transparent backdrop and the
layer then covered the page as an opaque block.
Vendored hayro-interpret patch (28): a group that is neither isolated nor
knockout composites its elements with the backdrop (ISO 32000-2 §11.4.7), so
when it is entered at full opacity with no soft mask and Normal blending there
is nothing for a layer to add, and its content is drawn straight onto the page.
`/I` and `/K` are read from the group dictionary. An isolated group keeps its
blank backdrop; a knockout group or one entered at partial opacity, under a
soft mask or with a blend mode keeps its layer.
Test: `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`
(the appearance drawn as an annotation and from page content, with `/I false
/K false` spelled out and with no group at all; an isolated group as a control;
a group entered at half opacity still composited as one). It fails before the
fix with the text pixel yellow instead of black. parity: view.render-transparency
cites it and says what is still approximated.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Wrap text in Document Properties rows within the 420 pt content column so documents with long filenames or metadata do not stretch the modal dialog card beyond the viewport boundaries.
Fixes#2011.
* Redaction Tool Properties: the mark's appearance, and Make properties default
Acrobat's third group in the Redaction Tool Properties dialog: the mark's
outline colour (/C), a fill colour shown while the pointer is over a mark (a
viewing preference, never written), and the mark's opacity (/CA). Make
properties default: ticked, OK keeps the values for the next run (saved with
the settings as redact_prefs); unticked, they hold for this run only and the
saved defaults stay; Cancel puts back what the dialog opened with. The dialog
is laid out in two columns so it fits a laptop screen. Rows in every
language, their checksums in ATTRIBUTION.toml, parity
protect.redaction-properties shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Edit: crop an existing page image with its handles
ImageChange::Crop(rect) keeps the part of a page image inside rect (user space)
with a clipping path placed just inside the scope that holds the image (or a
new q … Q around its Do); a clipping path already at the scope's start, the
picture's earlier crop, is replaced, so a crop can grow back. The pixels are
never scaled, and a later move or resize wraps the scope, so the crop moves
with the picture. ImageEdit::Crop (undo label "Crop image"), image_edit
action crop. In the window, right-click ▸ Crop Image puts the selected
picture in crop mode: eight handles trim (the inside pans), the part that goes
is dimmed, Enter or Done applies, Esc or Cancel leaves; Remove Crop shows the
whole picture again. Rows in every language, their checksums in
ATTRIBUTION.toml, parity edit.image-crop shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Image crop: Bulgarian hint without a character the monospace UI font lacks
JetBrains Mono has no 'ѝ', so primary_ui_fonts_cover_latin_and_cyrillic_catalogs
failed; the hint is reworded ("with its handles" -> "with the handles").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
calculate_js wrote a field's simplified field notation as
"/** BVCALC expr EVCALC **/ event.value = AFMakeNumber(0);". PdfCraft reads the
comment, but a viewer that runs the script instead (Acrobat among them) computed
0. notation_js now writes Acrobat's own translation after the comment: each
field name becomes AFMakeNumber(this.getField("name").value), with \ escapes
in names honoured, numbers and operators kept.
Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* View: in Single Page view the wheel turns the page at the edge of a zoomed-in page (#1924)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* ROADMAP: log edge paging (#1924)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* View: no jump past the last/first page, a turned page starts at its edge (#1924)
step_page is a no-op at either end (it re-aligned the view via goto), the page-view
ScrollArea is not animated (its zero-length offset target overwrote the turn's jump), and
the rest of a gesture that turned the page no longer pans the new page.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Page commands with no selection (Rotate, Delete) act on the current page. A page gone to
earlier stayed current while it showed whole, so the next press on the page beside it
rotated the wrong one.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Comments: font size for text boxes in Comment Properties (#1908)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Apply the minimum after page-unit scaling without inflating small raw crops. Retain the existing unit policy and nearly-zero fallback, with regression coverage for origins, rotations and reciprocal rounding.
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A VML rectangle with an image did not show the picture in Word. Pictures on
positioned pages are now wp:anchor drawings behind the text, sharing the
picture XML with inline pictures. At most 1000 pictures / 512 MiB are embedded;
empty images are skipped.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Every way into the page grid (toolbar, tool panel, page.organize, --organize,
Create multiple) goes through DocView::open_organize, which drops a selection
left from earlier that does not include the page being read and scrolls that
page's cell into view, computed from the row so virtualized rows need not be
laid out. Grid clicks navigate the document as Pages-panel clicks do, so the
viewer no longer resets the current page from its scroll position when the
grid closes.
Regression: crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page
(middle page, last page, reopening after a selection elsewhere).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Anthropic's tool API rejects a request when any tool's input_schema has
oneOf, anyOf or allOf at the top level, so attaching `pdfcraft-cli mcp`
to a Claude-based client made every prompt fail. The field/fields rule is
already enforced in the form_set_props handler and described in the
schema, so the oneOf is dropped.
tool_table_is_well_formed now checks that no tool has a top-level
oneOf/anyOf/allOf.
Co-authored-by: Eromosele <188490945+andreero0@users.noreply.github.com>
* Print: print as image (Advanced), with grayscale pictures
print::Settings.raster (dpi 72-600, grayscale): Session::print_pdf renders each
distinct page used once the way the viewer shows it (comments left out when the
print leaves them out), composes it over white, grey by Rec. 601 luma when
grayscale is on, makes an image-only document of the pictures and imposes it on
the paper like the pages themselves. The Print dialog's new Advanced section
(folded unless on) offers it at 150, 300 or 600 dpi; doc_print takes as_image
and dpi. Rows in every language; parity print.print-as-image shipped,
print.grayscale shipped (the pictures are grey, not only the printer asked).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ATTRIBUTION.toml: the language files' checksums after the new rows
cargo xtask assets checks each catalog's sha256; the rows this branch adds
changed the files without updating it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Auto-size fields (`0 Tf`, often inherited from the AcroForm /DA) fit their
value to the box in the page appearance, but the in-place editor and the
preview shown before the page re-renders ignored it: they used a fixed guess
from the box height, never shrank to the width and never wrapped multiline
text, so long values ran past the box and were clipped.
The fit now lives in one place, `appearance::auto_text_size_by` (re-exported
as `field_auto_text_size_by`). The page appearance measures with Helvetica;
the screen measures with the interface font it draws in, which is wider and
breaks lines differently, so the text stays inside the box on screen too. The
editor wraps at the same scaled width the size was fitted to. Explicit sizes
are unchanged and still clip, as in Acrobat.
Tests: forms auto_text_size_fits_single_and_multiline_text (incl. NaN/inf/
negative boxes and a NaN measurer); ui-egui
auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box (fails with the
old on-screen sizing restored).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The vendored hayro-syntax passed each filter an output limit, but
ASCII85 ignored it: every `z` expands one byte to four, so a Flate
stage at its limit could be grown another 4x past every budget.
ASCII85 now writes only what fits under the limit, stops with a
warning once it is reached (like RunLength, LZW and Flate), and caps
its starting capacity at the limit.
Test: decompression_bombs_stop_at_the_stream_limit gets a
[/FlateDecode /ASCII85Decode] bomb; it decoded 301,989,892 bytes
before and stops at MAX_DECODED_STREAM now.
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Check admission before each prepared result so a modal or queued picker keeps its document recipient. Wake retained replies once after picker consumption or worker abandonment.
Preserve merged #1914 continuation/startup behavior and the three worker regressions from upstream 3f044b62 (jcl-bit and its recorded coauthors). Keep the inherited printing/dependency changes intact; add only the remaining ownership barrier, wakes and focused regressions.
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M9: signature labels follow the interface language, and one the font can't draw is refused, never written as ?
The labels of a visible signature ("Digitally signed by", "Reason: ", ...) were written in WinAnsi unchecked, and every caller passed the English defaults. Now:
- sign: a drawn label is checked as the details are, and one Helvetica can't draw is refused (SignError::Unsupported) naming the label and the character. AppearanceLabels::fields and ::first_undrawable say which labels a caller can use.
- ui-egui: signing stamps the labels in the interface language when Helvetica can draw all of them, otherwise all in English (never a mix) with a note under the preview. The preview uses the same labels, the stamp's line layout and the signing time.
- automation: sign_document takes optional labels and appearance; a label the font can't draw, an unknown key or one without a visible signature is InvalidArgs.
- Catalogs: the five labels and the note in every language; refresh hashes.
Follows up the note on #1112.
* Refresh catalog hashes after merging main
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Flattening the fields of a laid-out XFA form used to leave the XFA in
the file: Adobe's viewers then laid the form out again from the
template over the baked pages, so the "flattened" PDF was still a live
form there and a plain one everywhere else.
pdfcraft_xfa::strip takes the XFA out of a document: the packets
(template, data and all), /NeedsRendering on the catalog and the
AcroForm, the layout mark, the overrides, and the private keys on
every field down the tree. The engine calls it after Flatten form
fields on a document with XFA packets, so the result is a plain PDF
every viewer shows the same, and for the new Edit::XfaToStatic
("Convert to a static form"; tool xfa_to_static), which keeps the
laid-out pages and the fields with their values as an ordinary
AcroForm that no viewer lays out again and whose scripts no longer
run. Both undoable: the engine's post-edit refresh re-reads the
layout mark to drop or restore its XFA state and the cached template.
What the packets held beyond the fields is reported (packets by
name, data no field showed, buttons left inert, the layout's own
warnings, Reader usage rights) as document warnings, and a dynamic
form that was never laid out is refused rather than left with
nothing. Applying redactions shares the same strip. Closes the last
planned XFA parity entry (form.xfa-flatten shipped).
Inserting an annex into a guide dropped the annex's bookmarks: the pages
came in without them, while Combine Files keeps every file's bookmarks
under one named after the file.
Insert pages from file now does the same: the inserted file's bookmarks,
destinations mapped to the copied pages (positions kept, named ones
resolved), go under one new bookmark titled with the file's name at its
first inserted page, collapsed. That bookmark is placed among the
document's top-level bookmarks before the first one pointing after the
inserted pages. A file without bookmarks adds none.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
To classify what later revisions changed, the signed bytes were reopened
with Document::open, without a password, so for a file opened with a
nonempty user password the earlier revision was unreadable and the earlier
signature became Invalid as soon as it was counter-signed. Document::
open_with_same_credentials opens such bytes with the current document's own
security handler when their /Encrypt and /ID are the same (no password is
needed again or kept), and the signature validator uses it.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The loopback listener spawned a thread per accepted connection and read its
first line with no deadline and no limit on how many could wait, so any
local process could hold threads for ever before knowing the token. Each
connection now has an absolute deadline to authenticate (10 s from
acceptance, re-armed per read so a byte at a time cannot extend it), at most
8 may wait at once (the rest are refused on the accepting thread with an
error and closed), writes time out, and a waiting place is given back on
authentication and on every exit.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A widget with no appearance to draw (none stored, or none for its selected
state) was removed from the page and the field tree with nothing in its
place, so the field's value was lost; a reply kept because it could not be
drawn was then deleted anyway as a reply of the flattened comment. Both now
stay as they are. A widget whose appearance draws nothing is still removed,
as is a reply note's empty appearance with its comment.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The effective-resolution walk skipped any form XObject already visited
once when it was nested more than four deep, with one seen-set for the
whole document. A later, larger use of the same form was never measured,
so Reduce File Size and Optimize PDF resampled the image for its smallest
use: a 1200 px image drawn 1 in and 8 in wide through six nested forms
came out 150 px wide, about 19 ppi on the large copy.
- Cycles are detected on the current path of forms, not document-wide.
- A form with its own resources drawn again at the same scale (only
moved) is skipped, as that can't change any image's resolution; forms
without resources draw their caller's images and are always walked.
- Repeat walks are capped at 100,000. If the cap or the 12-level depth
limit leaves a use unmeasured, every image gets INFINITY: recompressed
as the settings say, never resampled.
Fixes#1811
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Compare files: side by side, scrolled and zoomed together
After Compare files, when the old version is open in a tab and the window is
at least 1000 px wide, the document area splits: the old file on the left, the
new on the right, each a page view with its own shading (deleted and replaced
on the old, inserted and replaced on the new, visual differences on both). The
half under the pointer leads; the other takes its zoom, fit, layout and
rotation, and its scroll offset on the next frame. A change clicked in the
panel jumps both halves; Side by side / New file only toggle the mode; Clear
ends it. The old file keeps rendering while shown. Rows in every language,
parity misc.compare-side-by-side shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ATTRIBUTION.toml: the language files' checksums after the new rows
cargo xtask assets checks each catalog's sha256; the rows this branch adds
changed the files without updating it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The page Copier recursed once per reference and once per nested array or
dictionary with no bound, so a source whose page reached a chain of thousands
of shallow indirect objects overflowed the stack in Insert Pages, Combine,
Extract, Split and Duplicate; the memo map only cut cycles. The copy now keeps
a depth of at most 256, refuses with an OrganizeError beyond it, and
import_pages_with frees the objects a refused copy added, so the destination
is as it was.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
add_field gave text and date fields a fixed /Helv 12 Tf, which is clipped in
a box shorter than 12 pt, and a script can't see the clipping. They now get
/Helv 0 Tf (Auto), as Acrobat's new text fields do: 12 pt where there is
room, smaller where there isn't. An explicit size set afterwards still wins;
choice fields keep 12 pt.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The phone pattern tried every position, so the last groups of a French
social security number, an IBAN or a SIRET matched as a trunk-0 or NANP
number once the groups before them were skipped, and the start of the
identifier stayed visible. A phone number is now a whole run of digit groups:
one reached through separators from a digit before it, or going on past the
28-character cap, is not one.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Wayland compositors leave momentum to the client: libinput reports the
swipe as pixel deltas and the lift as an axis stop (TouchPhase::End), and
the page view stopped dead there (#1708). It now measures the gesture's
speed over its last 0.1 s and keeps scrolling through the #759 1:1 path,
slowing with a 0.35 s time constant.
- No glide for a slow drag, a pause before lifting, zoom or Shift swipes,
single-page paging, or with Animation effects off.
- Any wheel input, click, key, zoom, dialog or the document's edge ends it.
- Off on macOS (it sends its own momentum) and the web; Windows and X11
report no touch phases through winit, so nothing starts a glide there.
Not yet: fingers resting on the touchpad don't catch the glide; that needs
Wayland hold gestures, which the vendored winit doesn't bind.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A relative path that doesn't exist below the root was refused with the path
as typed and the system's localised message, so a script written for
another working directory ("sub/a.pdf" with --root sub) gave nothing to
correct from. The refusal now names the resolved path and the root; paths
outside the root keep their uniform refusal.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ISO 32000-2 Table 11 defines /Rows 0 (the default) as "height not
predetermined", but the vendored hayro-syntax decoder took it as zero
rows, so scanned pages written that way rendered blank. Treat 0 like an
absent /Rows and fall back to the image height.
Adds a synthetic regression test: an 8x4 all-black Group 4 stream,
hand-encoded from ITU-T T.6, rendered with /Rows absent, 4 and 0. It
fails without the patch.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
doc_audit_redactions and `pdfcraft-cli audit` returned rectangles in PDF
user space (origin at the bottom-left of the media box), while comment_list,
doc_info, text_lines and the redaction tools give points from the top-left
of the page as displayed. The same mark read [144.14, 91.2, 265.95, 102.2]
in comment_list and [144.138, 739.8, 265.952, 750.8] in the audit.
Both surfaces now convert with the helper doc_info uses (view_rect, made
public for the CLI), so they report the same rectangles, after /Rotate.
The CLI reads the page geometry only when there is a finding to place.
AuditFinding::rect stays in user space; the audit README says which space
each surface uses.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(a11y): bookmarks from structure keep the words of glyph-by-glyph headings
Chromium (Skia) draws text one glyph at a time, each moved into place with
its own Td. The marked text of a heading added a space at every Td, TD, T*
and Tm, so New Bookmarks from Structure titled them "C h a p t e r T w o",
and the real spaces between words were lost. On a 20-page tagged guide
printed by Chromium, 25 of 61 titles came out spaced.
mcid_texts now follows the text matrix (and the CTM, q/Q, Tc, Tw, Tz, TL)
and decides word breaks from where the glyphs are: a glyph that starts on
another line, more than 0.2 em after the previous glyph of the same marked
content, or back over more than an em, starts a new word. Kerning and
glyph-by-glyph placement no longer split words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(a11y): headings set with a negative font size keep their words
The em that decides word breaks was the font size times |Tz|, negative with
a negative font size: every glyph then started a new word, and a heading in
one Tj read "C h a p t e r O n e". The em is now |size x Tz|, and "ahead"
follows the way glyphs advance in text space, backwards when the size or Tz
is negative, so a TJ word space still splits words there as on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(a11y): keep wrapped headings separate under horizontal scaling
Co-Authored-By: OpenAI Codex <noreply@openai.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
* fix(audit): an applied redaction at the end of a line is not a faux redaction
After Apply Redactions removes text at the end of a line, the line keeps a
TJ adjustment where the text was, and the box of the line still spans it.
The audit compared the redaction box with that box: at the end of a line
it covered more than 40% of it and the words left beside it were reported
as covered text, on a correctly redacted file. In the middle of a line the
gap was usually too small to cross the threshold.
Text lines now also give the boxes of the words they draw (`ink`: glyphs,
without spaces and TJ adjustments), and the audit measures how much of
those words a cover hides. A real cover over text that is still there is
found as before.
doc_audit_redactions also reports its rectangles in top-left-origin points
like comment_list and doc_info; they were in PDF user space.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(audit): leave the rectangles of doc_audit_redactions in user space here
Reporting doc_audit_redactions rectangles in top-left-origin points is a
separate subject from the false alarm this branch fixes, and on its own it
made the tool and `pdfcraft-cli audit` disagree on the same file: the CLI
still prints PDF user space. Take it out of this change; the tool and the
CLI can move together in their own change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(edit): word boxes span whole words when glyphs run backwards
With a negative font size or a negative horizontal scale (Tz), each glyph
advances backwards along the text matrix, and the box of a word kept only
its first glyph: the end of the word was compared with a start that had
already moved past it. A word box is now the union of its glyphs whichever
way they go, and a TJ adjustment ends a word past 0.2 em of the scaled size,
whatever its sign.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(audit): a cover over a short value after a long label is still found
Measuring a cover on the words of a line alone missed faux redactions main
found: a long label followed by a short value hidden under the box is less
than 40% of the line's words. A cover now also counts, as before, when it
hides 40% of the line's box and at least half of one word still drawn; a
cover over the gap an applied redaction left, with no word under it, still
does not. The audit README states both rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(audit): retain partial-value cover detections
Co-Authored-By: OpenAI Codex <noreply@openai.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
- Pin FindingKind stable IDs (covered-text, unapplied-mark) with a test
and document the no-rename contract on id()
- Golden tests pinning the full AuditFinding struct for both finding
classes (page, kind, exact rect, covered text)
- Depth-limit boundary tests: 32 nested form XObjects audit clean,
33 fail closed with TooDeep
- Fix stale crate docs: m/l axis-aligned rectangles count as covers (#1510)
- Document AuditFinding.rect normalization and covered_text joining
Tests + docs only, no behavior change.
Co-authored-by: Erick Barraza <ejbarraza1994@gmail.com>
A malformed EKU left extended_key_usage at None, which reads as "no
restriction", so an issuer-signed certificate with a broken EKU could reach a
Valid verdict; EKU being on the processed-critical list let a critical one
through as well. Like keyUsage, a present EKU now restricts the key even when
it doesn't parse: it must be a SEQUENCE whose members are all OIDs, otherwise
it allows no purpose.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The redaction audit turned an re rectangle into an unknown path on a
redundant h, and its fill branch left out the close-fill-stroke operators b
and b*, so those opaque covers over extractable text produced no finding.
Keep a closed rectangle closed under h and treat b/b* like the other fills.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
encrypt_dict multiplied any V4 StdCF /Length up to 32 by 8, so a large
negative value overflowed (a panic with overflow checks, a wrapped length
without). A zero or negative length is meaningless; read it as if absent, the
128-bit default, which also fixes small negatives that used to clamp to a
40-bit key and fail to open.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Page::rotation rounded a /Rotate that isn't a multiple of 90 down to a
quarter turn (135 became 90), while the renderer draws such a page
upright. Display size and the display-to-user-space matrix (header and
footer, watermarks, measuring, printing, image signatures) then
disagreed with the page on screen. ISO 32000-2 §7.7.3.3 requires a
multiple of 90; read any other value as 0, as the renderer does.
Page import and image stamps, which read /Rotate themselves, already
treat such a value as 0. Test: rotate_off_a_quarter_turn_counts_as_upright
(/Rotate 135, 30, -45, 359).