## 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?
pdfcraft-engine
The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates.
word_count::count provides cancellable, page-at-a-time Unicode word and character statistics
with optional semantic exclusions; see word-count behavior and limits.
- Layer: L6. It depends on most of L1–L4 and on nothing above itself. No UI toolkit —
egui,winit,eframeandrfdare forbidden below L7, andxtask layersenforces it. - The crate denies
unwrap/expect/panic!/todo!. Entry points that run untrusted-document code keep a last-resortcatch_unwindguard so an escaped panic becomes an error and the user keeps their document.
Two frontends exist on top of it today: pdfcraft-ui-egui (desktop and web) and
pdfcraft-automation (headless tools, the CLI and MCP). Anything one can do, the other must be
able to do — see AGENTS.md §3.
When dynamic XFA layout is active, JsRunner does not initialize Acrobat JavaScript with
scripts from the PDF container's /Names /JavaScript tree. Those scripts belong to the
fallback PDF view and can otherwise show a misleading viewer-compatibility warning.
They remain in the document and in Document::document_scripts. Requested console scripts,
field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells
without an active layout keep their document scripts. Converting XFA to a static form and
undoing that conversion switches the context with the document state.
The editing model
Each open document holds a pdfcraft_cos::Document: the object graph plus a copy-on-write overlay.
- An edit runs on a clone. On success the previous state is pushed onto the undo stack. Clones share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo affordable on a 200 MB file.
- After every edit the working file is produced by an incremental write: the original bytes plus one appended revision.
- The view is refreshed from those bytes, so what you see is exactly what Save will write.
- Saving rebases onto the written bytes, so the next save appends only new edits.
Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed password); and unknown data survives a round trip because nothing is reconstructed that wasn't understood.
API
let mut s = Session::new();
let id = s.open("report.pdf", Some(path), bytes, None)?; // -> DocId
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?;
let label = s.undo(id)?; // "Rotate pages"
s.redo(id)?;
let doc = s.get(id).ok_or("gone")?; // &Document: queries, never edits
doc.can_undo(); doc.editable(); doc.allows_printing();
doc.text_blocks(page); doc.page_boxes(page); doc.security_summary();
let bytes = s.save_bytes(id)?; // incremental: keeps signatures over earlier revisions valid
let bytes = s.save_full_bytes(id)?; // full rewrite, garbage-collected
s.mark_saved(id, bytes.clone(), path)?;
Edit is one enum of 75 variants — every undoable change in the product, from
RotatePages to AddAnnotation, SetFieldValue, ApplyRedactions and AddWatermark. Adding a
feature means adding a variant and handling it in apply, which is what keeps undo, the dirty
flag, autosave and the automation tools consistent for free.
Session also owns the operations that produce a new document rather than editing one:
create_blank, create_from_images, create_from_text, combine, extract, split,
split_by_size, reduced_bytes, optimized_bytes, print_pdf, export_data, and the signing
family (sign, sign_with_timestamp, timestamp_document, embed_ltv).
Modules
| Module | What it holds |
|---|---|
commands |
The command registry: id, label, menu, shortcut, and a Needs precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled |
catalog |
Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an Availability of Ready, Planned(milestone) or Provider. The UI renders what is not built yet, honestly |
actions |
PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
links |
Link creation, properties, and finding URLs in page text |
open_parameters |
A path's PDF open parameters (manual.pdf#nameddest=Intro, #page=12): the file to open and where to show it. The app and doc_open both read them |
js |
The bridge to pdfcraft-js. forms never depends on js directly; the engine injects it, so a build without JavaScript is possible |
xfa, ocr, compare, export |
Façades over the L3/L4 crates of the same names |
AcroForm Keystroke, Validate, Calculate and Format scripts run automatically. Their Print, ShowPanel and SaveAs requests are ignored with a JavaScript console diagnostic; field values, alerts and other requests are retained. Explicit button and console scripts can still request printing (#4088).
Not done here
- Rendering is still borrowed.
pdfcraft-renderwrapshayroand inspects throughlopdf(ADR-0004). The engine only seesinspect() -> DocInfo,RenderPoolandtext::PageText, and that API stays frozen while M2 replaces what is behind it. - Performance budgets (
misc.performance-budgets) are not set or enforced. - Some shipped features are still reachable only from the UI, not through an
automationtool;cargo xtask paritylists them; closing that gap is tracked in the execution plan (D4).
doc.text_edit_preview(page, block) prepares a read-only page background with the selected
paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the
working document’s render configuration and never enters the undo history.