Commit Graph

464 Commits

Author SHA1 Message Date
Streetblock 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 <codex@openai.com>

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

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:39:57 -04:00
cyfung1031 63363ec438
M3: rasters reach egui without a copy on the UI thread (#555)
* M3: rasters reach egui without a copy on the UI thread

Every page, tile and thumbnail raster was copied pixel by pixel on the UI
thread (`ColorImage::from_rgba_premultiplied`), because egui's `Color32` is
4-byte aligned and a `Vec<u8>` isn't. That is 0.5 ms per 1024² tile, 5.4 ms
for a 33 MB page and 36 ms for a 197 MB page, against a 16.7 ms frame.

hayro, vendored patch 7: `render_size` and `render_into` render into a buffer
the caller allocates. `render` is `render_into` on a new pixmap, so its output
is unchanged. pdfcraft-render renders straight into `Pixels`, the same
premultiplied RGBA bytes kept in one 4-byte word per pixel. `Pixels`
dereferences to `[u8]`, so every reader of `RenderedPage::rgba` compiles
unchanged. The canvas reinterprets the words as `Color32`s and hands them to
egui as they are.

Pixels are identical: all 33 renders of #260's test file hash the same. The
UI-thread copy is gone, and rendering a 6182×8000 page takes 67 ms instead of
71–76 ms.

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

* M3: render_into leaves a buffer of the wrong length untouched instead of reaching vello's assert

* M3: regression test for render_into with a wrong-length buffer

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:28:46 -04:00
cyfung1031 eb00280eba
M1: stream data read from a file points into the file instead of being copied (#552)
* M1: stream data read from a file points into the file instead of being copied

Every stream COS loaded was copied out of the document's bytes
(`data[start..end].to_vec()`) and kept in the object cache until the document
closed. Anything that reads every object therefore left a second copy of all
stream data behind. That includes a full save, which edits needing a rewrite
(repaired files, security changes, redaction) run on every change. A full save
of a 208 MB document left 208 MB cached on it.

`Stream::raw` is now `Bytes`: owned bytes, or a range of a larger shared
buffer. Streams parsed from a document's own bytes are ranges of that buffer,
which the document keeps anyway. Decrypted and newly built streams own their
bytes as before. A stream added to a document from another document's file gets
its own copy of just its bytes (`Document::set`/`add`), so it never keeps
another whole file alive.

Results, measured with an exact heap counter:
- A full save of a 208 MB document leaves 0.2 MB cached instead of 208.2 MB.
- The first full save of a freshly opened document is about twice as fast:
  10 → 5 ms at 40 MB, 38 → 21 ms at 200 MB.

Output is identical: 68 incremental and full saves of five originals,
encrypted ones included, hash the same before and after.

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

* M1: the full-save capacity test (#523) builds its stream from Bytes

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:28:33 -04:00
Márk Bartalos 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 <echelon@gmail.com>
2026-10-09 12:28:06 -04:00
martinjohuber 9ed70e185c
de: translate labels that reach tl!() through variables; fix name collisions and wrong meanings (#541)
* de: translate labels that reach tl!() through variables; fix name collisions and wrong meanings

- 32 labels the German catalog was missing because they are looked up
  through a variable (Fill & Sign buttons, link prompts, notices, history
  labels, checker and certificate rows): the UI showed mixed text such as
  "Add Unterschrift" or "A link in diesem Dokument ..."
- Keep the trailing spaces of the digital-ID list parts ("Ausgestellt
  von:Example CA, läuft ab:2031-..." ran together)
- Name collisions: Add a text box / Add a text field, Paste pages / Insert pages
- Wrong meanings: Social Security Numbers (US format only), Clear form
  (resets to defaults), radio-button options called "Schaltfläche", the
  Mark as Confidential description, the first measuring point, Mouse Enter/Exit
- Small grammar fixes; du style and "das PDF" unchanged

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

* de: drop a leftover merge marker

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:23:15 -04:00
cyfung1031 24ba8385f8
Issue #324: on Windows 11, a window dragged to another monitor takes its scale (#544)
* Issue #324: vendor winit 0.30.13 as published

The crate as released on crates.io, without its asset files (docs/res/*.svg and
examples/data/*.png, AGENTS.md §1). Not used yet: the next commit patches it and
adds it to [patch.crates-io].

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

* Issue #324: on Windows 11, a window dragged to another monitor takes its scale

winit 0.30.13 handles WM_DPICHANGED by checking that the new window rect is on
the monitor `MonitorFromWindow` names. During a drag that is still the monitor
being left, so winit nudged the window back onto it, which sent another
WM_DPICHANGED: the window grew and stayed on the wrong monitor at the wrong
scale. From Windows 11 (build 22000) on, apply the rect Windows suggests, as
winit master does since 488c036a (rust-windowing/winit#4341); no 0.30 release
has it. Windows 10 keeps the old adjustment, as on winit master.

The vendored copy goes in [patch.crates-io]; a test fails if winit resolves
from crates.io again or the vendored copy loses the patch.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:41 -04:00
martinjohuber d9047d4d5b
Forms: radio groups and check boxes accept their /Opt export values (#532)
pdf-lib and other writers give radio groups and check boxes an /Opt array
(PDF 1.4, one export value per widget) and name the on states by position
(/0, /1 ...). Filling such a field by its export value ("Pick-up") failed with
"has no option", and form_fields listed the options as 0, 1.

- Field::export_of / state_for / export_for_state map between a widget, its
  /Opt export value and its on-state name; fields without /Opt are unchanged
- set_value accepts an export value for radio groups and check boxes; the
  "has no option" error lists the export values
- form_fields reports a radio group's options and selected value as export
  values
- Test: opt_export_values_select_button_states

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:36 -04:00
LloydNicholson 5deb42199c
Issue #537: pdfcraft-cli --help prints the full usage (#540)
The per-command options lived only in main.rs's doc header, so a packaged
binary had no way to show them. They move into a USAGE constant (one copy,
no drift) that `--help`, `-h`, `help` and `<command> --help` print to stdout,
exiting 0. No command prints the usage to stderr and fails; an unknown
command is named and points to --help.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:31 -04:00
Hal Goff 36af4afb19
Never crash: fall back to OpenGL when wgpu can't start (#461, #435, #392) (#530)
When wgpu can't get a GPU device or surface (old Intel Macs, some Windows
drivers), run_native returned an error and PdfCraft quit before showing a
window. Now, when wgpu fails while starting (eframe::Error::Wgpu, before the
app is created), the error is logged and the app starts again on eframe's
OpenGL (glow) renderer, which the web build already uses.

- PDFCRAFT_RENDERER=gl (opengl, glow) starts on OpenGL; =wgpu never falls
  back; unset or anything else tries wgpu, then OpenGL.
- Window and app set-up move into native_options/app_creator so the second
  run starts exactly like the first; eframe reuses its event loop.
- Page rasters and their tiled backdrop stay within the GPU's
  max_texture_side (OpenGL can report 2048; an oversized upload aborts).


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:26 -04:00
Dimitar bd27555519
M7: Type 3 fallback glyphs get a well-formed d1, so Acrobat draws them (#548)
type3_path wrote `wx 0 0 0 0 1000 1000 d1`: seven operands where d1 takes six
(wx wy llx lly urx ury). Lenient renderers (pdfium, Quartz, MuPDF, poppler) ignore the
extra one, but Acrobat treats the glyph as unusable and draws a bullet for every
character of edited Japanese (or other fallback) text. The box is now the glyph's own
bounds, rounded outward; empty glyphs (spaces) get a zero box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:20 -04:00
LloydNicholson 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 <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:14 -04:00
LloydNicholson 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 <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:10 -04:00
cyfung1031 cc17deb634
M3: the watchdog stops the render it gives up on (#522)
When a render runs past `stuck_after`, the watchdog answers with an error,
starts a replacement worker and drops the late result. The abandoned worker
still rendered the page to the end: #409's stop flag belonged to the whole
pool and was only set when the pool was dropped.

Each worker now has its own stop flag. Dropping the pool sets every worker's
flag, as before. The watchdog sets the flag of the worker it gives up on, so
that render stops at its next content operator. Its answer was already thrown
away, so nothing the user sees changes. Live workers are never stopped.

After giving up on 4 heavy renders, the process used 11.2–11.6 s of CPU in
the next 3 s with nothing left to do; now it uses none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:04 -04:00
cyfung1031 b98505c810
M1: full saves are allocated at their exact size (#523)
`write_full` can't know the output size in advance, so its buffer grows by
doubling and the result kept up to as much again unused: 28 % for a 100 MB
save, 99 % for a 5 MB one. The result is often kept for as long as a document
is open. It becomes the working bytes after edits that need a full rewrite
(repaired files, security changes, redaction) through `write_incremental`, and
it backs Save As ▸ optimized and every new document from Create, Combine,
Extract and Split.

`shrink_to_fit` at the end drops the unused capacity. The bytes are the same.
On macOS the shrink happens in place (2 µs for 100 MB, the buffer doesn't
move), and full-save times are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:18:59 -04:00
Brandon Thomas 8ddcf63ff8
Fix #445: prefer the discrete GPU on desktops with a monitor on each GPU (#558)
On Linux, `pick_adapter` preferred the integrated GPU whenever it drove
any display, to keep hybrid laptops (#8) on their low-power GPU. A desktop
with one monitor on the Intel iGPU and one on an NVIDIA card looked the
same, so wgpu picked the iGPU, which accepted the Wayland surface but then
failed to configure it, and egui-wgpu panicked.

`linux_display_gpus` now also records whether a GPU drives a built-in
panel connector (eDP, LVDS or DSI). When several GPUs drive a display,
the one driving the built-in panel wins (laptops, unchanged); with no
built-in panel anywhere, the discrete GPU wins. Tests cover the desktop
topology from the issue, the laptop case, connector classification and
reading the panel flag from a fake /sys/class/drm tree.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:06:22 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
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 Thomas 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 <noreply@anthropic.com>
2026-10-09 11:48:50 -04:00
Brandon Thomas e5b37b8b9e
Fix #454: dependencies keep only line tables in dev builds (#559)
On Windows every integration-test binary links the whole app and gets its
own 500-600 MB PDB, mostly debug info for third-party crates, so one
`cargo test --workspace` needs ~38 GB of target dir.

[profile.dev.package."*"] now sets debug = "line-tables-only". "*"
matches only non-workspace packages (crates.io dependencies and the
vendored crates under vendor/, which are excluded from the workspace), so
our own crates keep full debug info and dependency frames still show file
and line in backtraces.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:48:03 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-09 10:50:59 -04:00
Brandon Thomas 6fce73f902
Fix #429: pen strokes stay unselected and show no author popup (#549)
Cause: apply_edit selected every new annotation after Edit::AddAnnotation,
so each stroke drawn with the Draw (ink) tool became selected and showed
its selection box over the page. With the pointer still resting on the
fresh stroke, the canvas hover popup also showed the comment's author,
covering where the next stroke goes.

Fix: a new Ink annotation made while the ink tool is active is left
unselected (other annotation types, and ink added any other way, are
still selected). The annotation hover popup is suppressed while the ink
or eraser tool is active, as it already was mid-gesture.

Test: kittest draws two pen strokes in a row and checks both exist,
nothing is selected, the pen stays on and no author popup appears; a
square drawn afterwards is still selected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:50:17 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-09 10:49:21 -04:00
Brandon Thomas a593512700
Refs #347: encode the full WinAnsi 0x80-0x9F range (Š š Ž ž Œ œ Ÿ ...) (#546)
pdfcraft_fonts::win_ansi, edit's is_win_ansi_char and the xtask demo
encoder each carried a hand-written subset of WinAnsiEncoding's 0x80-0x9F
row (quotes, dashes, euro, trademark) and left out Š š Ž ž Œ œ Ÿ ƒ † ‡ ˆ
˜ ‰ ‹ ›. Text editing therefore treated š/ž as non-WinAnsi and sent them
to the Type3 fallback font (or refused them without craft-fonts), and
every other generator (forms, annotations, OCR layer, redaction overlay,
signatures, create) wrote them as '?'.

win_ansi now goes through a new win_ansi_byte, which reads the 0x80-0x9F
codes from the same ISO 32000-2 Annex D table the text extractor decodes
with, and is_win_ansi_char reuses it. xtask doesn't depend on
pdfcraft-fonts, so its demo encoder gets the full row with a pointer to
the shared table. č/ć are not in WinAnsi and still use the fallback.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:45:28 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-09 10:43:11 -04:00
Christian Schiffer 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) <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:26:15 -04:00
fares111111122 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 <aw674860@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:22:50 -04:00
jcl-bit ee06583523
Never crash: JBIG2 symbol dictionaries can't loop without end (#408)
`cargo xtask fuzz` found a 1.1 KB page whose JBIG2 image never finished
decoding (over two minutes, flat memory). It differs from a valid file by
one byte inside a symbol dictionary's arithmetic-coded data. Past the end
of its data the arithmetic decoder keeps returning values, and two loops
in symbol.rs only move on when those values make progress:

- Export runs (6.5.10): a run of length 0 doesn't advance, and the
  decoder returned 0 for ever. This was the fuzz finding.
- Height classes (6.5.5): a class whose first width is already the
  end-of-class marker decodes no symbol. A read-only sweep of the crate
  found this one. In a synthetic dictionary every class after the first
  symbol adds 13 to the height and decodes nothing, so it only fails
  when the height overflows u32, after 330 million classes (about five
  seconds per image, logged); a delta of 0 would never end.

The sweep found every other loop in hayro-jbig2 bounded by a symbol,
instance or pixel cap or by the end of the data. Bounded is not cheap
everywhere: refinement bitmaps in text regions and aggregate symbols
(up to 10,000 instances of up to 2^28 pixels each) are not changed
here.

Vendored hayro-jbig2 patch (3): a valid dictionary needs at most one
height class per new symbol and `2 x symbols + 1` export runs (they
alternate, so at most one zero-length run before each symbol's). Both
loops get that budget plus `EXTRA_EMPTY_STEPS` (1024), then fail the
dictionary as invalid. Encoders don't write empty steps back to back,
so the slack only tolerates oddities.

The fuzz finding now renders in 0.2 s. Synthetic regression test in
render: two dictionaries of a few arbitrary bytes each, found by
searching for streams that decode the same way (no corpus bytes). The
height-class one is drawn as twenty images so that it stalls well past
the 20 s timeout without the patch on any machine. Each case is red
with its own budget removed and green with it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:22:21 -04:00
Brandon Thomas 49fb1b1b35
Tests: one wgpu device compiles shaders at a time in fill_sign.rs (WARP ARM64 crash) (#395)
The image-signature tests (#335, #355) render pixels in parallel; on the
Windows 11 ARM64 runner WARP's pixel-shader JIT then crashes the test process
with STATUS_ACCESS_VIOLATION (3 of the last 4 main runs). Take the same GPU
lock tests/view.rs uses (d341651) in every fill_sign test that renders.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:21:57 -04:00
Brandon Thomas 2f98beba54
Merge pull request #272 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:21:44 -04:00
Brandon Thomas 37542ad9db Merge remote-tracking branch 'origin/main' into dmg-background
# Conflicts:
#	ATTRIBUTION.md
2026-10-09 09:21:35 -04:00
midasdf 32fbef684a
M5.7: keep natural image stamps upright on rotated pages (#473)
Refs #434 (partial). Preserve displayed bounds and standard appearance orientation through stamp edits; image signatures and explicit rectangles remain outside this fix.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:21:31 -04:00
Streetblock 8c90a8d1fd
M2: bound lopdf predictor scratch to available samples (#470)
* M2: bound lopdf predictor scratch to available samples

Return empty PNG predictor frames before allocating row buffers. Reject
overflowing packed TIFF row dimensions and bound component scratch to the
samples present in the input, aligning with the existing lopdf#589 fix.

Cover hostile dimensions and valid packed rows with synthetic regressions
and record the vendored patch and its upstream proposal.

Co-Authored-By: OpenAI Codex <codex@openai.com>

* Keep main's M1 roadmap row after merging main

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:21:21 -04:00
Dimitar 79eadc2aa8
M14: Bulgarian (Български) interface language (#466)
A complete clean-room catalog (2,035 entries: every command, All tools
label and tl! literal), registered as `bg` with one/other plurals so Auto
picks it on Bulgarian systems. Tests mirror the Russian ones (registration,
plurals, persistence, command and literal coverage), and Bulgarian joins
the Latin/Cyrillic font-coverage test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:16:16 -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
cyfung1031 3a35a7d6ac
M1: incremental saves are allocated once at their exact size (#402)
`write_incremental` copied the original into a buffer with 4 KB to spare,
then appended the revision. A revision above 4 KB (rotating a few dozen pages,
an image, a long comment thread) outgrew it. The buffer was then reallocated
at twice the size: the whole original was copied a second time, and the
result kept up to the original's size again unused. The engine keeps that
result as the document's working bytes, shared with the renderer and handed
to Save, so the unused half stayed alive. A 100 MB document held a 200 MB
allocation after one such edit, and still did after saving.

The revision is now written on its own, with offsets counted from where it
will start. The result is allocated once at exactly the original's size plus
the revision's, and filled in one pass. The output is byte for byte what it
was before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:16:01 -04:00
cyfung1031 d6c2a694ec
M3: rendered pixels are moved out of the pixmap, not copied (#404)
`render_page` copied every raster out of vello_cpu's pixmap with
`data_as_u8_slice().to_vec()`. That is a second buffer the size of the raster
(up to 256 MB for a 64 MP page), alive alongside the first for as long as the
copy runs. `Pixmap::take` hands over the pixel vector, and `bytemuck`
reinterprets its four one-byte channels as bytes in place: the same bytes, in
the same buffer.

On a 6182×8000 px page, peak RSS goes from about 425 MB to 246 MB and render
time from 102 ms to 81 ms. Pixels are identical: every page of #260's test
file, at two scales and as a tile, hashes the same before and after.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:15:55 -04:00
cyfung1031 e130af2ef4
M3: renders of a dropped render pool stop at the next operator (#409)
The engine replaces a document's render pool after every edit, undo, redo,
layer toggle and comment-visibility change, and drops it on close. Workers in
the middle of a page kept rendering it to the end before finding that nobody
could receive the answer. On heavy pages that is seconds of CPU per worker,
competing with the new pool re-rendering the same pages.

hayro-interpret, vendored patch 17: `InterpreterSettings::cancelled`, checked
before each content operator. The pool sets it only in `Drop`, when its result
channel is gone, so a partly drawn page can never be delivered. Live pools,
`PageRenderer` and inline pools never set it.

7 heavy pages on 7 workers re-rendered after replacing a busy pool: 2.4–2.5 s
before, 1.1 s after (1.0–1.3 s without a replacement, unchanged).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:15:47 -04:00
jcl-bit 8ce0a905bf
Never crash: dash patterns can't exhaust memory or hang the renderer (#405)
`cargo xtask fuzz` found a 392-byte page whose ExtGState sets
`/D [[11] 0]` on a line that starts at x = 92234775807. Stroke expansion
keeps every dash, so it cut the line into billions of them and allocated
more than 5 GB before the run was stopped. Triage found the same class
in ordinary-looking geometry, and a second bug next to it:

- A tiny dash on an ordinary line (`[0.000001] 0 d` on 20 pt) passed
  10 GB in under two seconds; so did `[1] 0 d` under a shrinking matrix,
  and a long pattern on many short subpaths, because kurbo restarts the
  pattern at every subpath. The render watchdog can skip a stuck page but
  can't stop it allocating, so on a desktop this ends in an abort that
  takes every open document with it.
- A dash array whose entries sum to less than zero (`[-1 -1] 0 d`) made
  kurbo's search for the starting dash loop forever. ISO 32000-2
  §8.4.3.6 requires nonnegative entries.

Vendored hayro patch (5, 6): `usable_dash_pattern` bounds the pieces a
pattern can cut the path into, in user space where kurbo dashes:
(control-polygon length / period + restarts) x entries per period, where
restarts are kurbo's: every MoveTo and ClosePath. Over 250,000 the stroke
is drawn solid, as it is for a pattern with a negative or non-finite
entry or a period that isn't positive. Measured in release builds: a
250,000-piece stroke takes about 16 ms and 75 MB at its peak (a million
took 80 ms and 290 MB). A non-finite phase becomes 0.

Every case now renders in about 0.2 s and a few MB. Synthetic regression
tests in render. Both were red before the fix (the first killed at
2.7 GB, the second stalled past 20 s), and four mutations each turn them
red: the first estimate (one piece per subpath) with a 100k cap runs out
of memory, a 100k cap draws a fine legitimate dash solid, dropping the
negative-entry check draws `[-1 3]` dashed, and dropping it together
with the period check hangs. (The period check alone is defence in
depth: hayro-interpret turns 0 entries into 0.01, so a pattern without
negative entries always has a positive period.)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:15:38 -04:00
feraremag2680 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 <soporte@codebit.es>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 09:15:32 -04:00
midasdf 57c281ca0a
M3: keep overflowed document tabs reachable (#416)
Refs #397. Scroll the tab strip, reveal the active tab and preserve the existing close-confirmation path.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)
2026-10-09 09:15:24 -04:00
midasdf c8c7959c0f
M4.6: search bookmark titles without changing the outline (#424)
Refs #257. Show matching entries and their ancestors, keep original edit paths and preserve per-document view state.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)
2026-10-09 09:15:18 -04:00
Wesley Haines 3543ca8296
Edit text: fast drags on a paragraph's edge no longer get dropped (#436)
The rewrap handle took the drag target from the pointer's current position.
egui only reports drag_started() once the pointer has moved 6 points (or the
button was held 0.8 s), so a quick outward flick had already left the 7x14 px
handle and the drag was ignored. Resolve the target from press_origin(), as
prepare.rs does, before the hover early-return.

Also: the left edge rewraps too (width plus an x offset), and edge grips span
the box's full height, 6 px either side of the edge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:15:13 -04:00
sanyamsingh001 561377eb1f
Keep document view state consistent when closing tabs and reverting (#439)
Preserve the selected document when an earlier inactive tab closes. Clear the reverted document's form drafts and queued edit only after a successful Revert.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-10-09 09:15:06 -04:00
pauliusiv123-rgb 3c771e0395
Issue #425: long file names and paths on Home end in "…" before the row's details (#443) 2026-10-09 09:15:00 -04:00
MarekSpodBiedry 5c5f4d8b49
Phone photos follow EXIF orientation; image signatures stay upright on rotated pages (#434) (#485)
* Stamps and added images follow a JPEG's EXIF orientation (part of #434)

Phones store a portrait photo as landscape pixels plus an EXIF
Orientation tag. A JPEG went into the PDF as it was (DCTDecode), and PDF
image XObjects have no such tag, so a photo used as a custom stamp, an
added or replaced image, a watermark or background, or a button image
showed up turned 90 degrees.

Before these images are embedded, a JPEG whose Orientation is not 1 is
decoded, turned the way the tag says and re-encoded (quality 92). Its
JFIF resolution is kept, swapped for quarter turns, so the natural size
on the page stays right. Other images, JPEGs without the tag, JPEGs that
fail to decode and photos over 64 megapixels are embedded unchanged, as
before. Fill & Sign image signatures already applied the tag.

* Image signatures stay upright and anchored on rotated pages (part of #434)

On a page with /Rotate 90, 180 or 270 an image signature turned with the
page, because its picture was drawn along user-space axes while the click
or the "at" argument is a displayed point. It was also anchored at the
wrong point: its left edge and vertical centre were taken in user space,
so on a 90 degree page the image centre ended up elsewhere.

The signature's appearance now carries the counter-rotation as its form
/Matrix, which viewers fit to /Rect, so the picture reads upright as
displayed. The page's rotation is kept in /PCPictureRotate, so restyling
regenerates the same matrix and moving and resizing keep it. The box of the
signature runs along the displayed axes, so its left edge and vertical
centre are where they were asked for. The page view matrix in
pdfcraft-model is split out as view_matrix_for.

The Fill & Sign preview and the live move/resize preview draw the picture
the way the appearance does, and the resize handles keep the image's
proportions as displayed. comment_image_preview reports image_rotation,
how far the image is shown turned (0 for one placed upright).

Custom image stamps are left to #473.

* Use is_multiple_of for the quarter-turn checks (clippy) (#434)

* Probe the pointer preview away from egui_kittest's drawn cursor (#434)

The kittest render paints a mouse cursor (a 16 px triangle with a black outline) at the
hover point, so the 2 pt probe beside the pointer landed on its outline when zoom and view
rotation made 2 pt only a pixel or two. Put the stroke's block at the picture's right end and
probe there and at its far margin instead.
2026-10-09 09:14:55 -04:00
Chen Tao 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 <noreply@anthropic.com>
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
Erige005 bc1d67cb61
Export to Word: detect tables of at most 63 columns, Word's limit (#479) (#483) 2026-10-09 09:14:34 -04:00
Hal Goff 7d9cc53472
Issue #225: a closed Comments panel stays closed when picking comment tools (#501)
Picking any comment tool opened the Comments panel whenever no right panel
was open, so closing it was undone by the next tool switch. Now closing
Comments (its close button or the rail icon) is remembered, across restarts,
and comment tools leave it closed until the user opens it again (rail, View
menu, Comments ▸ list, or a Reply action). Switching to or closing another
panel doesn't count. Opening a document with comments still shows them, as
Acrobat does.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:14:27 -04:00
feraremag2680 966bd0ffce
tests: pin the interface language so the suite passes outside English (#502)
The ui-egui integration tests drive the shell by its English labels, and
`i18n::system_lang` returns `Lang::EN` under `cfg!(test)` so they can. That
only covers the unit tests: an integration test links the library as it
ships, where `cfg!(test)` is off, so `PdfCraftApp::new()` leaves `language`
on `auto` and the shell is drawn in the system language. On a Spanish
Windows eleven of the test binaries fail; a French, Russian, Chinese,
Japanese, Portuguese, Czech or Telugu machine does the same. CI never sees
it because its locale is English.

`tests/theme.rs` and `tests/create.rs` already pin the option; the other
119 harnesses now do too, and the comment in `system_lang` says why, so a
test copied from an existing one keeps the line.

Also `i18n::tests::windows_display_language_query_returns_a_locale_tag`
treated the return of `windows_ui_language()` as a single tag. It is the
preferred-language list, one tag per line, as `first_supported` reads it,
so on a Windows with two or more display languages the assertion tripped
over the newline. It now checks the shape of every line.

Co-authored-by: Fernando Areán <soporte@codebit.es>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 09:14:19 -04:00