Commit Graph
752 Commits
Author SHA1 Message Date
315a0cc458 Issue #432: global Units preference (mm, cm, in, pt), defaulting to the system region (#463)
* Issue #432: global Units preference (mm, cm, in, pt), defaulting to the system region

Preferences ▸ Documents and view ▸ Units sets the unit for page sizes
(Document Properties, Print), Header & Footer margins, the start of Set
Page Boxes and the Measure tool on pages without a viewport scale. On
first start it follows the system region: AppleMeasurementUnits on macOS,
Region ▸ Measurement system (iMeasure) on Windows, then LC_ALL /
LC_MEASUREMENT / LANG; inches for US, LR and MM, millimetres elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Issue #432: macOS falls back to AppleLocale when the measurement system was never set

AppleMeasurementUnits only exists in the global domain once someone changed
Measurement system in System Settings, and apps started from the Finder get no
LANG, so a German Mac with untouched settings landed on inches. The region of
AppleLocale (always present) decides next; AppleMeasurementUnits still wins.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #432: Units labels in the Arabic and Italian catalogs new on main

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #432: the Print dialog's PDF page size follows the Units preference

Main's new "PDF page: {w} × {h} in" label was always in inches. It now uses
the preferred unit like the sheet size next to it; each catalog keeps its
wording with the size in place of the inch values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:33:54 -04:00
49217aec6c fix(theme): complete Linux System appearance tracking (#1017)
* fix(theme): complete Linux System appearance tracking

Follow up on #881 for #446 with the standard numeric appearance portal setting
and event-driven updates that wake idle windows. Keep manual modes independent,
cache their desktop updates without repainting, and cancel the worker on close.

Retain inherited native window appearance and populate both egui style branches
with the chosen palette. Preserve saved System, the Light default and unknown
source fallback, and avoid desktop session access in headless UI contexts.

Assisted-by: Codex (gpt-6.1-sol)

* Desktop theme: keep the GNOME colour-scheme fallback XFCE needs

The portal tracker read only org.freedesktop.appearance, which XFCE's portal
doesn't answer (it proxies the GSettings schemas). It now also reads and
subscribes to org.gnome.desktop.interface color-scheme and uses it when the
freedesktop setting states no preference, as main's reader did. KDE answers
only the freedesktop one, which wins when it states a preference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:23:49 -04:00
cyfung1031andBrandon Thomas 881682c495 Tests: consolidate safe ui-egui integration targets (#992)
* tests: consolidate safe ui-egui integration targets

* Group the safe UI tests as tests/ui_group/ instead of turning off test discovery

With autotests = false, a test file added later builds only if someone also lists it in
Cargo.toml, so new tests could silently never run. A tests/ui_group/main.rs target groups the
same 15 files while Cargo keeps discovering every other file in tests/.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:20:29 -04:00
RidwanandBrandon Thomas 5df8f3b33a Issue #995: unlock a restricted document with its permissions password (#1127)
A PDF protected with only a permissions (owner) password opens without
asking, restricted, and nothing let the user enter that password
afterwards: the security notice only led to a read-only Security tab and
the hint said to reopen the file with the password, which the app had no
way to do (#995, #1077).

Now the security notice and Document Properties > Security offer Unlock...,
and Change settings / Remove security on a restricted document ask for the
permissions password first and then carry on, as Acrobat does. The open
password is refused there.

- crypt: SecurityHandler::authenticate_owner accepts only the owner
  password and refuses a different file key.
- cos: Document::unlock upgrades the open document in place; owner and user
  passwords unlock the same file key, so nothing is decoded again.
- engine: Session::unlock (not an edit: no undo step, not marked unsaved)
  also unlocks the undo and redo states and reopens saves as owner;
  Document::can_unlock, EditError::WrongPassword, command protect.unlock.
- ui: the permissions password prompt shares the open prompt's modal; the
  notice and Security tab get Unlock...; the disabled-command hints no
  longer point at reopening the file.
- i18n: the new strings in all 15 full catalogs (catalog hashes updated).

Tests cover crypt, cos and engine (all four algorithms, wrong and open
passwords, undo, redo, save, removing security afterwards) and the UI
(notice flow, Remove security asking first).

Closes #995
Closes #1077

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:18:01 -04:00
PiohrunandBrandon Thomas d576fe2b06 Forms: keep tiles and render unbaked field values during page re-render (#636)
* Forms: keep tiles and render unbaked field values during page re-render

In complex interactive forms, switching focus away from an edited field
caused its text to disappear for several seconds while background tiles
re-rendered.

- In DocView, retain existing tiles as stale rather than immediately
  purging them on page_changed so the canvas remains sharp without flashing.
- Prioritize visible tiles over backdrops in the render queue.
- Track unbaked form fields and paint an immediate egui widget preview
  for committed text, checkbox, and radio inputs until fresh raster
  tiles arrive.
- Scope page invalidation on form edits to pages containing the edited
  or calculated fields.
- Hoist fields lookup in recalculate_with and apply_changes to eliminate
  repeated parsing.
- Run JavaScript scripts via catch_unwind on the current thread instead of
  spawning a new thread per script invocation.
- Add contributor entry for Piohrun in contributors/people.toml.
- Add test switching_fields_keeps_committed_text_visible_immediately.

* Forms: address review feedback on tile stale tracking, hoist, and diff invalidation

- Revert crates/js to preserve dedicated 1 GiB stack thread for boa scripts.
- In DocView::page_changed, purge non-current-tag tiles and mark only current-tag tiles stale.
- Restore demand queue order in prepare_render_queue.
- Add unbaked overlay entries only on successful edit apply and only for fields whose values changed; fix font zoom calculation on 90°/270° pages.
- Re-read fields(doc) in recalculate_with only when script returns changes.
- Diff form before and after edit in apply_edit, invalidating pages with changed fields, falling back to document_changed for XFA or page-count changes.
- Add tests covering script recalculation side effects across pages, unbaked overlay cleanup on tile delivery/zoom/rejection, and checkbox recalc chaining.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:15:48 -04:00
0c1528f17d feat: Scale down large signature images instead of rejecting (#1075) (#1124)
* Fill & Sign: scale down large signature images instead of rejecting them (#1075)

A 6025 x 2506 PNG signature failed with "Image size exceeds limit": imports
were capped at 4096 px per side and 4 megapixels, and the decoder's limit
error leaked through raw. Sources up to 8192 px per side and 32 megapixels
are now decoded and scaled down to fit the old bounds; anything larger
reports the normal size message.

Reported-by: @HassanAlthaf
Co-authored-by: mjohnson7cd <[email protected]>

* A placed image too large to preview gets its own message; scale with thumbnail, which needs far less memory

---------

Co-authored-by: mjohnson7cd <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:10:03 -04:00
b642e327e7 Security: fix 10 verified audit findings in redaction, signature validation, and untrusted-input handling (#1099)
* fix(create): cap decoded size of imported PNGs

A PNG's declared dimensions sized an infallible allocation, so a few-byte file claiming huge dimensions aborted the process when the allocation failed. Cap the decoded size at the TIFF ceiling and reserve every input-sized buffer fallibly, returning CreateError::Image instead. (Security audit run-2: v3-024.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(xfa): bound total picture bytes kept during layout

Each laid-out picture kept its own copy, so one picture in a repeating subform multiplied without bound and could exhaust memory on open. Add a per-layout aggregate cap (256 MiB) that stops with XfaError::TooLarge. (Security audit run-2: v3-103.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(xfdf): bound XML nesting and protect redaction marks on import

Deeply nested XFDF/XML overflowed roxmltree's recursive parser and aborted the process; a non-recursive depth scan now rejects it before parsing. Imported comments could also replace a locked or redaction mark by /NM or add a hidden one; place() no longer replaces locked/Redact annotations and clears the hidden/invisible flags on imported redaction marks. (Security audit run-2: v3-105, v3-111.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(sign): classify post-signing changes by the signed revision's roles

A signed object's modification was classified from labels in the attacker-written new version, so relabelling a signed page /Type /Sig (or giving it /ModDate), or relabelling a drawn stream /ObjStm or /XRef, let a forged page validate as Valid even under DocMDP P=1. Decide an object's role (page content, signature, metadata, file plumbing) from what it was in the signed revision, not its new dictionary. (Security audit run-2: v3-082, v3-083.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(redact): remove redacted content from the saved file, fail closed otherwise

Apply Redactions and Remove Hidden Information left the originals behind: dropped or cleaned image/form XObjects kept their resource names (so the full rewrite saved the originals), an XFA form kept the redacted value in its datasets packet, and vertical-writing text under a mark was never hit by the horizontal removal geometry yet reported success. Prune replaced XObject names the page no longer draws, drop /XFA and /NeedsRendering when redacting an XFA form, and fail closed (RedactError::VerticalText) rather than silently leave vertical text. (Security audit run-2: v3-064, v3-067, v3-062.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(ui-egui): trust the displayed signer, not the document's embedded root

The Signatures panel's one-click 'Add to trusted certificates' trusted the top of the chain the document supplied, installing the author's self-signed root as an anchor instead of the signer shown. Trust the displayed signer certificate. (Security audit run-2: v3-098.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:09:39 -04:00
8f8e0236aa M10: Enhance camera image cleans the photo up and chooses its look (part 4 of #1008, closes #1034) (#1038)
* 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]>

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Camera level: when another edit broke the run, redo what was undone instead of warping the page again

---------

Co-authored-by: magdale76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:57:31 -04:00
durtyrobbinandBrandon Thomas f036d3841b Print: architectural paper presets and PDF scale controls (#599 follow-up) (#1123)
* M10.5: Restore architectural paper presets and PDF scale controls

Port the missing paper catalog and scale controls from PR #599 onto the merged Windows printing implementation. Keep the spooler and background discovery from #847/#1007. Document the feature-by-feature audit and verification limits.

Assisted-by: OpenAI Codex

* Drop the provenance write-up and the screenshot example (the kittest covers the dialog)

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:49:32 -04:00
Oussama Dalhi a3753c52f9 Fix text selection cursor on all tools toggle(View More - View Less Button) (#1121)
* Fix text selection cursor on all tools toggle

* Apply Rust formatting
2026-10-11 14:46:33 -04:00
Chiranjeevi U 9644aeb9e2 Cap unbounded editing and text caches; index form fields once (#1115)
* Cap editing and text-layer caches that grew without bound

The cos editing cache kept every touched object until save, so editing
a large document pinned its whole object graph. Bound it like the scan
reader: 1024 objects / 32 MB of owned bytes, 256 object streams /
32 MB, evicting to the newcomer. Returned objects outlive eviction, and
clones share the caches, so this only costs re-parsing.

The viewer text cache had the same shape: every visited page's text
stayed for the session. Retain visible, selected and matched pages plus
a 24-page prefetch window each frame; evicted pages re-extract on
revisit. Skipped while a search is active, where demand still wants
every page. Failures are never evicted: they cost nothing to remember.

cli info on a 1024-page/147 KB file (debug build, same host/command):
before 66.7 MB RSS, after 54.0 MB RSS (3 runs, tight).

Tests: editor count bound, byte bound, snapshot-clone reads (cos);
evict-far/keep-visible+matched+selection, search-active keep-all
(canvas). All mutation-checked: each bound goes red when disabled.

Signed-off-by: Chiranjeevi U <[email protected]>

* Index form fields once per refresh instead of once per field

The form-scope refresh ran a linear find over all fields for every
field, O(F^2) on each keystroke; forms reach 10k fields. Build one
name index up front, O(F). entry().or_insert() keeps the old
first-match semantics for repeated field names.

New test with two same-named widgets pins first-wins and goes red
under insert. 141 engine tests green, clippy warnings and perf lints
clean.

Signed-off-by: Chiranjeevi U <[email protected]>

---------

Signed-off-by: Chiranjeevi U <[email protected]>
2026-10-11 14:46:19 -04:00
Nicholas Yoannou 92c9de7aad Comments: line breaks saved as CR show in comment text (#1113)
Acrobat saves the line breaks in a comment's /Contents as CR, e.g.
"first line\r\rsecond paragraph". egui draws CR as nothing and only breaks
lines at LF, so the comments panel, the hover popup and the edit box ran
the paragraphs together ("first linesecond paragraph").

Both readers of comment text (pdfcraft_render::inspect on open and
pdfcraft_annot::summaries after an edit) now turn CRLF and CR into LF.

Regression tests: carriage_returns_in_comment_text_become_line_breaks in
annot and render (both fail before this change).
2026-10-11 14:46:05 -04:00
jcl-bitandClaude Opus 5.5 0e8c978c6a M10: Windows printing starts PowerShell by its full path (#1106)
`powershell_command` passed the bare name "powershell" to
`std::process::Command::new`. On Windows, Rust resolves a bare name in the
directory of the running executable before System32
(https://doc.rust-lang.org/std/process/struct.Command.html, "Platform-specific
behavior"), so a powershell.exe sitting beside a portable pdfcraft.exe ran
instead of Windows PowerShell, for every printer list and every print job.
Seen in Windows Sandbox against bbbe600 with a harmless stand-in exe: the
Print dialog listed the stand-in's output as the printer and Print "sent" to
it.

Name it by its full path under %SystemRoot%, the way `open_printer_preferences`
already names rundll32 in the same file. The same Sandbox test against this
build: the stand-in beside the app never runs, the real printer is listed and
the job spools.

Regression test: `windows_starts_powershell_by_its_full_path` (red with the
bare name, green with the fix). Not in any release yet: #847 is on main only.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:45:52 -04:00
9564ed6885 Render visible pages before thumbnails and text extraction (#950)
* Linux: the tab strip carries the window controls, as in Acrobat

On X11 and Wayland the window manager drew its own title bar above the tab
strip, so every PdfCraft window had two bars and the controls the user clicked
were the window manager's, not ours. `ViewportBuilder::with_decorations(false)`
turns the frame off, and `chrome::window_frame` draws minimize, maximize and
close at the top right of the tab strip, plus a hairline border and resizing
from the edges and corners, so a frameless window behaves like a framed one.
Close still goes through the unsaved-changes check, and full screen and the
in-app full-screen page view draw no controls over the page.

Drawing it last keeps the controls reachable from every overlay that covers
the tab strip. `PDFCRAFT_SYSTEM_TITLEBAR=1` puts the window manager's frame
back for anyone who wants it.

* Render visible pages before thumbnails and text extraction

Opening a document queued its visible page pixels together with
Pages-panel thumbnails and background text extraction, so the render
workers spent the first seconds on background work while the visible
page stayed blank. Hold thumbnails and text until no visible main-page
raster is still missing; errored pages never block.

* Render visible pages before thumbnails and text extraction

Opening a document queued its visible page pixels together with
Pages-panel thumbnails and background text extraction, so the render
workers spent the first seconds on background work while the visible
page stayed blank. Hold thumbnails and text until no visible main-page
raster is still missing; errored pages never block.

---------

Co-authored-by: GyuriX <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:45:36 -04:00
MarekSpodBiedryandBrandon Thomas 8983f05c78 On-page text boxes keep a dark caret on their white backgrounds in the dark theme (#655) (#734)
* Text editor on the page keeps a dark caret on its white box in the dark theme (#655)

* Form fields, typed fill text and added text keep a dark caret on their white boxes in the dark theme (#655)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:42:14 -04:00
Dylan M. TaylorandBrandon Thomas 2ec7102035 chore: bump github actions to fix Node 20 deprecation warnings (#641)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:39:33 -04:00
Nigel Ihza FirdausyandBrandon Thomas a3d30d5866 fix(ui): prevent translated tool labels from overflowing and align German terminology (#939)
* fix(ui): bound translated tool labels and polish German terminology (#828)

* style(ui): keep bounded-label layout readable

* German: keep „Ausfüllen und unterschreiben“ (Fill & Sign isn't a certificate signature) and „Für Barrierefreiheit vorbereiten“

* Refresh catalog hashes

* Test: find the German Fill & Sign heading whole or elided

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:36:41 -04:00
21d5323132 Add faux-redaction audit (paint-order and colour-aware, fail-closed) (#110)
* Rework faux-redaction audit per maintainer review

- Paint-order aware: a rectangle counts as a cover only when painted
  strictly after the text (positions from the joined content stream).
- Colour-aware: WCAG contrast between text and cover must be below 3.0,
  so page backgrounds, table shading and highlights are not flagged.
- Fail closed: audit_redactions returns Err on undecodable content
  streams (decoded_strict) and malformed /Annots; the operator bound is
  an error, not a silent truncation.
- Graphics state carries across a page's content streams (one joined
  walk); form XObjects still isolate their state at Do.
- Audit-only: the sanitize-for-sharing half is dropped (upstream #162).
- Parity entry marked partial, not shipped.

* Audit: register its layer, add a README, fail closed past 32 nested forms, document what it doesn't check

---------

Co-authored-by: Erick Barraza <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:32:33 -04:00
Nigel Ihza FirdausyandBrandon Thomas 0059ed657c fix(annot): validate finite link areas before add or edit (#940)
* fix(annot): validate finite link areas before add or edit

* Test: find the edited link by its /Annots index, not its position in the list

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:31:59 -04:00
pauliusiv123-rgbandBrandon Thomas 57c9e3a9b4 Issue #431: reload a document when its file changes on disk (#459)
* Engine: reload a document from new bytes, keeping its id

* Issue #431: reload a document when its file changes on disk

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:31:32 -04:00
LucasandBrandon Thomas e4c9cc1a19 Open dialog: allow selecting several files at once (#1086)
* Open dialog: allow selecting several files at once

* add contribution in people.toml

* people.toml: restore arcanii and atoslins (lost in a merge); key the new entry by GitHub login

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:28:42 -04:00
42cfc6a8f2 M14: scan PdfCraft page-content markers once per document refresh (#1016)
* M14: Scan PdfCraft page-content markers once on open

Combine the engine-open marks and added-content discovery passes while keeping the existing public scans and document state unchanged. Add scanner equivalence and engine-open coverage, and retain the local Clippy binding needed for the workspace gate.

Co-authored-by: Codex <[email protected]>

* M14: reuse the combined marker scan on full refresh

Keep full refresh from repeating the separate marks and added-content traversals, with a mark_saved regression against the independent scans.

Co-authored-by: Codex <[email protected]>

* M14: scan marker metadata without cloning content containers

Borrow direct content objects and keep referenced objects alive during marker inspection. This avoids copying /Contents arrays and /PCAdded dictionaries for the combined read-only scan while preserving one-reference resolution and content-object identity.

Co-authored-by: Codex <[email protected]>

* M14: record combined marker scan measurements

Update the #307 progress entry with full-refresh reuse, the borrowed scan implementation, and exact before/after timing and memory measurements.

Co-authored-by: Codex <[email protected]>

* M14: align marker measurements with final benchmark run

Replace the preliminary values with the final exact-revision alternating-run medians and corresponding pair counts.

Co-authored-by: Codex <[email protected]>

* marks_present and list_added read through scan_markers, so the parsing lives in one place

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:08:27 -04:00
krinryandBrandon Thomas b90875466e Select tool: trigger multi-click text selection on pointer press (#1024) (#1040)
* Select tool: trigger multi-click text selection on pointer press (#1024)

- Detect consecutive primary pointer presses on page text using press timing
  and distance thresholds, highlighting words, lines, or the whole page
  immediately on the down-stroke rather than waiting for pointer release.
- Keep selection intact on release for multi-clicks while allowing single clicks
  to clear or shift-extend.
- Add regression test `multiclick_selects_on_pointer_press_before_release` in
  `crates/ui-egui/tests/text_select.rs`.
- Credit krinry in contributors/people.toml.

* Count presses with egui's own double-click delay and distance

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:05:12 -04:00
cyfung1031andBrandon Thomas 575f99f9be Add an opt-in low-disk Cargo profile (#951)
* PR-02: add opt-in low-disk Cargo profile

* PR-02: document low-disk Cargo profile

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:02:06 -04:00
134b98b0a8 M4: open Organize Pages as a selectable thumbnail overview (#1004)
* 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]>
2026-10-11 13:58:58 -04:00
1651069e7a Viewer: Continuous scrolling is an independent checkbox over the base layout (#752) (#896)
* 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]>
2026-10-11 13:54:26 -04:00
Joel TonyandBrandon Thomas acda408192 Issue #568: honour the document's copy permission (Table 22, bit 5) (#914)
* 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]>
2026-10-11 13:51:10 -04:00
Justin WilkinsandBrandon Thomas 56c034b0b3 M9: visible digital signatures can show a drawn, typed or image graphic (#193) (#934)
* 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]>
2026-10-11 13:47:51 -04:00
Wladmir JúniorandBrandon Thomas 626724bbdd pt-BR: lowercase month names in the date picker (#1089)
* pt-BR: lowercase month names in the date picker

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:44:40 -04:00
473d5d48d4 M2: overlapping text runs stay whole; placement in arrange, bounded checks (#748)
* 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]>
2026-10-11 13:41:15 -04:00
pkibiegoandBrandon Thomas adad3aae2c xfa, xfdf: refuse XML nested past 1024 levels instead of aborting the process (#987)
* 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]>
2026-10-11 13:38:10 -04:00
6ed0e66848 render: don't let a hostile page-tree /Count abort inspection (#956)
* 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]>
2026-10-11 13:33:02 -04:00
Omar EldersandBrandon Thomas bed9a48ccd fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056) (#1069)
* fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056)

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:32:38 -04:00
盈秋andClaude Opus 5.5 6f96c40eaa Chinese UI: draw with an installed Chinese face when the build has none (#826) (#925)
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]>
2026-10-11 13:32:14 -04:00
art804 d47c615a4c Windows: Save asks where to keep an Outlook attachment instead of writing its temporary copy (#601)
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.
2026-10-11 13:32:00 -04:00
cyfung1031andClaude Haiku 5.5 f08d6b17ab M12: add deterministic snap property tests to the measure crate (#714)
* 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]>
2026-10-11 13:31:46 -04:00
PurpleDaddyandClaude Opus 5.5 12346180c4 docs: register the MCP server from an installed release (#643)
* 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]>
2026-10-11 13:31:32 -04:00
dce9b9f912 M2: keep non-interpolated minified images sharp (#635)
* 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]>
2026-10-11 13:31:18 -04:00
mjohnson7cd dda1a155fd docs(readme): fetch the OCR models in Get started (refs storytold/artcraft#2104) (#1006) 2026-10-11 13:28:42 -04:00
mitul72andBrandon Thomas 657173fac1 M14: a chosen interface size is absolute, as in PhotoCraft (#1005)
"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]>
2026-10-11 13:28:30 -04:00
4582f48ec1 automation: text_find stops reading pages once its limit is reached (#971) (#990)
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]>
2026-10-11 13:24:58 -04:00
cyfung1031andClaude Haiku 5.5 be94112c8e M11: strip redundant ASCII stream wrappers during optimization (#989)
* 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]>
2026-10-11 13:21:20 -04:00
cyfung1031andClaude Haiku 5.5 d818f72a57 M10: stream Create Multiple sources without a fixed file-count cutoff (#978)
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]>
2026-10-11 13:20:58 -04:00
9960e31b72 M9: sign with CAC/PIV smart cards through the macOS Keychain (#968)
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]>
2026-10-11 13:20:44 -04:00
cyfung1031andClaude Haiku 5.5 0af2fdad05 M7: shard Arabic generated glyph fonts instead of rejecting large text (#967)
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]>
2026-10-11 13:20:30 -04:00
cc344a26de Typed signatures draw Cyrillic and Greek letters (#846) (#963)
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]>
2026-10-11 13:20:11 -04:00
cyfung1031andClaude Haiku 5.5 fb3fa5de57 M7: shard Japanese Type 3 fallback fonts across large edits (#958)
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]>
2026-10-11 13:16:25 -04:00
pechnikov2026-starandpechnikov2026-star 66edead348 demo-pdf: two fixes so xtask demo-pdf and xtask screenshots work on Windows (#954)
* 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]>
2026-10-11 13:16:12 -04:00
Nigel Ihza FirdausyandBrandon Thomas 43d16ef315 fix(measure): include closing edges of every filled PDF subpath (#952)
* 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]>
2026-10-11 13:16:00 -04:00
pauliusiv123-rgb a4742ba911 Issue #948: the root-confinement test tries the symbolic-link escape only when the link was made (#949) 2026-10-11 13:15:39 -04:00