## What this changes
**File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files** as new, unsaved PDFs (`Report.docx` → `Report.pdf`), the "LibreOffice sidecar" that `docs/gaps.md` (gap 8) and `docs/file-format-parity.md` planned for `create.from-office`.
- **Conversion** (`crates/create/src/office.rs`): an installed LibreOffice runs headless as a separate process, in a private temporary folder (the input is always `document.<ext>`, so the user's file name never reaches its command line). It gets a fresh profile with `DisableMacrosExecution`, `MacroSecurityLevel` 3 and `BlockUntrustedRefererLinks` set, and Writer's and Calc's `Content/Update/Link` set to *Never* (2 for Writer and 1 for Calc: the two number their values differently), no console window on Windows (`CREATE_NO_WINDOW`), and is killed after 120 s. The output is size-capped (512 MB), must start `%PDF-`, and must open as a PDF before it is used. LibreOffice is found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders per OS. Without it, the user is told it's needed rather than "not a readable PDF".
- **Off the frame thread:** File ▸ Open converts on the existing open worker (`ResultKind::Converted`); the frame thread only opens the result.
- **Detection:** `SourceKind::Office` requires an Office extension *and* an OLE, ZIP or RTF signature, so `photos.zip`, or a PDF saved with a `.docx` name, are not misread.
- **Open dialog:** PDF (still the default), Images, Office documents, Text, All files. All files is left out on macOS, where rfd merges every filter into one allowed-types list, and on the web; Office documents are left out on the web. The extension lists are now shared constants in `pdfcraft-create` (`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS`, `OFFICE_EXTENSIONS`), used by both detection and the picker. The duplicate filter list in `open_dialog` is gone. The two new labels are in all 22 catalogues, with their `ATTRIBUTION.toml` hashes updated.
- **Automation:** `doc_create_multiple`, `doc_combine` and batch `convert` go through `convert_to_pdf`, so they take Office documents too; their descriptions say so.
**Deliberately not in this PR:** Insert pages, Combine files and Create ▸ Multiple files convert on the frame thread, so they now refuse Office documents with a pointer to File ▸ Open (`convert_to_pdf_inline` / `as_pdf_inline`) rather than freezing the window for seconds. Moving them onto a worker is the follow-up. `create.from-office` goes planned → **partial**, with that and the rest listed in its notes.
## How you verified it
On Windows 11 Pro with LibreOffice 26.8.1 installed, per crate:
- [x] `cargo fmt --check`
- [x] `cargo clippy -p pdfcraft-create -p pdfcraft-engine -p pdfcraft-ui-egui -p pdfcraft-automation -j 2 --all-targets -- -D warnings`
- [x] `cargo test -p pdfcraft-create -p pdfcraft-engine --lib` (42 and 276 pass); `-p pdfcraft-automation --test automation` (the combine/create tests); `-p pdfcraft-ui-egui --lib open_worker` and `--test editing --test create`
- [x] `cargo xtask layers`, `cargo xtask assets`, `cargo xtask parity --partial`
- [ ] wasm check: the `wasm32-unknown-unknown` target isn't installed here, so I'm leaving it to CI. The web paths are `cfg`'d and were reviewed by eye.
**Tests seen failing before the fix**, by reverting each part:
- Detection off → `office_documents_are_their_own_kind`, `source_kind_tells_pdfs_images_and_text_apart`, and both engine Office tests fail.
- Worker conversion off → `file_open_converts_an_office_document_on_the_worker` fails with the old user-facing message, *"the file is not a readable PDF: no readable page tree or cross-reference data was found"*.
New tests also cover a converter that hangs (it's stopped; the test binary stands in for LibreOffice), one that writes no PDF, a missing converter, temp-folder cleanup, `file://` URL escaping (spaces, drive letters, UTF-8), and the search order.
**Real conversions, end to end:** `crates/automation/tests/it/office.rs` builds a minimal DOCX, an XLSX (with a formula, `=6*7`), a PPTX and an RTF inside the test, so no binary fixtures are committed. It runs them through `doc_create_multiple` in `separate` mode (each output reopened and its text checked, including `42`) and in `combine` mode (page order and bookmarks), and through `doc_combine` beside a PDF, with a page range on one file. These tests, `libreoffice_converts_a_document_when_installed` and the process-tree cleanup test (5 out of 5 reruns) pass with LibreOffice 26.8.1. The Office tests skip where LibreOffice isn't installed, which includes CI.
Three existing tests used a 4-byte `report.docx` as their "file that can't be converted" example. That's no longer true, so they now use `archive.zip`, which keeps what they test.
## Never-crash
- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No input-derived indexing or slicing (`rsplit_once` for the extension); the PDF read back is size-capped
- [x] No `unsafe`. The Windows flag uses `std::os::windows::process::CommandExt`
- [x] A crashing, hanging or misbehaving LibreOffice is an error, never a panic or a stuck app: the worker's `catch_unwind`, a 120 s kill, and output checks
## Provenance
- [x] No Adobe assets; no fonts; no copied code
- [x] LibreOffice (MPL-2.0) is only *run* as an external process, as the docs planned; nothing of it is linked or copied. Its command-line options and the profile keys come from LibreOffice's public documentation.
- [x] The translations are contributor-original, written from the plain meaning of "Office documents" and "All files" ("Office" kept as a product name); hashes updated, `cargo xtask assets` passes
## Security
- [x] `--root` confinement unchanged: automation still resolves and reads paths before converting; only the private temp folder is written outside it
- [x] MCP and the control channel untouched
- [ ] **No new outbound network: please decide.** PdfCraft itself opens no connection. The profile blocks untrusted referer links and sets Writer and Calc never to update linked content on load. I converted eight synthetic files that link to a listener on 127.0.0.1: RTF `INCLUDETEXT` and `INCLUDEPICTURE`, a DOCX external picture, an ODT linked section and linked picture, an ODS table source and an external cell reference, and an ODP linked picture. None made a connection, with or without the link settings. Two things remain open: Impress has no link-update setting, and the process's network access isn't sandboxed (documented in `crates/create/README.md` and `docs/gaps.md`).
## Parity and reach
- [x] `create.from-office` planned → partial, with the evidence tests and what's still missing
- [x] Reachable headlessly (`doc_create_multiple`, `doc_combine`, batch `convert`)
- [x] Docs: `docs/file-format-parity.md` (Word/Excel/PowerPoint and RTF rows), `docs/gaps.md` gap 8, `docs/target-app-parity.md` row J (numbers not re-measured), `docs/roadmap.md` M10, the `ROADMAP.md` log, and the `crates/create` README
## Anything a reviewer should look at first
1. **The network question** above. It's the owner's call.
2. **The frame-thread restraint:** Insert, Combine and Create Multiple refuse Office documents rather than block. Is a worker for those wanted in this PR, or as a follow-up?
Merge origin/main. Conflicts: docs/changelog headers keep both histories;
edit/run.rs keeps EnhanceScan beside main's run_edit_page_image; optimize
images.rs keeps main's scaled() and the crate-visible jpeg(); the German
strings in ui-egui/src/i18n/de.tsv go with that file, which main moved to
craft-i18n. decode_samples now takes main's ImageBudget; a scan clean-up
run shares one sized by the file.
Review (CodeRabbit, scan_clean.rs): a page whose own images and a
scan-holding Form only together cover it now cleans the images and reports
the Form; a page whose images alone cover it reports its Form too. New
regression fails on the old classification.
Trim marks at the trim box's corners, dashed bleed marks at the bleed box,
registration targets, a colour bar and a page-information line, drawn outside
the trim box as a removable page mark (MarkKind::PrinterMarks); the page is
widened to make room when asked and the trim box stays, so the printed page
cuts the same. Edit::AddPrinterMarks (scope Full, undoable) and Remove printer
marks through Edit::RemoveMarks; Dialog::PrinterMarks reachable from the
catalog, the palette and --view printer-marks; tools doc_printer_marks and
doc_remove_marks kind printer_marks; rows in every language; parity
print.printer-marks shipped.
Carries the review fixes:
- ATTRIBUTION.toml: the language files' checksums after the new rows
- Printer marks: drop the unreachable AddPrinterMarks pattern in scope_of
- Tests: printer marks read the document's marks through upstream's marks() (#1434)
- Printer marks: make room inside the crop box and keep the trim box on rerun
- Format with rustfmt
- Printer marks: keep accented labels and make room for the bleed marks
- Printer marks test: the page is letter size, so the bleed marks (33 pt beyond the trim box) fit
- i18n(ui): carry this PR's new wordings in the catalogues that must cover them
- Parity: evidence paths follow the tests moved into tests/it
- Fix the build after merging upstream/main
- fix(printer-marks): refuse numbers out of range, print on every plate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A partial rewrite put the paragraph marker around the shared ET, so the neighbour in that text object joined the paragraph and the next edit deleted it.
The join limit follows the text object's TL when it has one, checked against the measured baseline gap. A rewrite of every row is marked so a new row one leading above the next paragraph does not absorb it.
A gap of 2.4 times the font size was still under the 2.5 times join limit, so two paragraphs became one and editing the first deleted the second. Once a paragraph has two rows, a wider gap than its own leading stays the next paragraph.
- A scan drawn only through grouped Form artwork is reported in skipped
(once per form, with every page that draws it) instead of being counted
as no scan; a Form of vector artwork holding only a small picture
(under 36 dpi over its area) still counts as no scan.
- decode_samples checks the filter before the colour space and bit depth,
so a JPEG 2000 image without /ColorSpace or /BitsPerComponent is
reported as JPEG 2000, not as a colour-space or bit-depth problem.
- Optimize and Enhance scanned file no longer start while the other runs:
they share the progress card and capture the same document state.
- docs/gaps.md: background and edge-shadow removal apply to grey and RGB
scans, despeckle also to 1-bit ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scan & OCR ▸ Enhance scanned file (Acrobat's Enhance ▸ Scanned document)
now cleans up scanned pages: background removal (the camera clean-up's
level 0-100), edge shadow removal (book gutters, copier borders) and
despeckle, each its own switch.
The camera tool renders the page and replaces it with a picture, which
would drop a scan's invisible OCR text, comments and links. Instead each
scan image (pictures covering at least half the page, each at least a
tenth) is decoded, cleaned and written back into its own image object at
the same size and colour space: a JPEG stays a JPEG (quality 90), other
grey/RGB images are stored losslessly, a 1-bit image stays 1-bit with its
decode array, and every other dictionary entry (SMask, Interpolate, ...)
is kept. An image several pages share is cleaned once. What can't be
cleaned (CMYK, indexed, JPEG 2000, JBIG2, CCITT, colour-key masks, broken
data, oversized images) is reported, never skipped silently; pages that
are no scan are listed too.
The engine works as a job like Optimize (scan_clean_job, run on a worker
with progress and cancel, apply_scan_clean refuses a result for an older
document state) and applies one undoable step, "Enhance scanned file".
pdfcraft-optimize exposes decode_samples/encode_samples for in-place
pixel edits. camera_clean gains clean_samples for grey and RGB samples
with separate switches and an edge-shadow pass; the camera tool's output
is unchanged. page_enhance_scan drives it headlessly.
Not yet an option of Recognize text. Refs #4119.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Record successful PdfCraft web application check
* E21: bound startup requests and validate font and license inputs
* E21: separate pending control completion and gesture regression phases
* M-egui037: enforce control admission and per-family font notices
* M-egui037: qualify capture fixes and document migration behavior
* egui-037: identify current and historical qualification results
* Allow OFL typefaces whoever designed them (AGENTS.md §1.1)
Noto CJK and Source Han from their open-source releases are allowed like any
other OFL font. The fonts build no longer skips them, the asset check accepts
an OFL font credited to Adobe, and the docs describe the Chinese faces that
result. The ban on Adobe icons, artwork, UI graphics and the fonts Adobe
products install stays.
* Leave out whole CJK faces other than the Japanese ones for size
A Chinese or Korean face is embedded whole only under 4 MiB, so the 16.4 MB
Noto Sans CJK SC in craft-fonts stays out of the binary and the embedded
total against the pinned craft-fonts is unchanged (25,204,828 bytes).
* docs: replacement boxes for Chinese without craft-fonts only on the web or without an installed Chinese face
* xtask assets: the OFL font exemption needs at least one licence ID
* fonts build: count a CJK script tag with a region (Hant-HK) as CJK, shared with the embedding test
* AGENTS.md: the asset gate exempts OFL fonts from the Adobe-author check
* fonts build: count a Hangul-only face (Hang) as CJK for the size rule
* fonts: the CJK embedding test checks the size rule, sharing its constants with build.rs
* docs: the missing Hans face is a property of the pinned craft-fonts, not of every build
* Build each crate's integration tests as one binary
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos,
crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and
the support modules move into tests/it/ too. Crates with a single test
file keep it. The test executables go from 150 to 52, and every crate
runs the same number of tests as before.
ui-egui perf keeps its own binary (own process): its frame-time budgets
would charge each measurement for the other tests running beside it.
What the move needs:
- include_str!/include_bytes! and #[path] paths gain one "../".
- ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file
ones (content, document_links, editing, fill_sign, view), so one
process creates one wgpu device at a time. The drop-line test in ui.rs
and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and
CRAFT_UI_DOCKING_DIR, which render without holding it, take it too.
- pdfcraft winit_wayland_scroll: the vendored winit source it compiles
names crate::event, so that shim lives in main.rs.
- parity evidence names the new paths.
* Name the one-binary test paths in docs and comments
- READMEs, docs, ATTRIBUTION and source comments name tests/it/<file>.rs.
- The oracle README's example filters with --test it encryption::qpdf_agrees.
- pdfcraft's GPU lock comment points at the UI tests' shared lock in
crates/ui-egui/tests/it/main.rs.
The track merges used a union driver for these files, which kept two
versions of a milestone row or status line wherever two tracks changed the
same one. They are recomputed as base plus each track's changes, with two
edits of one line combined into one line. The status lines that #2805, #3808
and #3532 put above the Stage line join the status list below it, newest
first.
Treat predefined CJK CMaps and Adobe CID collections as alternative mapping routes. Resolve indirect metadata and reject unknown names and foreign registries without changing ToUnicode handling. Add synthetic checker and read-only automation regressions with red/green evidence.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The fix landed as #2698 (9dfc91e0) with its parity evidence, but no
progress-doc record did: add the status line and progress-log entry in
ROADMAP.md, the status line, revision row and M8 milestone note in
docs/roadmap.md, and gap 6's evidence and revision history in
docs/gaps.md — the wrong-result backlog the issue belongs to.
Estimates unchanged.
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
`CLAUDE.md` asks for a progress-log line and a status line at the end of a session.
- The Annex D.2 alias codes, in both tables (`13e3154a`).
- Reading order in the text oracle (`50cbf5fa`), with gap 12's revision history.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`cargo xtask text-oracle` reported one number: word F1 against `pdftotext`. F1 is a bag of
words. It catches a character read wrong, a glyph not read at all, a font we cannot map -- and
it is blind to order, so a page emitted back to front scores a perfect 1.000. Reading order is
most of what text extraction has left to get wrong: a column read into the middle of its
neighbour (#2387), a figure's labels threaded through a paragraph (#2586), a form's value parted
from its label.
The hole is not hypothetical. Reviewing #2431, a change that moves text and alters nothing else,
**word F1 called 26 of the 29 files whose output changed identical**, while scoring the word
sequence separated 18 improvements from 6 regressions and named the shape of the regressions
(forms: `prefilled_f1040.pdf` 0.672 -> 0.481, a checkbox's number parted from its label). The
harness could not see the only thing that change does.
So a second score per file: the longest common subsequence of the two word sequences, as a share
of the longer one. It shows up in the first three corpus files -- `Test-plusminus.pdf` scores F1
1.000 and reading order 0.875, the same forty words in a title block that we read down the
column and `pdftotext` reads across.
Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages
is plenty of signal; the run says how many files that bit, so the cap cannot quietly become the
measurement. It reports; it does not gate, and like the pixel score it is a *disagreement* with
Poppler rather than a verdict -- in the #2431 measurement the same split scored as a win on one
file and a loss on another purely because `pdftotext` makes it in one place and not the other.
The function's doc comment, `crates/oracle/README.md` and `docs/gaps.md` gap 12 all say so.
`words` now takes the sequence rather than re-splitting the text, so both scores read the same
tokens. `docs/file-format-parity.md` claimed plain-text export was good "(word-F1 0.98)" in a row
whose own column is reading order; it now says the two are scored separately.
Verified: `cargo xtask text-oracle --limit 60` end to end; `cargo test -p xtask` 49 passed; fmt
and clippy clean. The unit test was watched failing with the score blinded to order (sorting both
sides made it 1.000 where it must be below 0.7). The corpus-wide first reading is not in this
commit: the machine is shared with a parallel build and the full sweep was stopped twice for
memory.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* M12: tag unstructured AcroForm widgets
* i18n: translate form tag command
* i18n: repair form-field label merges
* fix(i18n): refresh translation catalog hashes
* fix(i18n): preserve main translations after rebase
* Merge main; tag a widget listed on several pages once, docs say existing trees are extended
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Merge main; add the Vietnamese catalog entry for Tag form fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep selected form values and page content through later edits (#1319, #1305, #1324, #1306)
An XFA radio item whose text contains a space was written back as an appearance-state name, so the next layout selected nothing. Replace Pages filled the gap between a split selection. Marking a figure decorative replaced every content stream on the page. Control clicks and drags accepted coordinates that overflow to infinity.
* Move the decorative-figure regression out of pdfcraft-edit
edit and a11y are both layer 4, so a dev-dependency from edit to a11y fails the layer check. The same regression now runs in the engine, which already depends on both.
* Drop pdfcraft-a11y from the edit crate's lockfile entry
* Run a tool after a background open of the file it asked for
A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker.
* Wait for background opens in the command tests
Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists.
* Compare batch copies to the PDF that was written
A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M14: correct parity checklist statuses (gap 24)
* M14: gap 24 parity fix-ups: localization reasons, stale Czech claim, split table
- misc.localization notes: drop the claim that Czech and Brazilian Portuguese
lack dialog coverage (cs.tsv 2,314 and pt-br.tsv 2,255 entries after #1066);
give the real reasons it is partial (Chinese tofu #826/#688, no Telugu release
font, Arabic not mirrored, English fallback). The language is settable through
the UI control channel, only not through an automation tool; 97 shipped
features have no tool, so that alone is not why it is partial.
- target-app-parity.md: header said localization "shipped"; it is partial.
- Mainstream/specialist split recomputed from the checklist so the rows add up
to the All row (508 + 304 = 812), with the slice rule written down.
- ROADMAP.md progress-log entry in the house format, noting that the breadth
move also counts earlier merges' checklist changes.
cargo xtask parity passes; the three cited i18n tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAUEUD5qJBoJd9rR3jyegt
* Merge main; keep only the parity status changes, drop stale percentages
Restore main's ROADMAP/docs numbers and header lines, keep the two
misc.localization/misc.installers status changes (22 catalogs now), fix the
gap 24 text and add a progress log line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Every way into the page grid (toolbar, tool panel, page.organize, --organize,
Create multiple) goes through DocView::open_organize, which drops a selection
left from earlier that does not include the page being read and scrolls that
page's cell into view, computed from the row so virtualized rows need not be
laid out. Grid clicks navigate the document as Pages-panel clicks do, so the
viewer no longer resets the current page from its scroll position when the
grid closes.
Regression: crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page
(middle page, last page, reopening after a selection elsewhere).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* G14: Vietnamese catalog — menus, file and shell chrome
Add the Vietnamese (vi) TSV catalog header and translations for menus,
File/Edit/Help chrome, clipboard and common shell labels. PhotoCraft and
VectorCraft catalogs used only as translation memory when English source
and context matched. ATTRIBUTION records the asset hash.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: Vietnamese catalog — forms, signatures and pages
Translate form/Fill & Sign/certificate wording and page organize/rotate/
extract/split labels. Keep product and format names unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: Vietnamese catalog — comments, view, a11y and contexts
Translate comments/markup, view/zoom/preferences tools, accessibility and
preflight sentences, plus contextual rows (certificate, calendar, keys,
scroll speed, signature pad, action wizard, @plural one-form).
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: Vietnamese catalog — remaining dialogs and notices
Complete the Vietnamese catalog to every (context, English) key in the
bundled union (2,580 entries). No unapproved English UI fallbacks remain
outside the documented VI_KEEP_AS_IS list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: restore complete Vietnamese catalog key order
Replace the domain-appended catalog with the verified 2,580-entry union
in stable key order so coverage matches every bundled reference key.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: register Vietnamese interface and date language
Add `vi` / Tiếng Việt to LANGUAGES and DATE_LANGUAGES with plural_none
and rtl false. Locale tags vi, vi-VN and vi_VN.UTF-8 resolve. Calendar
uses Tháng Một / tháng Một; Fill & Sign refuses non-WinAnsi month names.
Coverage tests require full command, literal, key and credits coverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: document Vietnamese localization
Record Vietnamese at 100% catalog coverage in localization-parity and
ROADMAP, list Tiếng Việt in Preferences/docs, and note the G14 catalog
in the progress log. Engine/OS details, document contents, filenames,
CLI/MCP and external help remain out of scope by design.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: use ASCII > in Vietnamese preference paths
Inter lacks U+25B8 (▸). Match other Latin catalogs and write preference
paths as "Tùy chọn > …" so the glyph coverage test stays green.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: finalize Vietnamese terminology and current coverage
Disambiguate theme Appearance (Giao diện) from object look (Diện mạo),
normalize form Field to Trường, make redaction apply wording permanent,
use numeric calendar months, close the GoToR/digital-ID key gap, and
pin regression assertions. Refresh the vi.tsv attribution hash.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: align localization docs with Vietnamese coverage
Recompute the twelve-language mean (≈59% → ≈68%), document 23 interface
languages, and remove stale claims that Vietnamese is missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: cover Wayland title-bar strings in Vietnamese
Translate Minimize/Maximize/Restore and the system title-bar preference
added on upstream main, and refresh the catalog attribution hash and counts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: correct current union key count in localization docs
Co-authored-by: Cursor <cursoragent@cursor.com>
* G14: prepare Vietnamese localization for review
Trim the vi.tsv header to maintainer-oriented notes, record native-speaker
community review in attribution and localization docs, and refresh the
weighted readiness arithmetic (localization ≈68% → ≈50%).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: DUY Tran Nhat (SAE-KIDS) <duytn@sanancorp.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The render oracle put four JBIG2 fixtures at the top of its disagreement list against Poppler, and
the obvious reading was that hayro-jbig2, which carries local patches, had a bug. Investigating it
found the reverse.
The 96 `bitmap-*.pdf` corpus files are a conformance suite: one small drawing encoded through every
corner of JBIG2 -- each generic-region template, custom AT pixels, TPGDON and TPGRON, refinement,
halftone with and without skip, symbol dictionaries with Huffman tables, MMR, the composition
operators, random access, unknown-height segments. Those features change how the bytes are
compressed, not what the picture is, so a correct decoder renders all 96 identically. That is a
real test with no reference image and no external oracle: the suite checks itself.
We render 1 distinct image across all 96. Poppler renders 8: it draws a blank page for the seven
halftone-skip and symbol fixtures, and its output changes when TPGRON is switched on, which a
correct decoder's cannot. So roughly a fifth of Poppler's JBIG2 output is wrong, the top of the
oracle's worst list is its bug rather than ours, and Poppler is not a usable oracle for JBIG2.
Watched failing by admitting one unrelated file to the set: the test fails and names it as the odd
one out rather than merely reporting a count.
The expectation holds of the pinned corpus. Whoever re-pins it to a newer pdf.js should re-check
this rather than assume it still holds, which the test's own documentation says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Investigating the worst file of the first 250 turned the headline finding around. bug1552113.pdf
scores 84%, and the cause is Poppler: it paints the link's `/Border [0 0 112]` as a 112-unit band
across a 250x50 page, hiding the two lines of text. The fixture is named "this text should be
visible", and in our rendering it is. The oracle is not ground truth, and the top of its worst
list needs a person to look at both images before it is read as a bug of ours.
That led to a plausible change that the measurement rejected. Poppler synthesises appearances for
annotations that have none, where we draw only what the document carries -- `/AP`-less link borders
are already recorded as not covered under view.render-annotation-appearances -- so hiding
annotations looked like the way to measure page content alone. It is worse: it then scores Poppler
for every appearance we do draw. Over the same 250 files, shown gives mean 2.16%, p90 5.35% and 25
files within 0.1%; hidden gives 2.18%, 5.55% and 22, with annotation-tx3.pdf jumping to 45.64% and
annotation-tx2.pdf to 18.79%. The two biases nearly cancel.
So the default is unchanged and `--hide-annotations` is an opt-in for explaining a single file,
with the numbers written down so the experiment is not repeated. Verified that the default
reproduces the merged baseline exactly and that the flag moves it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gap 12 of docs/gaps.md: until rendering is measured, every claim about its quality is a judgement.
Text extraction had an oracle and written output had qpdf; rendering had nothing.
`cargo xtask render-oracle` renders page 1 of every corpus file with our CLI and with Poppler's
pdftoppm and reports how far apart they are. The metric is the point. A raw pixel comparison is
useless here -- two correct renderers disagree on every glyph edge, so it calls a perfect page
badly wrong and says nothing about whether a shading is missing. `pixels::compare` counts a pixel
as differing only when it differs from every pixel in the other image's 3x3 neighbourhood by more
than a tolerance, in both directions, taking the worse. A one-pixel shift is forgiven; absent or
miscoloured content is not.
First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean 2.16%, p90
5.35%, 25 within 0.1%. It reports rather than gates -- the corpus is not on CI, and a first
reading of a borrowed renderer is a baseline to improve on, not a bar to clear. The worst results
cluster on JBIG2 `bitmap-*` files and on ZapfDingbats, which aims the renderer work at something
specific for the first time. 15 files could not be compared because the two renderers round the
page box to different pixel sizes; they are reported as unmeasurable rather than scored.
Verified against the knob's bounds end to end: tolerance 255 scores every file 0.00%, tolerance 0
takes the median to 0.95% and p90 to 26.36%.
Also turns on `extract_type3_shared_xobjects_matches_poppler_pixels`, which had never run anywhere.
Poppler is now installed on the Linux runner, and the test is version-robust by construction --
both sides of its comparison go through the same pdftoppm, so it is about our two documents rather
than about Poppler's output. Verified in all three states: runs and passes with pdftoppm present,
skips without it, fails without it when PDFCRAFT_ORACLES_REQUIRED is set.
`run_capture` keeps its exact behaviour, including leaving the buffer empty on non-UTF-8 output;
the byte-oriented capture an image needs is a second entry point on the same timeout logic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stamp placed on a page with /Rotate was drawn in user space, so the
label or picture turned with the page. Counterrotate the appearance so
it reads upright as the page is displayed.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Flattening deleted a cloudy comment it could not draw, and a refused text-box restyle or resize left the new properties in the file. Arrow defaults were stored on plain lines. A custom match ending on a separator included the next glyph, and the vertical-text redaction error claimed an overlap it had not checked.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(edit): move existing text by its frame, and selections with the arrow keys (fixes#1520)
* docs(parity): arrow-key nudging and the paragraph frame are no longer gaps (#1520)
Glyph boxes of tightly spaced lines overlap, so replacement treated the next line as a duplicate and removed it. A second drawing is removed only when it also shares the edited line's baseline.
* M12: preview text structure proposals for auto-tagging
* Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check
The preview is read-only with no UI command yet, like render_preview, so it no
longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M12: preview text structure proposals for auto-tagging
* Keep read-only auto-tag preview separate from unavailable command
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M2: snap shrunk interpolated images to whole pixels so scans stay sharp
A scanned page (one 300 dpi image with /Interpolate true) was shrunk to a
ceil-sized raster and then sampled a second time at a fractional scale and
offset, which softened every edge next to Acrobat. An axis-aligned shrunk
interpolated image is now shrunk once to its rounded device rectangle and
drawn 1:1 at the rounded corner. Rotated, skewed, flipped, Type 3 and
non-interpolated images are unchanged.
On a synthetic scan page the mean error against an area-average reference
falls from 3.4 to 1.6 grey levels at 96 dpi (3.0 to 1.5 at 144 dpi).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* vendor: list the hayro snap patch as (14) in the Patches and Tests columns; correct gaps.md patch counts
The patch description had been appended to the Tests column of one of the
two hayro rows. gaps.md now counts the distinct patches vendor/README.md
lists (26 hayro-interpret, 15 hayro, 3 hayro-jbig2, 17 hayro-syntax).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Gates: fix two clippy failures on main
- ocr_ui: "Also recognize pages that already have text" used egui's own
checkbox, which clippy.toml disallows (#1184 landed beside #1173);
use widgets::checkbox like every other box.
- camera_edges: Rust 1.97's manual_range_contains; the rewrite keeps the
old answer for a NaN angle (false) with an is_finite check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ojKsSCHWk2AuyDTFMHCd7
* M10: print from the web build through the browser's print dialog (gap 4)
The web build had no printer: spool::submit refuses on wasm, so Print
could only download the print-ready PDF. Now its Print dialog offers
"Browser's print dialog" (the default there), which reaches every
printer the operating system knows.
- web_print: PdfCraft draws the print-ready PDF's sheets itself
(engine Exporter), one sheet a frame since wasm has no threads:
300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most
64 MP a sheet and 1 GiB of PNGs a job. The PNGs become blob URLs
and print from a hidden srcdoc iframe, one sheet per page, margin 0.
- Chromium prints every page at the default @page size, so a sheet of
the paper's size turned sideways is rotated 90 degrees CCW onto it
(as drivers do); other sizes get named @page rules.
- Copies, two-sided and colour are greyed out (the browser's dialog
sets them). If print() is refused (e.g. a sandboxed frame), the
print-ready PDF downloads instead, with the reason.
- pdfcraft-print stays free of web APIs; six new strings in all 20
catalogs (ATTRIBUTION hashes refreshed); docs and parity updated,
gap 4 closed.
Verified in headless Chromium against a trunk build: three sheets at
300 dpi, the landscape sheet turned, no blank trailing page, and the
download fallback. Firefox and Safari not yet tried.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ojKsSCHWk2AuyDTFMHCd7
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M0: keep the camera edge test NaN-safe under clippy's manual_range_contains
Clippy 1.97 flags `d < 0.35 || d > PI - 0.35` in the camera edge hit test. Its suggested
`!(0.35..=PI - 0.35).contains(&d)` would count a NaN direction as a hit, which the two
comparisons did not, so the NaN check is kept explicitly. Behaviour is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm
* M14: edits let go of the previous working file before writing the next (gap 13)
Every edit writes a new working file: the original plus one appended revision. The previous
one stayed alive in the working bytes, the display bytes and the renderer while the next was
written, so after a few edits a 1 GiB file peaked at three copies.
Each refresh path (full, comments/form, content, undo, redo, after save) now releases them
first: the working and display bytes fall back to the original (already held by the editor)
and the renderer is replaced by an empty one until the refresh rebuilds it. A refresh that
still fails after rollback draws the original instead of nothing. Save still writes from the
editor, never from these.
Measured on a synthetic 1 GiB file (release build, Linux): five edits peak at 2.1 GiB instead
of 3.1 GiB, in 8.5 s instead of 16 s. Saving still peaks at three copies; reading from disk on
demand stays blocked on the renderer (hayro parses one contiguous buffer, gap 2). gaps.md,
the parity notes and the roadmap record both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm
---------
Co-authored-by: Claude <noreply@anthropic.com>