* M14: keep long menus usable in short windows
* Menus: scroll each top-level menu once, from its caller, so View's list isn't nested in a second scroll area
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(preflight): identify standards by XML namespace
* fix(preflight): bound XMP attribute parsing work
* fix(preflight): cap XMP during stream decoding
* Preflight: read the PDF/A and PDF/UA identification by its usual prefix when the XMP can't be parsed as XML
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Viewer: accelerate coarse wheel scrolling (#1072)
Line/Page notches moved a fixed eased step, so a fast spin felt slow.
Accumulate one frame of coarse input per axis, scale by arrival rate
(1.0 idle, up to 6.0 spinning), and apply it directly like precise
touchpad input.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Viewer: add only the fast-spin surplus on top of egui smoothing
Single notches flow through egui untouched, so isolated input matches
the scroll speed preference exactly; page units are never boosted.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
---------
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* M1: a saved file keeps its creation date (#2658)
Every save writes a staging file and renames it over the target, so the
file's creation date became the save time. Before the rename the staging
file now takes the replaced file's creation date, where the system lets
a program set one (Windows and macOS, through std's FileTimesExt;
best effort). A new file keeps its own date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
* Staging: compile the creation-date step only where it applies; say so in write_atomically's doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M7: work out each font's space width once per paragraph rewrite (#2616)
Each keystroke in Edit text rebuilds the paragraph's draft. When a source
font can't show a space (LaTeX and Sphinx subsets space words with TJ
kerning instead), source_space reads every font on the page, building
Metrics for each. The draft did that once per character, so a keystroke
in a 380-character paragraph of NVIDIA's vGPU guide took ~460 ms. The
widths are now worked out once per font before the per-character pass:
~71 ms there, ~16 ms for a 200-character paragraph (was ~255 ms), with
identical layouts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
* M2: work out space widths only for the fonts a paragraph uses (#2616)
Review follow-up: the eager map scanned the page once per page font even for a
short edit on a page of many fonts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Recovery snapshots are unsaved copies of open documents. The folder was
made with the default mode, so on a shared Linux or macOS machine other
accounts could list it and read the snapshots of documents that are not
encrypted. The folder is now made owner-only (0o700), and a folder an
earlier version made is tightened when an instance starts or writes a
snapshot, as image_pick does for its folder. A volume that can't change
modes (FAT, some network shares) works as before, with one warning in
the log.
Merges left four hayro-interpret rows; the current one lacked the
transparency-group, Type 3 rendering-mode and Lab white-point patches (each
numbered 25, 27 or 28 on a branch where another patch took that number).
They are now (41), (42) and (43) in the one row, with their tests.
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M10: exported TIFF and JPEG pages record the resolution they were drawn at
A page exported as TIFF said 1 x 1 with no unit, whatever the dpi, and
a JPEG export said nothing, so other programs showed and printed them at
a default (a 300-dpi page shown at 72 dpi is four times too big).
encode_image now takes the dpi: TIFF X/YResolution in inches, the JFIF
density in dots per inch.
PNG stays without pHYs. It counts pixels per metre, so 72 dpi would read
back as 72.009, and a page made from an exported PNG (doc_create from
images, as enhance_camera_image_through_tools does) would come out 0.01%
small.
Exporter records the resolution a page was actually drawn at (dpi_used),
so a page too large for the renderer says the lower one. render records
--dpi, since it refuses oversized pages. The TIFF is written with the
tiff crate, already in the graph, because image's TiffEncoder can't set
a resolution.
* M10: TIFF page exports are LZW-compressed, as documented
encode_tiff's comment said "LZW-compressed by the encoder's default",
but the TIFF was written uncompressed: a Letter page at 300 dpi came to
25 MB. It is now LZW without a predictor, the compression TIFF readers
support most widely after none.
* Contributors: add my name for the About window
* cli: info names the standard a document declares
A PDF/A file was summarised with no mention of PDF/A. `info` prints two dozen
fields -- tagged, xfa, javascript, layers -- and said nothing about the
standard, while the app's Standards panel reads the same declaration from the
catalog's XMP and shows "PDF/A-2b" (#3319).
`info` gains `pdfa` (the part and conformance together, e.g. "2B"), `pdfua` and
`output_intents`, from `pdfcraft_preflight::declared` -- the call the Standards
panel makes, so the two agree by construction rather than by a second reading
of the metadata. A document that declares nothing leaves them null and empty.
Read in the CLI rather than in `pdfcraft_render::Inspect`: render is layer 3
and preflight layer 4, so the summary struct can't reach it, while an app may
depend on any layer.
* cli: read the declaration from the bytes already in hand
`Document::open_with_password` takes an `Arc<Vec<u8>>` and `info` already holds
one, so `bytes.to_vec()` copied the whole file for every call -- a file-sized
allocation on a large PDF, for nothing. Clone the `Arc` instead.
* info: name the declared PDF/A level as the automation tools do (PDF/A-2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/js/src/formcalc.rs:1929-1957 (format_date), with DateFmt returning "EEEE, MMMM D, YYYY" at :2333
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Found while testing crates/xfdf/src/lib.rs:192-209 (esc), used by export_xfdf (:445) and export_data XML (:594-612)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* M4: Ctrl-drag copies pages in the Organize grid (#2997)
Dragging thumbnails in the Organize page grid with Ctrl held (Option on
macOS) drops copies at the gap instead of moving the pages: a "+" badge
by the pointer, the copies land at the gap selected, and one undo step
takes them out. The insertion point joins the engine edit
(Edit::DuplicatePages { at }) so the copy is one undo step rather than a
duplicate plus a move.
page_duplicate takes a 1-based "at" (the first copy becomes page "at"),
so agents can drive the same thing, and the UI control channel's
ui.drag modifiers now reach the input state -- which gestures that read
it (this copy-drag, and Alt-drag column select) need.
Closes#2997.
* fix(ui): take the grid's copy modifier from the runtime OS (#3092 review)
Option-drag in the Organize grid moved the pages instead of copying them in a Mac
browser: `cfg!(target_os = "macos")` is false in the wasm build, and eframe sets the
context's OS from the browser. `copy_modifier` now takes the context and asks
`commands::mac_shortcuts`, as the shortcut labels do.
The drag helpers in `tests/editing.rs` and `tests/control.rs` picked the modifier the
same compile-time way, so they would have sent Ctrl on a Mac host; both read the
context's OS now. The new regression test
`option_copies_and_ctrl_moves_when_the_context_reports_macos` drives a Mac context —
Ctrl moves, Option copies — and fails without this change.
crates/cos/src/document.rs: both new accessors, header_offset (#2525) and reconstructed (#2029).
parity/acrobat-features.toml: the malformed-xref entry has both sides' evidence and notes (#2029 full save of a repaired file, #2717 table numbered from 1).
* M4: custom document properties through the engine and the tools (#organize.custom-properties)
/Info entries beyond the ones the spec names are custom properties:
organize lists them (custom_info) and set_info accepts any such name
after Acrobat's checks (no standard name, no empty name, no spaces,
delimiters, / or #, at most 127 bytes); inspection lists them too
(DocInfo::custom); doc_info reports them under custom and doc_set_info
sets or clears them. The Custom tab in Document Properties and the XMP
mirror stay planned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom document properties list the same before and after an edit
Review follow-up. The open-time reader (render/inspect.rs) now uses the
engine's rule (pdfcraft_organize::custom_info, trimmed, empty values
dropped): text strings only, NUL/whitespace trimmed, empty values left out,
and the 256 cap counting listed entries. Before, it also listed name
values (`/Dept /RnD`) and capped before filtering, so the list could change
after the first unrelated edit. An automation test opens a file with a name
value, blank, NUL-padded, empty and numeric entries and checks doc_info's
custom list is the same before and after setting the Title.
Drops the ROADMAP.md log line, which conflicted with main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom properties drop NUL/whitespace padding in one pass, before the cap
Review follow-up. custom_info now trims NULs and whitespace in any mix
(" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does
both before taking 256, so blank entries can't crowd real ones out. It is
the one rule: the engine uses it as it is, and the open-time reader trims
the same way. Tests: 300 blank entries before a real one, and mixed
padding, in organize and through doc_info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
* fix(engine): Save writes a repaired file in full even without edits (#1937)
A damaged file is repaired on open (its cross-reference index rebuilt from
object headers, as Document Properties reports), but Save wrote it back as it
was: `Session::save_bytes` returned the bytes as opened whenever nothing was
edited, and a repair is not an edit. The "repaired" file on disk was still the
damaged one.
`save_bytes` now writes a reconstructed document in full even with no edits.
`Document::reconstructed()` in cos names the condition (no revision chain:
rebuilt, or built from scratch), and `write_incremental` uses it too, so the
writer and the engine share one definition.
Tests: `engine::tests::a_repaired_file_is_rewritten_by_save_even_without_edits`
(table offsets shifted; no table at all) and
`automation::saving_a_repaired_file_puts_the_repair_on_disk` (doc_open,
doc_info, doc_save over the same path, reopen). Both fail with the condition
removed: the saved bytes equal the damaged file. parity: core.repair-broken-files
cites them and notes that a repair still does not count as an unsaved change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* fix(engine): a signed repaired file keeps its bytes on Save without edits (#1937)
Review follow-up: writing a reconstructed file in full on Save puts the
repair on disk, but for a signed file it would also invalidate its
signatures without warning. Save now skips the early return only when the
document is reconstructed and not signed. A regression test signs a
document, appends a `startxref` that points nowhere (the signed bytes stay
intact, the cross-reference data must be rebuilt) and checks that Save
without edits returns those bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once.
parity/acrobat-features.toml: the XFA entry has both sides' evidence and notes (#2604 page areas, #2813 CLI layout).
Fixes#1645. For a dynamic XFA form, `info` reported the placeholder
page and field counts, and `text` and `render` read the placeholder
pages, while the app and the automation tools lay the template out.
The CLI now opens dynamic XFA forms through the engine session and uses
its laid-out document for page and field metadata, text and pixels.
`info` keeps the input's file size and also reports the original counts
as `pdf_pages` and `acroform_fields`; `text` checks the copy permission
before extracting. The input file is never written.
Checks run (rustc 1.99.0): cargo fmt --check; cargo clippy -p
pdfcraft-cli --all-targets -- -D warnings; cargo test -p pdfcraft-cli;
cargo xtask layers; cargo xtask assets.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M3: type an exact zoom into the rail's zoom field
The rail's zoom percentage was a read-only label. It is now a text field:
Enter applies a typed percentage (150, 150%, 62.5 %), Escape or unparsable
input keeps the current zoom. The control channel's zoom option uses the
same parser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M3: document parse_zoom_percent
* i18n: Vietnamese for the zoom field's tooltip
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
In a region that mixes sizes (a 24 pt title over 12 pt lines), the
inline editor's caret on a small row was as tall as the region's first
size and reached into the row below. egui draws the caret at least one
row of the TextEdit's font tall, and that font had the paragraph's first
size. Each row already carries its source row's height, so the
TextEdit's font is now half the region's smallest size and only sets
that floor.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Clamp the surface device descriptor to the adapter's capabilities while
preserving its maximum 2D texture extent. Cover V3D shader variables,
lower GL capabilities, alignment ordering and capable-device defaults.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Several entries carried mainland-Chinese computing terms in a catalog
written in the vocabulary used in Taiwan:
- 丟失 → 遺失 (unsaved-changes warning)
- 郵政編碼 → 郵遞區號 (Zip Code field formats)
- 自簽名 → 自我簽署 (self-signed digital IDs, three entries)
- 灰度 → 灰階 (Grayscale Images, Print in grayscale; the rest of the
file already used 灰階)
- 匹配 → 相符 (ten search/match entries; 符合 stays for the
fit/conform sense: 符合頁面, 符合 PDF/A)
Translation column only — source keys, placeholders and ellipses
unchanged. ATTRIBUTION.toml hash re-pinned.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Find credit card numbers followed by an expiry, year or CVV
Found while testing crates/redact/src/patterns.rs:180-190 (credit_card, via digit_run at :53)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Redact: a card followed by a number drops at most four trailing digits
A longer grouped number (an IBAN's digits) no longer matches on a Luhn-valid prefix; the run is
read far enough to see the tail and refused when it goes on past that.
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Keep a subscript or superscript in its line when extracting text (#3174)
* Text extraction: only a glyph right beside the previous one gets the sub/superscript centre limit
A large negative gap (a small glyph drawn back under a large one) kept the wider limit.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
doc_flatten with comments chose every non-widget annotation except Link
and Popup, so a comments-only flatten baked in and deleted 3D, Screen,
Movie, RichMedia, PrinterMark, TrapNet and Watermark annotations with
their data (a 3D annotation's U3D model and default view). The comments
branch now uses pdfcraft_annot::is_comment_subtype, the classifier the
comment list uses; widgets follow `fields` only.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Word's Type 0 subsets map CID 0 (.notdef) as well as the real space glyph
to U+0020 in ToUnicode and list a width for CID 0. encode keeps the lowest
code for each character, so an edited line wrote every space as <0000> and
drew a box where each space was. CID 0 is .notdef whatever ToUnicode says,
so has_glyph now refuses it and the space takes the real CID.
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
crates/xfdf/README.md, parity/acrobat-features.toml: next's text, which has the same sentences without the formula-cell one the #2688 integration fix removed.
crates/xfdf/src/tests.rs: the XML comments test once, where next has it.
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once.