Commit Graph

397 Commits

Author SHA1 Message Date
Maksim Popov 4995da0653
i18n: add Russian (ru) interface translation (#375)
* i18n: add Russian (ru) interface translation

- Add ru.tsv catalog (~1950 entries, clean-room translations following PhotoCraft terminology: Слой, Образцы, Обработка контуров, Монтажная область, Обводка, Заливка)
- Register ru in LANGUAGES with plural_russian (one/few/many with 11-14 exception)
- Add Russian tests: registration, command/catalogue coverage, UI literals, history/diagnostics, plural forms
- Add ATTRIBUTION.toml entry for ru.tsv
- Update docs/localization.md, ROADMAP.md with Russian language
- All i18n tests pass, cargo fmt and clippy clean

* ru: translate labels added on main since the catalog was written; Russian in the Latin-font coverage test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ru: '>' in the JavaScript-off hint, as the other catalogs (Inter has no ▸)

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>
2026-10-08 19:24:51 -04:00
sanyamsingh001 dcf81b4507
UI-POLISH: fix toast wrapping and Linux shortcut hint (#376)
Keep notices within the viewport and let short feedback recover its natural
width. Format the toolbar shortcut for the current platform.

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>
2026-10-08 19:10:13 -04:00
cyfung1031 5ef27ff678
M5: ink strokes are drawn as smooth curves through their points (#374)
#260 (page 7) compares PdfCraft with Acrobat, which draws an Ink
annotation's /InkList as a smooth curve through the points;
pdfcraft-annot drew straight segments between them (as MuPDF, Poppler
and PDFium do). A stroke of three or more points is now drawn as a
Catmull-Rom spline through every point, written as cubic Béziers: the
curve leaves each point parallel to the chord between its neighbours,
and an end uses itself as the missing neighbour. Two points stay a
line and one point a dot. This is the appearance PdfCraft writes for
its own ink, signatures drawn with Fill & Sign included, and, with the
display-only appearances for comments that have none, how ink without
/AP is shown.

A curve can swing beyond its points, so a new ink annotation's /Rect
now also covers the curve's control points (the curve stays within
them); before, only the points plus the line-width margin.

Test: annot tests::ink_strokes_are_smooth_curves_through_their_points
checks the exact curve for a three-point stroke, a line for two points,
a dot for one, and a /Rect that holds a stroke whose curve rises above
its highest point (22 on main, 23.667 now). The comment.pencil parity
entry cites it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 18:58:17 -04:00
cyfung1031 4a50222258
Forms: round required-field border on radio buttons; Highlight fields is remembered (#372)
* Forms: round required-field border on radio buttons; Highlight fields is remembered

#260 (page 8) compares PdfCraft with Acrobat, which outlines required
checkboxes and radio buttons in red. Adobe's documentation says that
border shows "when the Highlight Existing Fields button is selected",
which PdfCraft already did (View: Highlight fields). Two differences
remained:

- The red border was a rectangle for every widget, so a round radio
  button got a square around its corners; Acrobat's follows the button.
  Radio buttons now get a round border and tint.
- Acrobat keeps Highlight existing fields as a preference, while
  PdfCraft forgot it for every document. The choice is now remembered
  for the next document and the next session (ViewDefaults and
  settings). It still defaults to off, and red borders still appear
  only while fields are highlighted.

Tests (view.rs): required_radio_buttons_get_a_round_red_border (the
widget's corner, outside the circle, is not red; on main it is) and
field_highlighting_is_remembered (a second document and a restored
session open highlighted; on main the second document didn't). The
form.highlight-fields parity entry cites both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* view.rs: the field-highlight tests sit with the others (no merge conflict with #363)

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>
2026-10-08 18:49:26 -04:00
mlkonrad 2a35a1a76f
M11: Optimize PDF and Reduce File Size run in the background with a progress bar (#370)
* M11: Optimize PDF and Reduce File Size run in the background with a progress bar

Advanced optimization and Reduce File Size did all their work on the UI
thread, so on a large file the window stopped repainting until they were
done and looked as if it had crashed.

- pdfcraft-optimize: optimize_with_progress reports each image and the
  clean-up; returning false stops with OptimizeError::Cancelled.
- engine: Session::optimize_job captures a copy of the document and the
  choices; OptimizeJob::run reports each OptimizeStage (removing user data,
  images, cleaning up, merging, writing), stops on request with
  EditError::Cancelled and turns a panic into an error so a worker always
  reports back. optimized_bytes is the job run in place.
- ui: both commands run the job on a worker thread (inline in tests and on
  the web), like OCR and Export. A card at the bottom centre shows the
  stage ("Optimizing... image 14 of 40"), a bar that eases towards the
  fraction with a light sweeping across it, the percentage and Cancel;
  notices stack above it. A second run is refused while one is going. The
  control channel's state includes the progress.

The headless doc_optimize and doc_reduce tools stay synchronous. New
strings are translated for es, fr, ja, zh-hans, zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clippy: as_chunks in the signature image background test (follow-up to #355)

#360 switched the image test to as_chunks; #355 added another chunks_exact(4)
in the background test, which clippy's chunks_exact_to_as_chunks rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Optimize: report a failed worker spawn instead of waiting forever

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>
2026-10-08 18:46:34 -04:00
cyfung1031 23bac79756
View: page rasters map texel for texel onto the screen (sharp text at any zoom) (#363)
#260 (page 9) reports text that looks softer than in Acrobat once zoomed.
The page was rendered at the zoom's device scale rounded to 1/64
(render_scale, to spare re-renders on tiny fit-width changes), and the
texture was stretched onto the page's layout rectangle, whose corner
usually isn't on a whole physical pixel. Every line and glyph went
through linear resampling: at 87 % on a 2x display the raster was 0.32 %
too small for its rectangle, and #260's page 9 lost 7.9 % sharpness
against a direct render (1.7 % at 137 %; 0 at 150 %, which happens to
quantize exactly).

The page now renders at the exact device scale (tags resolve 1/65536 of
a scale step instead of 1/1000), each page rectangle starts on a whole
physical pixel, and a raster at the current scale is drawn at its own
pixel size from there, whole page or tile. A raster from an older scale
(mid-zoom) and the low-resolution backdrop under tiles still stretch, as
before. With the fix, page 9 on screen matches a direct render exactly
(mean difference 0.00/255 at 87 % and 137 %, was 6.30 and 2.66).

Test: view.rs::page_raster_maps_one_to_one_onto_screen_pixels renders a
page of 0.5 pt lines at 87 % with 1 and 2 pixels per point and compares
the screen with the page rendered directly at the exact scale. On main:
mean difference 69.5/255, max 216. Now: under 1, max under 16.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 18:23:45 -04:00
cyfung1031 a747f2521f
M2.6: W/W* clip after whichever path-painting operator ends the path (#362)
ISO 32000-2 §8.5.4: W and W* mark the current path to become the clip
once the path-painting operator that ends it has painted it with the old
clip, and n is only one such operator. The vendored interpreter applied
the clip in n alone. After S, s, f, F, f*, B, B*, b or b* it was never
applied, so everything painted afterwards ignored it, and it stayed
pending until some later, unrelated n (even after Q) clipped content
that should show. Issue #260's test file uses `W* S` on pages 3, 5 and
9: a shading meant for a circle flooded page 3, an image meant for a
rounded rectangle and nested clips on 5 and 9 spilled out.

Every painting operator now applies a pending clip after painting and
clears it (apply_pending_clip, shared with n). The now unused
Context::clip getter is removed.

Test: raster::tests::clipping_path_applies_after_any_painting_operator.
W and W* with each of n, S, s, f, F, f*, B, B*, b, b*: the later red fill
stays inside the square, and a stroking operator's outer half still
shows outside it (painted under the old clip). A clip used by S doesn't
linger for a later n, inside or after q/Q. On main it fails at `W S`.
MuPDF, Poppler and PDFium agree on every case. Page 3 of #260's file now
differs from PDFium by 0.84/255 on average (was 164). Vendored patch
(16).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 18:17:34 -04:00
cyfung1031 620e8a2d7b
Add my contributor name (cyfung1031) (#364)
Adds my own entry to contributors/people.toml, as described in
docs/contributors.md ("Credit yourself").

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:45:33 -04:00
Sameh Elhawary f88a59f762
M10.2: Create PDF from multiple files, edited as pages in the Organize grid (#361)
* M10.2: create PDF from multiple files

Create a PDF ▸ Multiple files takes PDFs, images and .txt files, lets the
user reorder them, and makes one combined PDF (a bookmark and an optional
page range per file) or one PDF per file in a chosen folder, never
overwriting what is there.

- create: source_kind() tells PDFs, images and text apart (moved from the shell)
- engine: Session::convert_to_pdf, shared by Open, the dialog and the tool
- automation: doc_create_multiple (combine or separate)
- ui: Multiple files dialog reusing the Combine files list; translations
- parity entry and ROADMAP updated; catalog hashes refreshed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10.2: multiple files open in the page grid; insert files at any gap

Create a PDF ▸ Multiple files now opens the picked files as one unsaved
document in the Organize page grid instead of a file-list dialog: pages
are removed and reordered there, and Save pages writes what is shown.

- grid: a "+" at every gap inserts files right there; Save pages button
- insert from file: several files in order, images and text converted,
  inserted pages selected
- the file-list dialog is removed; one PDF per file stays in the
  doc_create_multiple tool
- translations, catalog hashes, parity notes and ROADMAP updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: insert files by dropping them on the page grid

On desktop, PDFs, images and .txt files dropped on the Organize page grid
are inserted at the gap under the pointer, in order, and selected; a
dropped file still opens as a document everywhere else.

winit does not report the pointer while files are dragged over the
window on every platform, so a drop waits (up to a second) for the
pointer to show up and otherwise appends at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: files dragged over the page grid follow the pointer (macOS)

winit sends no pointer moves while files are dragged over the window, so
egui kept the place the pointer entered at: the insertion bar froze there
and the files were inserted there. During a file drag the pointer is now
read from AppKit (safe objc2 calls, crates already in the tree) and fed
to egui, so the bar and a "+" badge follow the pointer and the files go
where they are released.

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>
2026-10-08 17:42:13 -04:00
MarekSpodBiedry 2f42c743b9
Issue #157: the Windows portable zip keeps settings, logs and recovery beside pdfcraft.exe (#325)
* Issue #157: the Windows portable zip keeps settings, logs and recovery beside pdfcraft.exe

The portable zip had no portable.txt, and the app had no portable mode: it always wrote %APPDATA%\PdfCraft and %LOCALAPPDATA%\PdfCraft. Now portable.txt (or PdfCraft.portable) beside the executable puts the settings, log files, crash recovery and new digital IDs in PdfCraftData next to it, as PhotoCraft does, and the zip ships portable.txt. An unwritable PdfCraftData falls back to the per-user folders and says so.

* Issue #157: update ATTRIBUTION.toml SHA-256 for the translation catalogs this PR changes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:29:59 -04:00
Devon Llewellyn acb8e96aff
Update image signatures live during resize and movement (#355)
* M5.7: update image signatures live during resize and movement

* M5.7: preserve image signature proportions when resizing corners

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:17:05 -04:00
Chiranjeevi U b5edb59d84
Clippy: as_chunks in the signature image test (follow-up to #335) (#360)
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
2026-10-08 17:11:51 -04:00
jcl-bit 69fa2ea9f8
Platform: one save-staging helper for automation and the desktop app (#356)
Follow-up to #319, from review. create_staging, staging_suffixes and
STAGING_ATTEMPTS were copied in pdfcraft-automation and pdfcraft-ui-egui,
which are both L7 and can't depend on each other. They now live once in
the new pdfcraft-platform crate (L7, std only), which layers.rs already
reserved with both sideways edges.

A pure move: the body is unchanged apart from a StagingName parameter
that keeps each caller's existing file name (.{name}.{suffix}.pdfcraft-tmp
for automation, .{name}.pdfcraft-{suffix}.tmp for the desktop save). The
doc comment on staging_suffixes now says the suffix is SipHash keyed from
the OS random source: enough that a name can't be planted in advance, but
not a CSPRNG.

New tests in the platform crate pin both name forms, the 128-byte stem
cut, the 16-name limit and refusal when no suffix is left. The callers'
planted-file, link, folder and failed-rename tests stay where they are.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:07:00 -04:00
Chiranjeevi U 05c8ab3b3e
M14: Telugu (తెలుగు) interface language (#349)
* M14: Telugu interface font

- The interface takes `Telu` faces from craft-fonts after the CJK and Arabic ones, on the web too
- Test: Telugu text has glyphs in every family and its conjuncts are shaped

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M14: Telugu interface language

- Complete te catalog: 1,984 entries plus whole-phrase Undo/Redo; te and te_IN locales use it
- Undo and Redo labels use a catalog phrase when the language has one (Telugu puts the verb last)
- Tests, docs, attribution, parity and ROADMAP

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Add my contributor name (chiranjeevi-max)

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M14: catalog contexts for labels with two meanings

- tl_ctx! gives the signature pad's Type, the Action Wizard's Start and the comment menu's Edit their own context; Telugu translates them, other languages keep their current translation
- Preferences' All tools goes through the catalog in every language

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M14: Telugu for the look-alike web address warnings

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M14: Telugu for the image signature notices

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Telugu for Open Recent (#323)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:00:25 -04:00
Oldrich333 868bd727d1
M13: `pdfcraft-cli mcp --compact` lists 12 tools and finds the rest by search (#353)
`tools/list` returns all 132 tools (109,578 bytes, ~27k tokens), which an
agent pays on every session. With `--compact` (library:
`McpServer::with_compact(true)`) it returns the 10 core tools
(`mcp::COMPACT_CORE_TOOLS`) plus `tool_search` (query / category / name
with the full schema) and `tool_call` (runs any tool through the same
`Automation::call`): 12 tools, 7,697 bytes. `tools/call` by name keeps
working for every tool; without the flag nothing changes. Same shape as
`lightcraft-cli mcp --compact`; the meta tools are not called
`*_command` because "command" already means a registry command here.

Both meta tools reject unknown outer keys and report failures as
`isError` results; `tool_call` honours `--root` exactly like a direct
call. Tests: automation integration tests (compact list, discovery,
dispatch, errors, root confinement) and a CLI test that launches the
binary with and without `--compact`.

Co-authored-by: Oldrich333 <184516902+Oldrich333@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:55:25 -04:00
Mawfyy dbe4b2dddb
Issue #290: File ▸ Open Recent lists the recent files (#323)
* Issue #290: File ▸ Open Recent lists the recent files

The Home view kept a recent-files list but the File menu had no way to
reach it; Acrobat shows File ▸ Open Recent and the request asked for
exactly the menu wiring.

A new command file.open_recent renders as a submenu of the live list
right under Open… (disabled while the list is empty); each entry opens
its file or focuses the tab already showing it, which is now shared
with the Home view's list through the new open_recent() instead of a
second copy of that check. The command palette runs it as "open the
most recent file", or says there are none. Translations added to every
catalog (Japanese, Czech, Spanish, both Chinese and Brazilian
Portuguese are test-covered).

Test: commands.rs::the_open_recent_menu_lists_files_and_opens_one
(submenu lists a seeded recent file and clicking it opens a new active
tab on that path) and commands.rs::the_open_recent_menu_is_disabled
while_the_list_is_empty. Both failed before the change (no such menu).

Co-authored-by: Mawfyy <mawfy12@gmail.com>

* Build: cap cargo jobs and lighten the dev profile

On a 7.1 GiB machine the default one-job-per-core (12) with every
dependency at opt-level 2 plus full debuginfo swap-thrashes the box
frozen mid-build. jobs = 4 bounds peak memory to a few GiB and matches
GitHub runners (4 vCPUs), so CI is unaffected; dependencies drop to
opt-level 1 with debug = false, which halves their build cost and
keeps them fast enough for a usable debug app. Own crates keep their
opt-level and full debuginfo.

Verified: a from-scratch rebuild of all dependencies peaked at ~2 GiB
used with the machine responsive throughout; the perf.rs frame-budget
tests still pass at the lower dependency optimization.

Co-authored-by: Mawfyy <mawfy12@gmail.com>

* Keep the build configuration as on main; French for Open Recent

The jobs and dependency opt-level changes are a separate build-policy decision.
French is coverage-checked since #291; catalog checksums refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* French for Open Recent again, and catalog checksums refreshed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mawfyy <mawfy12@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:46:22 -04:00
Devon Llewellyn e17833a64e
Add image signatures with placement preview and resize controls (#335)
* M5.7: import images as Fill & Sign signatures and initials

* M5.7: preview image signatures and select after placement
2026-10-08 16:36:26 -04:00
Mawfyy f540035db3
Issue #124: a long page label no longer overlaps its bookmark title (#328)
* Issue #124: a long page label no longer overlaps its bookmark title

The Bookmarks panel painted the destination's page label right-aligned
with no width limit, while the title's wrap width reserved a fixed 36
px for it. Drawing tools put whole section titles in /PageLabels (the
reporter's file uses "Floor Plans (001 Floor Plans): SITE PLAN" as both
title and label), so the label painted straight over the title.

The label now gets a bounded column (72 px) and is ellipsized on
character boundaries by a pure ellipsized_prefix helper; the title
wraps to the width the column really takes. Short labels ("3", "iv",
"1-1") are unchanged, and a label with no page still shows nothing.

Test: panels.rs::label_column (short labels untouched, long labels cut
to the column with an ellipsis and stay a prefix of the real label,
CJK cut on char boundaries, the loop ends when nothing fits). The
overlap itself is verified against the reporter's file (headless shot
of the Bookmarks panel).

Co-authored-by: Mawfyy <mawfy12@gmail.com>

* Bookmark labels: measure at most 64 characters of a page label

A page label is document text; ellipsizing a huge one character by character laid out
thousands of candidates per row per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mawfyy <mawfy12@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:31:31 -04:00
jcl-bit bb8fcd006e
Security: refuse mailto attachments, show link hosts in punycode (follow-up to #104) (#337)
* Security: refuse email links that attach or insert a local file

Follow-up to #104. A document's mailto: link could ask the email app to
attach a file from the user's computer (Evolution, KMail and Claws Mail
honour attach=, Evolution and KMail also attachment=) or read one into
the body (Claws Mail's insert=).

document_url now refuses a mailto: address in which any word starting
with attach or insert (any case) is followed by =, wherever it stands
and after each layer of percent-decoding. It also refuses text that
some email tools decode before reading the address: RFC 2047 encoded
words, which KMail decodes across the whole address, and backslash
escapes, which xdg-email's echo decodes. The link is refused rather
than rewritten: PdfCraft doesn't change what a document asks to open,
and the email apps' parsers disagree with one another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: show a link's host as the browser receives it, in punycode

Follow-up to #104. The link prompt's host now comes from
links::display_host, which reads it with url::Url: the webbrowser crate
hands the OS that crate's serialisation, and the web build's browser
parses by the same WHATWG standard. International hosts are shown in
punycode with a note, a label mixing writing systems (UTS #39 resolved
script sets) gets a red warning, numeric hosts such as 3232235777 show
as 192.168.1.1, and a host the browser would reject is refused as
malformed. The Unicode form isn't repeated in the prompt, so a
whole-script lookalike can't read as the real site.

url, idna and icu_properties become direct dependencies of
pdfcraft-engine; all three were already in the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: refuse document addresses the URL parser rejects

Follow-up to #104. egui opens links through the webbrowser crate, which
parses the address with url::Url and, when that fails, treats it as a
local file path and opens file:///<cwd>/<address> in the browser.
document_url accepted addresses that don't parse, such as
mailto://a^b/x or https://a^b/ (a forbidden host code point), so a
document could still get the browser to open a local path.

document_url now also requires url::Url to accept the address with the
same scheme, which is exactly the parse webbrowser does first.

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>
2026-10-08 16:07:25 -04:00
jcl-bit 130b7ea06c
Security: print jobs, new digital IDs and the --control file no longer write through planted links (#338)
* Security: pipe print jobs to lp instead of a shared temp folder

On macOS, Linux and FreeBSD, Print wrote each job to
<temp>/pdfcraft-print-<pid>/job-<time>.pdf and passed that name to lp.
The folder name is predictable and was made with create_dir_all, which
accepts a folder another local user created first in a shared /tmp and
follows a symlink planted there; the job was then written with fs::write.
That user could read every printed document, or swap the file before lp
read it. The folder was also never removed.

The job now goes to lp on stdin, so nothing is written to disk. lp_args
no longer takes a file. The job is fed on its own thread while lp's
output is collected, so neither side can block on a full pipe; if that
thread can't be started, lp is stopped and an error returned rather than
a panic. When lp refuses a job before reading it (an unknown printer)
the user sees lp's message rather than a broken pipe. CUPS ends its reply
for a job read from stdin with "(0 file(s))", which reads as if nothing
was sent, so that is dropped from the message shown.

submit_via takes the spooler command so tests can run this test binary
as a stand-in lp on every platform; the new test failed on the old code
(it received the temp file as an argument and nothing on stdin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: create a new digital ID file fresh, owner-only

Configure New Digital ID > Create picked the first free name with
`!p.exists()` and then wrote the .p12 with fs::write. That is
check-then-write: a file or link planted at the name between the two is
written through. exists() also follows links, so a dangling symlink
looked free and the private key was created wherever it pointed. The
file got default permissions, readable by other local users on most
Unix systems.

save_new_id_file now creates the file with create_new, never opening
anything already at the name, and with mode 0o600 on Unix. A taken name
moves on to the next ("<name> 2.p12", ...), including a folder, which
Windows reports as access denied rather than already exists. A failed
write removes the partial file.

The dangling-link test failed on the old code (the key was written
through the link); the hard-link and folder test guards the retry loop,
and a Unix-only test checks the 0o600 mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: never write the --control token through a planted link

write_control_file opened the --control path with create + truncate. A
symlink or hard link already at that path was followed: its target was
truncated and overwritten with the control token before set_permissions
ran, so the token could land in a file another user can read, and any
file the link pointed at was destroyed.

The JSON now goes to a fresh file next to the path, with a random name
(create_new, 0o600 on Unix, another name if one is taken, including a
folder on Windows), which is then renamed over the path. The rename
replaces a link instead of following it, a control file left by an
earlier run is still replaced as before, and a reader polling for the
file never sees it half-written. A failed write or rename removes the
temporary file.

The hard-link and symlink tests failed on the old code (the token was
written into the linked file); a third test covers replacing a previous
run's file, and the owner-only mode on Unix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: credit jcl-bit as James Locke

My own entry in contributors/people.toml (AGENTS.md, Contributor
credits), added in my PR as asked there.

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>
2026-10-08 16:04:48 -04:00
Fazmin Nizam a2d92daa00
Issue #263: combine, extract and split keep the PDF/X output intent and identification (#334)
Combine, extract and split built the new document from copies of the
pages and carried bookmarks, fields, layers, attachments and four Info
keys, but not the catalog's /OutputIntents, the GTS_PDFXVersion,
GTS_PDFXConformance and Trapped Info entries, or the XMP pdfxid
identification. A PDF/X file stopped being one, and a RIP or preflight
lost the intended output condition.

Extract and split now carry the output intents (with their ICC
profiles, through the same object map as the pages), the Info entries
(the version read from Info, else XMP) and a new XMP packet holding the
identification, trapping and title. The source's packet is not copied:
it can claim standards the new file no longer meets, such as PDF/UA,
whose structure tree is not copied. Combine carries the first source's
standard only when every source declares the same version, trapping
and output intents (type, condition and profile bytes).

Not yet: a warning when combine's sources disagree, and XMP dates.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:04:03 -04:00
sanyamsingh001 a21637c757
M14: reject invalid combine page selector types (#329)
Validate every doc_combine selector before reading inputs or changing output and session state. Cover invalid types and saved/reopened selection order.

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>
2026-10-08 15:55:42 -04:00
Fazmin Nizam ca9541069f
Issue #236: the Save-changes prompt shortens very long names to fit the window (#322)
Wrapping the title (#161) kept the prompt narrow, but a long enough name
still grew it taller than the window and pushed Save, Cancel and Don't
save off-screen. On the web a `?file=` URL names the document, so the
name has no length limit.

Past 80 characters the name now gives way in the middle ("start…end"),
keeping the start and the extension and version suffix; the full name
shows on hover. The new test checks the title and buttons stay inside
the desktop's minimum window (820×520) for long Latin, Thai and CJK
names and a 2000-character one.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 15:37:21 -04:00
mlkonrad 9140b44792
M4: Combine files in its own tab, with a sortable table and unlocking of protected files (#321)
* M4: Combine files in its own tab, with a sortable table and unlocking of protected files

Combine files was a modal dialog with a cramped list and up/down buttons on
every row. It is now a tab next to Home and the documents, showing a table
of the files: File name | Pages | Size | Modified | Warnings.

- Columns sort when their heading is clicked (anywhere in the cell; again to
  reverse; names in natural order), resize from their edges and move by
  dragging the heading. Order and widths are kept in the settings; a
  right-click on a heading resets them.
- Rows select like a file manager (click, Ctrl/Cmd+click, Shift+click,
  Shift+arrows, Ctrl/Cmd+A). One toolbar moves, removes and unlocks the
  selection; Alt+arrows and dragging move it too. Every change to the list
  undoes and redoes with Cmd/Ctrl+Z (also toolbar buttons).
- Add files, Add folder, Add folder and subfolders (a split button), Add open
  documents, or drop files and folders on the tab.
- Problems show before Combine: password-protected files, security settings
  that withhold page assembly, page ranges (checked as typed, with the pages
  they take), duplicates. Files that open but are protected say that the
  combined file won't keep their security; the result is never encrypted.
- Unlock... asks for a file's open or permissions password (one password can
  be tried on several files). Passwords stay in memory, are never printed,
  and reach the engine through combine_unlocked; doc_combine takes them as
  `passwords`. The engine decides whether a password works, since the
  inspector rejects some it accepts (RC4 owner passwords).

organize.combine-sort is shipped and organize.combine-options partial
(include open files). Strings are translated for es, fr, ja, zh-hans, zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit Marlon Konrad (mlkonrad) in the About window

Adds my own entry to contributors/people.toml, as docs/contributors.md asks.

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>
2026-10-08 15:37:10 -04:00
jcl-bit f70c4caac9
Security: create save staging files with create_new (follow-up to #164) (#319)
* Security: create the --root staging file with create_new

Follow-up to #164. write_atomic staged every save at the fixed name
.<name>.pdfcraft-tmp and wrote it with fs::write, which follows a
symbolic link and writes through a hard link. So a link planted there,
in a folder someone else can write to, redirected the bytes to any file
the link pointed at.

The staging file now gets a random 64-bit suffix (std's RandomState,
keyed from the operating system's random source) and is opened with
OpenOptions::create_new, which fails if anything already has the name
(a file, a hard link, a symbolic link even when dangling), on Windows
(CREATE_NEW) as on Unix (O_EXCL). A taken name is skipped, never
opened; Windows reports a folder at the name as "access denied", which
counts as taken when something is there. After 16 taken names the save
fails and nothing is overwritten. A failed write or rename removes only
the file this save created. At most 128 bytes of the target's name go
into the staging name, so it fits every system's name limit.

The #164 tests that planted a sentinel at the old fixed name now also
compare folder listings, and say that the "is a folder" refusal is what
they check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: create the desktop app's save staging file with create_new

The same fix as the previous commit, for the desktop app.
editing::write_atomically, which Save, Save As, Export, Print to PDF,
signing and crash recovery all use, staged at
.<name>.pdfcraft-<pid>.tmp and opened it with File::create, which
follows a symbolic link and writes through a hard link. A process id is
easy to guess, so a link planted beside a document in a shared folder
could redirect a save into another file.

The staging file now gets a random 64-bit suffix and is opened with
OpenOptions::create_new; a taken name (on Windows, a folder reports
"access denied") is skipped, never opened, and after 16 the save fails
without overwriting anything. At most 128 bytes of the document's name
go into the staging name.

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>
2026-10-08 15:19:16 -04:00
才倾梦华V d801407a9f
Windows MSI: reject per-user overrides and retain progress text (#305) (#310)
* #305: harden Windows MSI deployment and progress UI

Co-authored-by: Codex <noreply@openai.com>

* Keep the MSI manufacturer as Learning Machines LLC

The company name matches the Linux packages; changing it is the owner's call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #305: keep compiled-MSI checks compatible with PowerShell COM

Validated the compiled x64 MSI and negative scope, publisher and progress mutations.

Co-authored-by: Codex <noreply@openai.com>

* ROADMAP: the publisher name is unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:40:30 -04:00
Flowershangfromthebranches 7458f7d47d
M14: complete Simplified Chinese About credits and multiline guidance (#311)
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 13:39:40 -04:00
midasdf 81a6ca456c
M7: preserve ideographic spaces in edited paragraphs (#309)
Co-authored-by: Codex <noreply@openai.com>
2026-10-08 13:29:15 -04:00
ysrdevs 87f635cf65
XFA: run FormCalc scripts (#301)
* XFA: run FormCalc scripts

A FormCalc interpreter (XFA 3.3 part 8) in the js crate, over the same
form table, effects and budgets as the XFA JavaScript model, so the
engine runs both languages alike: expressions and FormCalc coercions,
var, if/while/for/foreach/func, accessors ($, $host, $form, $record,
$layout, $event, xfa., .., [n], [*], _name instance managers) and the
arithmetic, logical, string, date/time, financial and unit built-ins.
Get/Post/Put refuse.

The JavaScript model's XHost, HNode, SOM resolution and instance
operations are shared (pub(crate)) with Context-free add_instance and
remove_instance that the boa wrappers call.

Bounded like the JavaScript path: steps, loop iterations, recursion,
parse depth, string and list sizes, clamped dates, the instance cap,
and the per-activity time budget with abandonment.

Tests: language, built-ins, control flow, form objects and hostile
scripts in the js crate; a FormCalc fixture form in the engine that
initializes, calculates, validates, adds and removes rows, shows a
subform and round-trips through save; hostile scripts at the engine
level. parity form.xfa-formcalc planned -> partial.

Hardened after two adversarial review passes: statement nesting and
chain length count against the limits (verified on a 1 MiB stack), the
script honours the engine's deadline itself, strings are capped after
every built-in and messages/values clipped and noted (shared host),
lists hold each object once and the shared SOM resolver deduplicates,
values go into fields only (shared host, JavaScript too), saturating
Stuff and time arithmetic, FormCalc-specific size check instead of the
JavaScript refuse scan. Semantics: bare row[*] and row[n] from inside a
row see every row, relative [-n]/[+n] indexes, Count of non-null,
Parse with a date picture gives the ISO date, assignment is an
expression.

* form.xfa-formcalc: bound FormCalc values, results, errors and copying (#301 review)

- XHost::set_value cuts a value to 64 Ki chars before storing it, so later
  reads match what is saved and the snapshot stays bounded.
- A script's completion value (calculate result) is cut to 64 Ki chars with a
  note, in both the FormCalc and the JavaScript paths.
- throw clips its value like any message.
- Copying, dereferencing and comparing long strings is charged to the step
  budget (len/256), so wasm (no clock) can't spend unbounded time on it;
  OneOf charges per comparison.
- Exists propagates step/deadline/depth/size limits, answering 0 only for
  lookup failures.
- IPmt/PPmt charge a step per period.
- The parser counts each top-level expression's chain from zero, so an
  elseif condition no longer inherits the previous arm's count.
- Comment on the interpreter's depth limit fixed; parity notes say memory
  isn't capped and Uuid/Date/Time are deterministic on wasm.

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>
2026-10-08 13:06:33 -04:00
sanyamsingh001 d770f649b4
fix(automation): validate doc_close discard_changes (M13) (#306)
Validate the optional flag for clean documents before closing them. Add
regressions for invalid types, document preservation, and valid defaults.

AI-generated by Codex (GPT-6), with GPT-6.1 Sol and GPT-6 Astra agents.

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 13:05:43 -04:00
Epsilon-LLC edce549a32
M9: sign with the Windows certificate store (#179) (#304)
List Current User Personal CNG identities alongside file digital IDs and sign
without exporting their private keys through safe rustls-cng wrappers.
Expose Windows store references through automation and the desktop dialogs,
with RSA and ECDSA validation, certificate cleanup, and UI regression coverage.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 13:02:52 -04:00
DWAA1660 9fa837e452
Issue #173: a failed `?file=` startup fetch is reported in the app (#302)
When the browser app's `?file=` URL couldn't be fetched (an HTTP 404, a
network or CORS failure), the error went only to the developer console and
the user saw the ordinary welcome screen with no sign the PDF was tried.

The fetch now also queues `(name, error)` in a new `failed_inbox` next to
the existing `inbox`, and the app reports it on the next frame with the
notice it already uses for a delivered file that won't open: "Couldn't open
missing.pdf: HTTP 404". The console line stays for developers.

Test: a_failed_startup_url_is_reported_in_the_app (pickers.rs).


Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:49:25 -04:00
Mawfyy 5ec14155ff
Issue #159: open .p12 files with trailing whitespace, PEM armour or base64 (#298)
pkcs12::open demanded clean DER at the first byte, so a file with one
extra newline, PEM armour or a bare base64 body (the shapes OpenSSL
wraps, editors and government portals emit) was refused before any
crypto ran, with "malformed data: PKCS #12: not a PKCS #12 file" and no
reason.

open now runs the bytes through normalize(): ASCII whitespace is
trimmed, "-----BEGIN" takes the base64 body between the armour markers
(byte offsets, so a marker glued to the body line works too), and a
bare base64 body is decoded — a real DER PFX always contains bytes
outside the base64 alphabet, so the fast path can't swallow one. The
exact parse reason is carried into the error instead of being dropped.

Truncated or empty files still fail, now saying why.

Test: crypto.rs::opens_wrapped_pkcs12_files_and_still_rejects_broken_ones
(a wrapped copy of rsa-aes.p12 yields the same certificate through
trailing whitespace, PEM armour and bare base64; truncated and empty
files error with the reason). It failed on all three wrapped forms
before the fix.

Co-authored-by: Mawfyy <mawfy12@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 12:15:01 -04:00
Sameh Elhawary a9209c3235
M10: select pages in the Pages panel and print the selection (#296)
* M10: select pages in the Pages panel and print the selection

The Pages panel could only jump to a page. It now picks several:
Ctrl/Cmd-click toggles a page (the first one keeps the current page
selected too), Shift-click takes a range from the last clicked page, a
plain click goes to the page and starts over, Escape clears. Picking does
not turn the page. It is the selection the Organize grid and the page
commands already use; the click rules moved to DocView::click_page and
both places call it.

Print opens on a new "Selected pages" choice when pages are picked and
prints them in page order. They are passed as positions
(print::select_listed), not as a typed range, which would read numbers as
page labels first. ui.state reports active.selected_pages.

Windows still has no printer output, so there the result is a PDF of the
selected pages. No context menu or drag in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore ROADMAP.md, emptied by accident in this PR

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>
2026-10-08 12:00:35 -04:00
pauliusiv123-rgb 22752dc915
Run set-layer-visibility actions from links and push buttons (#292)
* Layers: run set-layer-visibility actions, keeping radio-button groups

The engine can apply a set-layer-visibility action (SetOCGState,
ISO 32000-2 §12.6.4.13) to a document's layers: each ON, OFF or Toggle
in order, naming the layer by its optional content group, so a toggle
sees the state the changes before it left. With /PreserveRB (the
default), a layer turned on turns off the other layers of its
radio-button groups, now read from /OCProperties /D /RBGroups; turning
one off leaves them alone. The renderer is rebuilt once per action, not
once per layer.

Like the Layers panel, this changes the view, not the document.

Tests: engine set_layer_actions_toggle_in_order_and_keep_radio_groups
and set_layer_actions_change_what_renders (pixels before and after),
render radio_button_layer_groups_are_read.

* Links: run set-layer-visibility actions

Clicking a link whose action is SetOCGState showed "SetOCGState actions
run in the JavaScript engine (M6)" and did nothing. Links now read the
action (/State, also when indirect, and /PreserveRB) and clicking runs
it; the Layers panel follows. Hovering shows "Set layer visibility"
(translated in the ja, es, zh-hans and zh-hant catalogs), and doc_info
describes the action in its link list.

Tests: render links_read_set_layer_actions, automation
doc_info_describes_set_layer_links and ui-egui
links_set_layer_visibility.

* Forms: push buttons run set-layer-visibility actions

A push button whose mouse-up action is SetOCGState counted as having no
action, so clicking it did nothing. Buttons now read the action as
links do, and clicking runs it.

Tests: forms push_buttons_read_set_layer_actions and ui-egui
push_buttons_set_layer_visibility.
parity: edit.action-set-ocg-state is partial. The notes say what is
missing: creating the action, bookmarks and other triggers, /Next
chains, JavaScript ocg.state, and the Layers panel keeping radio-button
groups itself.

* French: Set layer visibility

The French catalog is coverage-checked since #291.

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>
2026-10-08 11:27:10 -04:00
iMhack cd8c753527
M14: French interface catalog (#291)
Add Français (fr) as a complete UI catalog and select it for every
French locale. Preferences lists it, and Auto follows fr, fr_FR,
fr_CA and fr_BE. About credits labels now go through the catalog.

Coverage tests check commands, All tools labels and every tl! literal.
The bundled Latin faces cover the catalog.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: iMhack <iMhack87@users.noreply.github.com>
2026-10-08 11:16:30 -04:00
midasdf 1b62d47fa7
M3: find text in mixed horizontal and vertical PDFs (#287)
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 10:49:56 -04:00
Sameh Elhawary d30bb111db
M14: Arabic and Hebrew file names render in the tab strip, recent files and Properties (#285)
A tab named in Arabic showed replacement boxes: no interface font had the
glyphs, and egui joins the letters of a right-to-left word but places the
runs in logical order.

- Fonts: the interface takes `Arab` faces from the craft-fonts build input
  (also on wasm), and on desktop adds one font already installed on the
  machine as the last fallback of every family. It is read at runtime, never
  embedded; PDFCRAFT_SYSTEM_FONTS=0 turns it off and `cargo xtask screenshots`
  sets that. AGENTS.md §1.4 records the rule.
- Order: bidi::visual puts single-line text in display order with a
  left-to-right base, used for the painted tab label and hover text, the
  recent-files name and path, and the Properties File and Location rows.
  Accessibility names keep the logical text.

misc.rtl-ui stays planned: multi-line text, text input and a mirrored layout
are not done.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:58 -04:00
wstock 879d5174c2
V, H and Space pick the Select and Hand tools (#281)
The quick toolbar's tooltips read "Select (V)" and "Hand (H)", but
nothing listened for either key. V now selects, H picks the Hand, and
holding Space pans with the Hand until it is released, as in Acrobat.
They are plain letters, so not while a text field, a form field, a
dialog or the palette has the keyboard, and not with a modifier held.

The shortcuts dialog lists them (reusing the translated tooltips) and
the view.hand-tool parity entry cites the new test.


Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:20:04 -04:00
Hal Goff 9f2188bf7e
Issue #166: Save, Print and Export include the text still being typed in a form field (#278)
* Issue #166: Save, Print and Export include the text still being typed in a form field

A field's editor kept its text in a draft until Enter, Tab or clicking away,
and ⌘S (which runs while a field has focus) saved the document first. Typing
also didn't count as unsaved, so closing or quitting didn't ask.

- commit_form_typing(): apply the tab's queued edit, then commit the open
  field's draft; if the field refuses the value, keep the editor and text
  and stop. Called where the document is read: Save, Save As (and its later
  callback), Print, Export, Reduce/Optimize, Sign, Extract, Split, Copy
  pages, Flatten, button scripts.
- Uncommitted typing counts as unsaved work for Close, Close All and Quit;
  saving from the prompt brings that tab forward first.
- Shortcuts pressed while a form field's editor is open run one frame later,
  for that document only, so the field takes the frame's typing and Escape.
- A value refused on Enter/Tab/blur reopens the field with the text.
- Notices show in full screen too.

Co-authored-by: Hal Goff <hal@hg.studio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add my contributor name (AutoHenry)

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>
2026-10-08 10:02:18 -04:00
Brandon Thomas 96c58f6f44 Bump version to 0.4.0 (from 0.3.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:01:50 -04:00
Brandon Thomas 4dc79ccc41
README Downloads: where to get it first, then builds with Windows before macOS (#279)
Also drop "signed installers" from the thin-or-missing list: release installers are signed.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:58:57 -04:00
Brandon Thomas 7f4677dc93
Release: Flatpak bundles, AppImage zsync updates, README Downloads (#275)
* Release: Flatpak bundles (x86_64, aarch64), AppImage zsync updates, README Downloads

- AppImages embed gh-releases-zsync update information and ship a .zsync beside them;
  the Linux job installs zsync and checks both.
- New flatpak job (x86_64 on ubuntu-24.04, aarch64 on ubuntu-24.04-arm) repackages the
  Linux tarball with packaging/linux/flatpak-bundle.sh and the new bundle manifest, then
  smoke-tests it in the sandbox. finish-args shared with the Flathub manifest (packaging-lint
  checks they match): Wayland/X11, dri, CUPS for printing, Documents and Downloads.
- Only the signing jobs and the draft-release job keep `environment: release`, so a branch
  dispatch is a dry run of everything else and can never draft a release.
- README: a Downloads section listing every build; docs/releasing.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Flatpak: no CUPS socket (the runtime has no lp); document printing as unavailable there

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:58:13 -04:00
cyfung1031 118a12a0ad
M2.6: map annotation appearance boxes onto /Rect corner to corner (#277)
ISO 32000-2 §12.5.5 maps an appearance onto its annotation with a matrix
A that takes the lower-left and upper-right corners of the transformed
appearance box to those of /Rect. The vendored interpreter scaled by
/Rect size over box size but translated by /Rect.x0 - box.x0, which is
only right when the box starts at the origin or the scale is 1. Any
other appearance was drawn shifted by (scale - 1) × box origin, partly
outside its /Rect: /BBox [10 10 30 30] in /Rect [10 10 60 40] painted
x 25..74, y 55..84 instead of x 10..59, y 60..89 (device pixels).
MuPDF, Poppler, PDFium and pdf.js all fill /Rect exactly.

The translation is now /Rect.x0 - scale × box.x0 (and likewise for y).
Boxes at the origin, and boxes as large as /Rect (PdfCraft's own
appearances use /BBox = /Rect), map exactly as before. A box that
collapses to a line still yields a non-finite matrix and is skipped.

Test: raster::tests::annotation_appearance_box_maps_onto_rect. An offset
/BBox, an offset made by /Matrix and a negative-origin /BBox now fill
exactly /Rect (on main the first is drawn at (25, 55)-(74, 84)); a box at
the origin and an offset box as large as /Rect are unchanged. Vendored
patch (15) in vendor/README.md (after (14) in #276).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:52:24 -04:00
cyfung1031 3fc8108a07
M2.6: simple-font /Widths: bounded allocation, bad entries skipped, font kept (#276)
The vendored hayro-interpret reads /FirstChar, /LastChar and /Widths of
every simple font (TrueType, Type 1, Type 3, standard 14) in read_widths.
Three defects there, each checked against MuPDF, Poppler, PDFium and
pdf.js on synthetic files:

- One entry was pushed per code below /FirstChar before any width was
  read. 50,000,000 already took 276 MB; 4294967295 asks for 34 GB, and
  a larger value aborts on allocation (AGENTS.md §4: cap allocations
  sized by the file). Simple-font codes are single bytes, so only codes
  0 to 255 now get an entry.
- An entry that isn't a number (null, a name) ended the array, so every
  later code lost its width and glyphs piled up. It now leaves just that
  code missing (/MissingWidth); the four renderers do the same.
- A /LastChar below /FirstChar, or a /FirstChar so large the count
  overflowed, made read_widths fail, so the font didn't load and a
  standard font stood in for the embedded program. The font now loads
  with no widths from the array; the four renderers keep the font too.

Valid /Widths read exactly as before, including explicit zero widths,
which all four renderers and Apple Quartz (sips) honour literally.

Tests (a Type 3 font, so no font program is needed):
raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font
fails on main for [null 600], [/W 600], /LastChar < /FirstChar and
/FirstChar 4294967295; raster::tests::huge_simple_font_first_char_terminates
renders /FirstChar 4294967295 /LastChar 4294967295 quickly (not run
against the old code: it would exhaust memory). Vendored patch (14).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:45:55 -04:00
cyfung1031 8c281b39b2
M2.6: draw Highlight annotations that have no appearance stream (#274)
A Highlight annotation (ISO 32000-2 §12.5.6.10) whose /AP is missing, as
many older and generated files have, was not drawn at all: the vendored
interpreter only draws annotations through their normal appearance.
MuPDF, Poppler and PDFium all draw such highlights.

When there is no usable /AP /N, the interpreter now fills the
/QuadPoints quadrilaterals with /C at /CA opacity, blended with
Multiply, the way pdfcraft-annot draws PdfCraft's own highlights. It
uses the interpreter's own fill path, so clipping, layers and the device
see an ordinary fill. pdfcraft-annot itself can't be reused here: render
and annot are both L3, and it builds pdfcraft-cos objects while hayro
reads hayro-syntax ones. Nothing changes when an /AP exists. Malformed
/QuadPoints (not a non-empty multiple of eight numbers) and an absent,
empty or malformed /C draw nothing, as pdfcraft-annot does. Hidden,
NoView and "Hide all comments" are checked before this point and still
apply. Only Highlight changes; the source PDF is not modified.

Test: raster::tests::highlights_without_an_appearance_are_drawn. On
main the quad stays white; now it is yellow over white and black over
black (Multiply), two quads leave their gap white, /CA 0.5 and gray /C
match MuPDF, Poppler and PDFium. An /AP still wins, and ten malformed or
hidden variants draw nothing. Vendored patch (13) in vendor/README.md;
the parity entry view.render-annotation-appearances cites the test and
notes that other comment types without /AP are still not drawn.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:23:55 -04:00
cyfung1031 7965b497ff
M2.6: skip annotation appearances whose box collapses to a line or point (#271)
ISO 32000-2 §12.5.5 maps an appearance onto its annotation by transforming
/BBox with /Matrix and scaling the resulting box onto /Rect. When /BBox has
zero width or height, or /Matrix has a zero row or column, that box is a
line or a point, and the vendored interpreter divided the /Rect size by
zero. The infinite or NaN matrix then reached the device with every path,
clip and glyph the appearance drew (4 of 4 drawing calls in the test).

hayro's rasteriser happens to drop such geometry, so pages looked right,
but nothing guaranteed it; AGENTS.md §4 asks for division by zero and NaN
to be guarded where they arise. Clipped to a collapsed box the appearance
shows nothing (MuPDF and Poppler draw nothing for these fixtures either),
so the appearance is now skipped, as is one whose mapping overflows. The
mapping of valid appearances is unchanged.

Test: raster::tests::degenerate_annotation_appearances_are_skipped. A
recording device sees non-finite geometry on main for a zero-width,
zero-height and point /BBox and for a /Matrix with a zero row or column;
none after. Each page renders blank; a valid appearance still fills its
/Rect and nothing outside it. Vendored patch (12) in vendor/README.md.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 09:00:21 -04:00
Hal Goff fdadf68085
Web: Insert and Replace edit the document they started on; Print ▸ Save as PDF downloads (#167, #170) (#270)
#167: browser file requests are bound to the document active when the
picker opened (id + edit generation), the same check desktop picks use,
now shared from files.rs. A request that arrives after a switch or an edit
is refused with a notice. Replace keeps that target through its dialog, so
OK never replaces pages in a document opened meanwhile (desktop too).
Browser picks wake the UI once queued, and run under the last-resort guard.

#170: on wasm, Save as PDF downloads "<name> (print).pdf" as Save does,
instead of returning false silently; it never takes the file-system path.
The Print dialog stays open when printing or saving fails.

Co-authored-by: Hal Goff <hal@hg.studio>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 08:54:45 -04:00
Brandon Thomas 4dd738b60f
CLI text tests: each protected-PDF test writes its own files (#267)
text_of_a_protected_document_without_the_password_fails failed on main: both protected
tests wrote to-protect.pdf / protected.pdf / protect.json at the same paths, and tests run
in parallel. Each now uses its own names.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:33:26 -04:00
wstock d2a59121fd
Issue #188: page steps in two-page view take every spread when several fit on screen (#265)
The current page is the page with the largest visible height. When more
than one row of pages is wholly on screen (a book with a portrait cover
and landscape spreads, at Fit Width or Fit Page), those heights differ
only by sub-point rounding after the scroll, so the "current" page was
whichever wholly visible page rounding favoured. ⌘Page Down then stepped
from the wrong spread: from the cover it went to 2–3 and then to 6–7,
skipping 4–5 (or, the other way round, stayed put after the cover).

Visible heights within half a point are now equal (the topmost wins),
and the page navigated to stays current while no page shows more of
itself, so the next step starts from it.

Test: viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen
(a 12-page book with a cover page at Fit Width, Fit Page and Fit Height:
⌘Page Down visits 1, 3, 5, 7, 9, 11 and ⌘Page Up 9, 7, 5, 3, 1, 0).
Before the fix it went 1, 5, 7, 11, 11, 11 at Fit Width. Cited on the
view.cover-page parity entry.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 08:31:38 -04:00