Commit Graph
190 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 01f4937dac Layering: the render-based rotated-field test moves from forms to engine
forms (L3) gained a dev-dependency on render (L3) with #516, which
`xtask layers` refuses, so main CI failed. The one test that renders a
rotated field now lives in engine's tests, which may depend on both;
the parity evidence points there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:08:46 -04:00
Márk BartalosandBrandon Thomas 3af93c7585 M5: draw all ten PDF line-ending styles (#520)
* M5: draw all ten PDF line-ending styles

Imported lines, polylines and callouts kept Square, Circle, Diamond, Butt, Slash and reverse arrows in /LE but would not redraw them. Those styles now draw, and comment_add and Comment properties can set them.

* hu, uk: Line ending

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:15:44 -04:00
Márk BartalosandBrandon Thomas c9d0619226 Forms: rotate field widgets with /MK /R (#516)
* M6: rotate form field widgets with /MK /R

Filling or restyling a rotated field redrew its text upright. Appearances now follow /MK /R, and Field Properties and form_set_props can set the rotation.

* Forms: a rotation refused as too small is checked before any other property is written; no indexing by widget number

* hu, uk: Rotation:

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:13:12 -04:00
e6df6b1384 i18n: add complete Ukrainian interface translation (#509)
* M14: add complete Ukrainian interface translation

Add the uk registry entry and original catalog, Ukrainian integer plurals,
calendar month inflection, attribution and localization documentation.
Fit Home recommendation cards and the search hint to translated text.
Cover locale detection, persistence, catalogs, bundled glyphs and UI control.

Fixes #503
Related to #348

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

* uk: add the recent-files labels, labels that reach tl!() through a variable, and 'Inserted text'; fix 'мають мати'

* uk: Home / End are key-cap names

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:55:46 -04:00
f626db25cc M14: refine German labels and prevent translated UI text overlap (#515)
* M14: refine German labels and keep translated text within UI bounds

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

* Tool names inside notices stay lowercase in English; German keeps its capitalised nouns

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:39:57 -04:00
Márk BartalosandBrandon Thomas 95120862fc M14: Hungarian interface catalog (hu) (#545)
* M14: Hungarian interface catalog (hu)

Magyar joins the interface languages as a complete catalog, so Preferences and hu locales show commands, dialogs, panels and notices in Hungarian. Wording uses informal "te" and nouns for commands; the strings were written from the meaning of the English labels.

* Small grammar correction

* hu: restore trailing spaces in run-on labels; add the recent-files labels and labels that reach tl!() through a variable

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:28:06 -04:00
4e725dcee2 Bookmarks and links go to their destination's position and zoom (#556)
Every destination was reduced to a page index (`dest_page` in the
inspector), so a bookmark written as [p1 /XYZ 0 420 0] went to the top
of page 1 instead of putting y 420 at the top of the window, and /FitH,
/FitV, /FitR and an /XYZ zoom were ignored.

Destinations now keep their view (ISO 32000-2 §12.3.2.2):
- render: `DestView` (Top, Xyz, Fit, FitH, FitV, FitR) parsed from
  explicit arrays, /D dictionaries and named destinations (/Names
  /Dests and /Dests). `OutlineItem` gains `view`; `LinkTarget::Page`
  carries it. Missing, wrong-typed or non-finite operands are null, a
  zoom that isn't positive keeps the zoom, and an unknown type or a
  /FitR without four numbers falls back to the page top.
  `PageInfo::dest_fraction` maps a user-space point to the displayed
  page through the crop box origin, /Rotate and the view rotation,
  clamped to the page.
- ui: `DocView::go_to_dest` applies it for bookmark and link clicks
  (and so for `ui.click` on the control channel): /XYZ puts (left, top)
  at the window's top-left (beside the quick-action gutter) at its
  zoom; /Fit, /FitH and /FitV switch to fit page, width and height with
  top/left at the edge; /FitR zooms so the rectangle fits, centred. The
  /FitB* content box is read as the whole page.

Tests: operand parsing incl. hostile values, a fixture with every form
(named, GoTo, wrong types, huge numbers, no page), point mapping under
crop offsets and rotations, go_to_dest state, and kittest bookmark and
link clicks checking the point lands at the window's top.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:14 -04:00
19625d40a8 M3: a newly opened document starts at the top, not where the last session scrolled (#554)
Opening a PDF after a restart showed it part-way down, at the page some
other document had been left on in the previous session.

Since #259 (#189) the page view's ScrollArea is salted with the
document's DocId, so each tab keeps its own offset. eframe persists
egui's memory (persist_egui_memory defaults to true) to app.ron every
30 s and on quit, scroll offsets included, and restores it at the next
launch. DocIds start again at 1 every launch, so the first document
opened inherited the offset stored under id 1 last time, the second the
one under id 2, and so on.

A DocView now notes when its page view is first shown, and that frame
sets the scroll offset to zero. Navigation queued before the first frame
still wins: a zoom anchor or `goto` (--page, the PDF's open action,
reopening the last session's files at their pages, #462) is applied on
top. Tabs that were already shown keep their own position, as #189
needs. egui's other persisted memory (panel sizes and the like) is
untouched.

- canvas: `DocView::shown`, reset of the offset on the first frame
- tests/viewer.rs: a_new_document_opens_at_the_top_after_a_relaunch
  saves egui's memory through eframe's own RON storage path after
  scrolling a.pdf to page 4, restores it in a new app, opens c.pdf
  (DocId 1) and checks it starts on page 1 (it opened on page 4 before
  the fix) and then keeps its own position
- parity: the test is evidence on view.document-tabs

Refs #189, #259, #462.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:10 -04:00
Brandon ThomasandClaude Opus 5.5 5ddf33ccf5 Fix #299: typed and drawn signatures stay upright on rotated pages (#562)
On a page with /Rotate 90, 180 or 270, Fill & Sign placed typed and drawn
signatures and initials sideways or upside down: their box and their
strokes/outlines were laid out along user-space axes, which the page turns,
while the click (or fill_sign_add's "at") is a displayed point. Only image
signatures were counter-rotated (#485).

Both now use the same displayed axes as image signatures
(pdfcraft_model::view_matrix_for without its offset): the box's left edge and
vertical centre are at the displayed point, a drawn signature's ink points are
mapped through the displayed right/up vectors, and a typed signature's
normalised outlines are turned back inside a user-space box laid out along the
turned page (the shared signature_image::upright_box, now also used by
SignatureImage::rect). The appearance is plain geometry, so every viewer shows
it upright without relying on a form /Matrix. Unturned pages are unchanged
(the mapping is the identity there).

typed_signature_shape and fill_sign::{signature_at, typed_signature_at} take
the page rotation; fill_sign::place and fill_sign_add pass it.

Tests: a kittest-file test places a drawn, typed signature and typed initials
on /Rotate 0/90/180/270 pages through fill_sign::place, renders them and checks
each displayed ink box is wider than tall, anchored at the point, identical to
the unturned page, and that an asymmetric drawn stroke is not mirrored; an
automation test does the same for fill_sign_add typed signatures and initials,
before and after save/reopen.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:54:07 -04:00
pauliusiv123-rgb 28804d55d7 Issue #430: clear the recent-files list, or remove one file from it (#557) 2026-10-09 11:53:34 -04:00
Brandon ThomasandClaude Opus 5.5 9acb29e418 Fix #527: Shift+click extends the text selection (#560)
A plain click on a page cleared the text selection whatever modifiers were
held, so there was no way to extend a selection with Shift+click as in
Acrobat. Now, when Shift is held and there is a text selection on the
clicked page, the click moves the selection head to the nearest text
position and keeps the anchor. Without Shift, or with no selection on that
page, a click still clears the selection. Selections remain single-page:
cross-page selection (the issue's second request) is not part of this
change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:48:50 -04:00
Brandon ThomasandClaude Opus 5.5 653f8268c2 Fix #273: ↓ / ↑ turn pages in single-page view (#550)
In single-page view ↓ / ↑ only added to the pending keyboard scroll, so on a
page that fits the window (or once scrolled to the page's end) they did
nothing, while the mouse wheel turned the page there.

The page view now records, after each frame in single-page view, which page
it drew and whether it was at the top and at the bottom of it. ↓ at the
bottom (always, on a page that fits) steps to the next page; ↑ at the top
steps to the previous page and lands on its bottom, so holding ↑ keeps
scrolling back through the document. The edges only count when they belong
to the current page with no jump pending, so a stale frame can't turn two
pages for one press.

Tested by a kittest: Fit page in single-page view, ↓ / ↑ turn one page per
press; Fit width, ↓ scrolls through page 1 before turning, and ↑ from the
top of page 2 returns to the bottom of page 1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:50:59 -04:00
Brandon ThomasandClaude Opus 5.5 2aca0692fc Fix #526: keep text colour in Word, HTML and RTF export (#547)
The export model (`pdfcraft_export::Block`/`Cell`) had no colour, so
`export_pages` dropped the fill colour that `edit::TextBlock` already
carries and every run came out black.

Block and Cell now carry an sRGB colour, mapped from TextBlock.color.
Word runs get `<w:color w:val="RRGGBB"/>`, HTML paragraphs, headings and
cells a `style="color:#RRGGBB"`, and RTF a colour table with `\cfN`.
Black text keeps each format's default; NaN and out-of-range components
clamp.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:49:21 -04:00
Brandon ThomasandClaude Opus 5.5 bd57377435 Fix #525 (item 4): phone pattern redacts international numbers whole (#543)
The Phone Numbers redaction pattern accepted only 10 digits (11 with a
country code), the North American form. For "+49 2151 123456" (12 digits)
the `+` candidate was rejected and the scan then matched "2151 123456",
leaving "+49" visible: a partial redaction in a document the user thinks
is clean.

The pattern now accepts, still grouped by at least one separator:
- E.164 numbers with a leading `+`: 8-15 digits in all;
- national numbers with a trunk 0 followed by 6-14 digits, whose first
  group has at least two digits (so decimals don't match) and which are
  not a day-first date (left to the date pattern);
- North American numbers as before.
A separator may now be followed by `(`, so "+49 (0) 2151 123456" is one
match instead of a partial "(0) 2151 123456".

Refs #525. The other items (page-number and date tokens, stamps) are not
addressed here.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:43:11 -04:00
7c358099ff M14: German interface catalog (de) (#471)
* M14: German interface catalog (de)

Deutsch (`de`) joins the interface languages as a complete catalog of
2,035 entries: every command, All tools entry, dialog, panel and notice.
It is selected from Preferences, and Auto picks it for `de`, `de_DE`,
`de_AT`, `de_CH` and other German locales. Wording uses the informal
"du" and the infinitive for commands; translations are clean-room, written
from the meaning of the English labels.

- de.tsv registered in LANGUAGES (plural rule one|other)
- tests: registration and locale mapping, plurals, coverage of every
  command, All tools label and tl! literal, history labels keeping user
  values; the Latin UI faces cover umlauts, ß and German quotes; the
  control-channel language switch includes `de`
- ATTRIBUTION.toml/.md entry (kind = "translation"), docs/localization.md,
  ROADMAP and parity notes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* M14: register German as the 12th language; translate the reopen-session preference (de, bg)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:26:15 -04:00
21a9624585 M7: Arabic in added text (shaping, bidi order, searchable Type 3 glyphs) (#403)
Add text wrote Arabic as "?" (standard-14 fonts, WinAnsi). Items with
Arabic are now shaped with the craft-fonts Arab face through harfrust
(already in the tree via egui) and laid out line by line in display
order with unicode-bidi (UAX #9), so Arabic mixed with Latin, digits and
brackets reads correctly. Each cluster (a letter with its dots or marks)
is one glyph of a Type 3 font drawn from the face's outlines, with a
ToUnicode entry, so text extraction, copy and search get every letter
once and no font file is embedded.

- fonts: shape_arabic / arabic_glyph / arabic_has / document_arabic_font;
  japanese_glyph's bounded outline code is shared as bounded_outline.
- edit: draw_arabic and type3_arabic; wrapping measures shaped words.
  Fonts get names free on the page (a full save renumbers objects, not
  resource names) and are removed when the item is updated or deleted.
  Outlines are read before anything is written.
- Without the face, adding Arabic is a clear error that changes nothing;
  an existing item holding Arabic still moves, drawn as "?" as before.
- Non-Arabic items are drawn exactly as before.

Refs #250, #280, #369.

Co-authored-by: fares <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:22:50 -04:00
pauliusiv123-rgb 3de2be8a68 Issue #442: reopen the files that were open when PdfCraft last closed (#462) 2026-10-09 09:16:10 -04:00
fa52469dbe sign: read the .p12 files Windows writes (BER, not DER) (#453)
Importing a digital ID exported by Windows failed with "PKCS #12: not a
PKCS #12 file" — at the second byte of the file, before the password was
looked at. Windows writes a .p12 in BER: the PFX, its ContentInfo and
the [0] around the AuthenticatedSafe all use the indefinite length form
(0x80 … 00 00), and the AuthenticatedSafe itself is an OCTET STRING
split into segments. der.rs took definite lengths only, so every one of
those files was turned away. OpenSSL writes definite DER, which is why
the fixtures never showed it.

A Tlv now carries the rules it was read with, and its children are read
the same way: Tlv::parse is the strict DER reader it always was,
Tlv::parse_ber also takes the indefinite form, and Tlv::octets joins the
segments of a constructed OCTET STRING, borrowing when there is nothing
to join. Both are depth-bounded. pkcs12 reads the envelope with the BER
reader; x509 and cms keep the strict one, so nothing that is verified
over its own bytes changes.

The MAC decides what is allowed here: it covers the contents of the
authSafe OCTET STRING, the segments joined, which is what the writer
MACed. So the file is never normalised to DER — that would change the
octets the MAC was computed over and turn a right password into a wrong
one.

The test builds the Windows shape out of the OpenSSL fixtures, twice:
the outer wrappers in BER with the MacData intact, which is what proves
the MAC is taken over the right octets, and then the AuthenticatedSafe
re-encoded as well, with the MacData dropped (RFC 7292 §4 makes it
optional), for a file that is BER the whole way down.


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

Co-authored-by: Fernando Areán <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 09:15:32 -04:00
Chen TaoandClaude Opus 5.5 5f570eab01 M2.6: non-embedded Mincho fonts draw half-width katakana (#484)
Since #368, a non-embedded Adobe-Japan1 font with a Mincho name
(HeiseiMin-W3, MS-Mincho, ...) is drawn in document_japanese_font(),
i.e. Shippori Mincho. Shippori Mincho has none of the 63 half-width
katakana (U+FF61-U+FF9F), which are common in Japanese documents.
hayro maps the CID through Adobe-Japan1-UCS2 to Unicode, finds no
glyph in the face's cmap, and falls back to drawing the CID as a glyph
index: half-width katakana came out as unrelated glyphs (ligatures
such as fi and fl).

The Mincho substitute is now BIZ UDMincho Regular, which covers
half-width katakana (and was designed as a metric-compatible
replacement for MS Mincho), falling back to document_japanese_font()
when it isn't built in. The face used for Japanese text written into
PDFs is unchanged. Without craft-fonts nothing changes.

Tests: raster::tests::mincho_substitute_covers_half_width_katakana
(with CRAFT_FONTS_DIR: the Mincho substitute's cmap maps U+FF61-U+FF9F
and 日本; it lacked all 63 half-width katakana before; skips without
craft-fonts). skrifa 0.44 is a dev-dependency of pdfcraft-render for
it, the version pdfcraft-fonts already uses. The parity entry
view.render-cid-fonts cites the test.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:14:46 -04:00
MarekSpodBiedry 271139bd3b Comments: a new highlight opens its note for typing (#475) (#481)
Adding a highlight selected its card in the Comments panel but left the
note closed; the text field only appeared after double-clicking the card.
Acrobat opens the note for typing as soon as the highlight is made.

apply_edit now opens the inline editor for a new highlight (text drag,
selection then tool, area highlight all pass through it), unless a note
typed into another card is still unsaved, so that text is never dropped.
2026-10-09 09:14:40 -04:00
midasdfandBrandon Thomas 438a269bac M7: report Japanese Type 3 fallback font names (#387)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 21:03:16 -04:00
pauliusiv123-rgbandBrandon Thomas 85dcb9dece Issue #295: three clicks select the line, four clicks the page (#366)
* Text: find the word and the line around a glyph

PageText gets word_at(i) and line_at(i). Each returns the first and
last glyph of the word or the line around glyph i, or None when i is
past the end of the page's text. A word stops at a word gap, a blank
glyph or a line end. A line is the run of glyphs with the same line
number.

The canvas's double-click used its own loop for this, which indexed the
glyph, line and gap arrays directly. It now calls word_at, which reads
them with get(). Double-clicking selects the same words as before.

Test: render words_and_lines_around_a_glyph, on two lines with a blank
glyph, a word gap and a line break.

* Issue #295: three clicks select the line, four clicks the page

With the Select tool, quick clicks on text widen the selection like
Acrobat: two clicks select a word (as before), three the line, four or
more all the text on the page. A third click used to clear the
selection.

egui counts only up to triple clicks and reports a fourth quick click
as another triple, so the view keeps its own count of the current run.
A single click resets it and a pause ends it.

With the markup tools (Highlight and the like) and Redact, a
double-click already turns the word into a mark at once. They stop at
the word, so more clicks don't stack line or page marks.

Tests: ui-egui quick_clicks_select_a_word_then_the_line_then_the_page
and quick_clicks_with_the_highlighter_mark_only_the_word.

parity: view.text-select cites the tests. The notes say what is
missing: paragraph selection, and Ctrl/⌘+A across the whole document
(it selects the current page).

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

Clippy 1.99 rejects chunks_exact with a constant chunk size
(chunks_exact_to_as_chunks), so `cargo clippy --workspace --all-targets
-- -D warnings` fails on main since #355. Use as_chunks, as #360 did
for the test above it.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:04:10 -04:00
31f3631868 Home: pin folders and list their newest PDFs (#383)
* Home: pin folders and list their newest PDFs

File > Pin folder to Home... (or "Pin a folder..." on Home) pins up to 8
folders. Each lists its newest PDFs with how long ago and size, opens one
with a click, and can be unpinned. Folders are read on a worker thread and
listed again every few seconds while Home shows, so new scans appear by
themselves; an unreadable folder (moved, offline synced drive) stays pinned
and says so. A folder is described by where it is ("iCloud Drive",
"OneDrive - Contoso", "Documents > Work") instead of its full path.

Cloud drives work through their local synced folder; nothing goes through a
provider API. Settings keep absolute paths only, deduplicated and capped.

Tests: crates/ui-egui/tests/folders.rs (8) and folders_ui unit tests (4).
Command label translated in every catalog; Home strings in fr, es, ja,
zh-hans and zh-hant; catalog hashes updated in ATTRIBUTION.toml.

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

* Add my contributor name (amitabhthakur)

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

* Home pinned folders: Russian strings; regenerate ATTRIBUTION.md

The Russian catalog (#375) landed while this branch was open: its coverage
tests need the new command label and Home strings. ATTRIBUTION.md is
regenerated (cargo xtask assets --write), which also adds the ru.tsv row
that #375 left out.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 19:59:24 -04:00
07b95409bf Comments without an appearance stream are drawn (display only, never saved) (#371)
* M5: comments without an appearance stream are drawn (display only, never saved)

#260 (page 7): a FreeText box with /DA and an orange /C background, a
Text note and an Ink stroke, all without /AP, were not drawn; Acrobat,
MuPDF, PDFium and Poppler draw them. The renderer only draws /AP (plus
the Highlight fallback of #274), and pdfcraft-annot, which already
draws all of these for PdfCraft's own comments, can't be used from the
renderer (both are L3).

pdfcraft_annot::with_missing_appearances returns a copy of the document
in which every annotation without /AP /N that appearance::build can
draw (FreeText, Ink, Text, FileAttachment, stamps, shapes, lines,
markup, …) has the appearance build draws from its dictionary; links,
form fields and pop-ups are left alone, as is anything build declines.
The engine keeps that copy, written as an in-memory incremental update,
in a new Document::display: the page view's render pool, page_render
and page images (export_source) read it. Document::bytes, what Save,
signatures and the inspector use, is unchanged. display is rebuilt
wherever the renderer was (open, refresh, scoped refresh) and is bytes
itself when nothing is missing or the document can't be edited.

Tests: annot tests::comments_without_appearances_get_one_for_display_only
(FreeText, Ink and Text get /AP /N in the copy, an existing /AP is kept,
Link, Widget and an unknown subtype aren't touched, the original is
untouched, none means no copy) and engine
tests::comments_without_appearances_are_drawn_but_not_saved (the page
view draws the FreeText background and the Ink stroke, before and after
an edit; the working bytes stay the opened file; Save writes no /AP).
On main the page view shows white where the FreeText box is. Parity:
view.render-annotation-appearances cites the test and notes what's left
(Link borders without /AP, printing).

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

* Test the Ink stroke on its apex row: Ink is now a curve through its points (#374)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 19:43:58 -04:00
3b8a274f52 M2.6: non-embedded Japanese CID fonts draw in a craft-fonts face (#368)
#260 (page 2): text in a Japanese CID font that the PDF doesn't embed
(HeiseiMin-W3 with UniJIS-UCS2-H, Adobe-Japan1) drew nothing, while
MuPDF, Poppler, PDFium and Acrobat draw it in a Mincho face. hayro asks
its font resolver for a substitute with the CID collection in the
query, but the renderer never installed one, so hayro's default
answered with a Latin standard-14 font that has no Japanese glyphs.
Release builds have embedded Japanese faces from craft-fonts all along;
the renderer just didn't use them.

RenderConfig::settings now installs a resolver: for an Adobe-Japan1
fallback query it returns a craft-fonts face (Shippori Mincho for
Mincho names, BIZ UDPGothic Regular or Bold for Gothic names, the
descriptor's serif flag otherwise); every other query keeps hayro's
default. hayro already maps such fonts through the Adobe-Japan1-UCS2
CMap to Unicode and the face's cmap. Without craft-fonts nothing
changes. Other CJK collections are untouched: the pinned craft-fonts
has only Japanese faces, and its later Chinese face is Noto CJK, which
AGENTS.md §1.1 rules out. pdfcraft-render now depends on pdfcraft-fonts
(L3 to L2).

Tests: raster::tests::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face
(with CRAFT_FONTS_DIR: 日本 in HeiseiMin-W3 and HeiseiKakuGo-W5 now
draws; it drew 0 dark pixels before; skips without craft-fonts, as the
Linux CI job has them) and raster::tests::japanese_font_names_pick_mincho_or_gothic.
The parity entry view.render-cid-fonts cites the test and notes what is
still missing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 19:39:26 -04:00
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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 18:58:17 -04:00
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 <[email protected]>

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 18:49:26 -04:00
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 <[email protected]>

* 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 <[email protected]>

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 18:46:34 -04:00
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 17:42:13 -04:00
Devon LlewellynandBrandon Thomas 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 <[email protected]>
2026-10-08 17:17:05 -04:00
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 <[email protected]>

* 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 <[email protected]>

* Add my contributor name (chiranjeevi-max)

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

* 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 <[email protected]>

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

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

* M14: Telugu for the image signature notices

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

* Telugu for Open Recent (#323)

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

---------

Signed-off-by: Chiranjeevi U <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:00:25 -04:00
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* French for Open Recent again, and catalog checksums refreshed

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

---------

Co-authored-by: Mawfyy <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:07:25 -04:00
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) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:04:03 -04:00
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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 15:37:10 -04:00
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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 15:19:16 -04:00
midasdfandCodex 81a6ca456c M7: preserve ideographic spaces in edited paragraphs (#309)
Co-authored-by: Codex <[email protected]>
2026-10-08 13:29:15 -04:00
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 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 13:06:33 -04:00
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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 13:02:52 -04:00
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 <[email protected]>

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 12:00:35 -04:00
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 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 11:27:10 -04:00
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 <[email protected]>
Co-authored-by: iMhack <[email protected]>
2026-10-08 11:16:30 -04:00
1b62d47fa7 M3: find text in mixed horizontal and vertical PDFs (#287)
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 10:49:56 -04:00
Sameh ElhawaryandClaude Opus 5.5 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 <[email protected]>
2026-10-08 10:46:58 -04:00
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 <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 10:20:04 -04:00
cyfung1031andClaude Opus 5.5 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 <[email protected]>
2026-10-08 09:23:55 -04:00
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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 08:31:38 -04:00
f866d995c8 Issue #185: arrow keys and Page Down / Up move through a scrolling document (#261)
In the continuous (default) and two-page views, → / ← and Page Down /
Page Up only did something with ⌘ held, and ↓ / ↑ never did: egui's
ScrollArea has no keyboard scrolling of its own, so without a modifier
the keyboard could not move through the document at all.

As in Acrobat: → / ← now go to the next / previous page (a spread in
two-page view) in every layout; ↓ / ↑ scroll a line (40 pt, a desktop
mouse-wheel line) and Page Down / Page Up scroll a screen (less one
line) where the pages scroll. ⌘ + those keys and single-page view keep
their page steps. The scroll is queued on the DocView and applied by the
page view's ScrollArea in the same frame, next to the middle-button
auto-scroll.

The shortcuts list shows ← / → beside ⌘← / ⌘→; the view.page-navigation
parity entry cites the new test.

Test: viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document
(continuous at Fit Width: → / ← turn pages; ↓ and Page Down take
several presses to reach page 2, ↓ more than Page Down, and ↑ / Page Up
come back; → in two-page view and Page Down in single-page view). It
failed at the first → before the fix.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 08:11:30 -04:00
786fa481f4 Issue #189: each document tab keeps its own scroll position (#259)
The page view's ScrollArea had no id of its own, so egui derived it from
the parent Ui, which is the same for every document. All tabs therefore
shared one scroll offset: switching to another document showed it at
wherever the previous one had been scrolled to (clamped to its length),
and switching back showed the first document at the other one's offset,
usually the top.

Salt the ScrollArea with the document's id so egui keeps one offset per
tab.

Test: viewer.rs::each_document_keeps_its_own_scroll_position scrolls
a.pdf to page 4, switches to b.pdf and scrolls it to page 2, then
switches back and forth: before the fix b.pdf opened on page 2 instead
of its own top. Cited as evidence on view.document-tabs.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 07:53:12 -04:00