M10: open Word, Excel and PowerPoint files through LibreOffice

## 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?
This commit is contained in:
Max Abramowitz authored and GitHub committed 2026-11-11 11:06:01 +01:00
1 parent 4efcfa6ea3
commit 3a117c3c0e
30 files changed
+1290 -121

No files matched your search

Generated
+31 -30
View File
@@ -238,7 +238,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -321,7 +321,7 @@ dependencies = [
"futures-lite",
"parking",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"slab",
"windows-sys 0.61.2",
]
@@ -352,7 +352,7 @@ dependencies = [
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -378,7 +378,7 @@ dependencies = [
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.5",
"rustix 1.1.6",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
@@ -878,7 +878,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"slab",
"tracing",
]
@@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.4",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-client",
]
@@ -2050,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2427,7 +2427,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"windows-link",
]
@@ -4172,7 +4172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4338,7 +4338,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
"rustix 1.1.5",
"rustix 1.1.6",
"serde_json",
"sha2 0.11.1",
"tempfile",
@@ -4357,7 +4357,7 @@ dependencies = [
"pdfcraft-preflight",
"pdfcraft-render",
"png 0.18.1",
"rustix 1.1.5",
"rustix 1.1.6",
"serde_json",
"winresource",
]
@@ -4405,6 +4405,7 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-xfa",
"png 0.18.1",
"rustix 1.1.6",
"thiserror 2.0.21",
"tiff",
"zune-jpeg",
@@ -4625,7 +4626,7 @@ dependencies = [
"async-channel",
"futures-lite",
"log",
"rustix 1.1.5",
"rustix 1.1.6",
"zbus",
]
@@ -4807,7 +4808,7 @@ dependencies = [
"pollster 1.0.1",
"proptest",
"rfd",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"skrifa 0.44.0",
@@ -5143,7 +5144,7 @@ dependencies = [
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -5401,7 +5402,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5953,15 +5954,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.5"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6031,7 +6032,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6655,7 +6656,7 @@ dependencies = [
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
@@ -6920,8 +6921,8 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"rustix 1.1.6",
"windows-sys 0.52.0",
]
[[package]]
@@ -7673,7 +7674,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.5",
"rustix 1.1.6",
"scoped-tls",
"smallvec",
"wayland-sys",
@@ -7686,7 +7687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-scanner",
]
@@ -7708,7 +7709,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-client",
"xcursor",
]
@@ -8046,7 +8047,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -8404,7 +8405,7 @@ dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
@@ -8462,7 +8463,7 @@ dependencies = [
"libc",
"libloading",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"x11rb-protocol",
]
@@ -8504,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -8611,7 +8612,7 @@ dependencies = [
"hex",
"libc",
"ordered-stream",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_repr",
"tracing",
+2
View File
@@ -87,6 +87,7 @@
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M10, gap 8: Office documents open through an installed LibreOffice; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -494,6 +495,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged.
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-17 (M10, gap 8):** File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files as new, unsaved PDFs, converted on the open worker by an installed LibreOffice (headless, a private temporary folder and fresh profile with macros off, stopped after 120 s); without LibreOffice the user is told it is needed. The Open dialog lists PDF (the default), Images, Office documents, Text and All files. Insert, Combine and Create ▸ Multiple files refuse Office documents for now, since they convert on the frame thread. `create.from-office` planned → partial. On 2026-10-26 the profile also stopped Writer and Calc from updating linked content (Impress has no such setting), and tests that skip without LibreOffice convert generated DOCX, XLSX, PPTX and RTF files through `doc_create_multiple` and `doc_combine` and check their text. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
+2 -2
View File
@@ -347,7 +347,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000), .txt files and Office documents (Word, Excel, PowerPoint, OpenDocument, RTF; through an installed LibreOffice) are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
.cmd("page.combine")
.with(schema(
json!({
@@ -361,7 +361,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["paths"],
)),
t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")
t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files, Office documents through an installed LibreOffice) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")
.cmd("create.multiple")
.with(schema(
json!({
+6 -6
View File
@@ -1226,7 +1226,7 @@ fn combine_takes_images_and_text_files() {
std::fs::write(dir.join("scan.png"), png).unwrap();
std::fs::write(dir.join("notes.txt"), "Some notes").unwrap();
std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap();
std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap();
std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap();
let mut a = auto(&dir);
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "open": true }));
let doc = combined["document"]["doc"].as_u64().unwrap();
@@ -1236,7 +1236,7 @@ fn combine_takes_images_and_text_files() {
let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect();
assert_eq!(titles, ["a", "scan", "notes"]);
// A file that can't be made into a PDF stops the combine, and says which.
for bad in ["broken.png", "report.docx"] {
for bad in ["broken.png", "archive.zip"] {
let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] }));
assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}");
}
@@ -5452,7 +5452,7 @@ fn creating_from_multiple_files_through_tools() {
}
std::fs::write(dir.join("scan.png"), png).unwrap();
std::fs::write(dir.join("notes.txt"), "hello").unwrap();
std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap();
std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap();
let mut a = auto(&dir);
// Combine: every file converted, in the order given, with a bookmark per file.
@@ -5472,8 +5472,8 @@ fn creating_from_multiple_files_through_tools() {
assert_eq!(titles, ["notes", "a", "scan"]);
assert!(dir.join("all.pdf").is_file());
// A file that can't be converted fails the whole combine, naming it.
let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err();
assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}");
let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "archive.zip"] })).unwrap_err();
assert!(matches!(&err, ToolError::Failed(m) if m.contains("archive.zip") && m.contains("can't be converted")), "{err}");
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_))));
@@ -5482,7 +5482,7 @@ fn creating_from_multiple_files_through_tools() {
// existing file is never overwritten.
std::fs::create_dir_all(dir.join("out")).unwrap();
std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap();
let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" });
let args = json!({ "paths": ["notes.txt", "archive.zip", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" });
let made = ok(&mut a, "doc_create_multiple", args);
let files = made["files"].as_array().unwrap();
assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}");
+1
View File
@@ -15,6 +15,7 @@ mod hostile_args;
mod list_selection;
mod model_3d;
mod model_render;
mod office;
mod optimize_content_boundaries;
mod portfolio_info;
mod redact_after_stray_q_form;
+277
View File
@@ -0,0 +1,277 @@
//! Office documents converted by a real LibreOffice through the tools that take them
//! (`doc_create_multiple`, `doc_combine`), with the converted text checked. Every test here
//! skips when LibreOffice isn't installed, which is the case on CI; run them on a machine that
//! has it. The Word, Excel and PowerPoint files are written here, as the smallest packages
//! LibreOffice reads, so no binary fixtures are committed.
use std::path::Path;
use pdfcraft_automation::{Automation, Content, ToolError};
use serde_json::{Value, json};
fn auto(dir: &Path) -> Automation {
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// Each page's text, with runs of whitespace made single spaces.
fn page_text(a: &mut Automation, doc: u64) -> Vec<String> {
ok(a, "text_extract", json!({ "doc": doc }))["pages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["text"].as_str().unwrap().split_whitespace().collect::<Vec<_>>().join(" "))
.collect()
}
/// A ZIP of `entries`, stored without compression (Office packages may be).
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
let (mut out, mut central) = (Vec::new(), Vec::new());
for (name, data) in entries {
let (crc, size, offset) = (crc32(data.as_bytes()), data.len() as u32, out.len() as u32);
// version 2.0, no flags, stored, 1980-01-01 00:00
let common = [&20u16.to_le_bytes()[..], &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0x21u16.to_le_bytes()].concat();
let sizes = [crc.to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat();
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&common);
out.extend_from_slice(&sizes);
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data.as_bytes());
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes());
central.extend_from_slice(&common);
central.extend_from_slice(&sizes);
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
// extra, comment, disk, internal and external attributes
central.extend_from_slice(&[0u8; 12]);
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let (at, count) = (out.len() as u32, entries.len() as u16);
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0u8; 4]);
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&at.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
const XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
const PACKAGE_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const DOC_RELS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
/// `[Content_Types].xml` with one override per (part, type).
fn content_types(parts: &[(&str, &str)]) -> String {
let overrides: String = parts.iter().map(|(part, kind)| format!(r#"<Override PartName="/{part}" ContentType="{kind}"/>"#)).collect();
format!(
r#"{XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>{overrides}</Types>"#
)
}
/// A relationships part with one relationship per (id, type under officeDocument, target).
fn rels(list: &[(&str, &str, &str)]) -> String {
let items: String =
list.iter().map(|(id, kind, target)| format!(r#"<Relationship Id="{id}" Type="{DOC_RELS}/{kind}" Target="{target}"/>"#)).collect();
format!(r#"{XML}<Relationships xmlns="{PACKAGE_RELS}">{items}</Relationships>"#)
}
/// A Word document of one paragraph.
fn docx(text: &str) -> Vec<u8> {
let document = format!(
r#"{XML}<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>"#
);
zip(&[
(
"[Content_Types].xml",
&content_types(&[("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")]),
),
("_rels/.rels", &rels(&[("rId1", "officeDocument", "word/document.xml")])),
("word/document.xml", &document),
])
}
/// An Excel workbook whose one sheet holds `text` in A1 and a formula, `=6*7`, in A2.
fn xlsx(text: &str) -> Vec<u8> {
let workbook = format!(
r#"{XML}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{DOC_RELS}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let sheet = format!(
r#"{XML}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>{text}</t></is></c></row><row r="2"><c r="A2"><f>6*7</f></c></row></sheetData></worksheet>"#
);
zip(&[
(
"[Content_Types].xml",
&content_types(&[
("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"),
("xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"),
]),
),
("_rels/.rels", &rels(&[("rId1", "officeDocument", "xl/workbook.xml")])),
("xl/workbook.xml", &workbook),
("xl/_rels/workbook.xml.rels", &rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")])),
("xl/worksheets/sheet1.xml", &sheet),
])
}
/// A PowerPoint presentation of one slide with one text box.
fn pptx(text: &str) -> Vec<u8> {
const P: &str =
r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#;
let presentation = format!(
r#"{XML}<p:presentation {P} xmlns:r="{DOC_RELS}"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>"#
);
let tree = r#"<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>"#;
let master = format!(
r#"{XML}<p:sldMaster {P} xmlns:r="{DOC_RELS}">{tree}</p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>"#
);
let layout = format!(r#"{XML}<p:sldLayout {P}>{tree}</p:spTree></p:cSld></p:sldLayout>"#);
let slide = format!(
r#"{XML}<p:sld {P}>{tree}<p:sp><p:nvSpPr><p:cNvPr id="2" name="Text"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="6000000" cy="1000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:rPr lang="en-US" sz="2800"/><a:t>{text}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#
);
let theme = format!(
r#"{XML}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Plain"><a:themeElements><a:clrScheme name="Plain"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="000000"/></a:dk2><a:lt2><a:srgbClr val="FFFFFF"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="Plain"><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Plain"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#
);
const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml";
let types = content_types(&[
("ppt/presentation.xml", &format!("{PML}.presentation.main+xml")),
("ppt/slideMasters/slideMaster1.xml", &format!("{PML}.slideMaster+xml")),
("ppt/slideLayouts/slideLayout1.xml", &format!("{PML}.slideLayout+xml")),
("ppt/slides/slide1.xml", &format!("{PML}.slide+xml")),
("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"),
]);
zip(&[
("[Content_Types].xml", &types),
("_rels/.rels", &rels(&[("rId1", "officeDocument", "ppt/presentation.xml")])),
("ppt/presentation.xml", &presentation),
(
"ppt/_rels/presentation.xml.rels",
&rels(&[
("rId1", "slideMaster", "slideMasters/slideMaster1.xml"),
("rId2", "slide", "slides/slide1.xml"),
("rId3", "theme", "theme/theme1.xml"),
]),
),
("ppt/slideMasters/slideMaster1.xml", &master),
(
"ppt/slideMasters/_rels/slideMaster1.xml.rels",
&rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")]),
),
("ppt/slideLayouts/slideLayout1.xml", &layout),
("ppt/slideLayouts/_rels/slideLayout1.xml.rels", &rels(&[("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])),
("ppt/slides/slide1.xml", &slide),
("ppt/slides/_rels/slide1.xml.rels", &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")])),
("ppt/theme/theme1.xml", &theme),
])
}
/// An RTF document of one paragraph.
fn rtf(text: &str) -> Vec<u8> {
format!("{{\\rtf1\\ansi{{\\fonttbl\\f0\\fswiss Helvetica;}}\\f0\\pard {text}\\par}}").into_bytes()
}
/// A folder with one Word, Excel, PowerPoint and RTF file, each carrying its own word, and the
/// tools rooted there; `None` when LibreOffice isn't installed.
fn office_files() -> Option<(tempfile::TempDir, Automation)> {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("letter.docx"), docx("Quokka letter")).unwrap();
std::fs::write(dir.path().join("budget.xlsx"), xlsx("Narwhal budget")).unwrap();
std::fs::write(dir.path().join("deck.pptx"), pptx("Axolotl deck")).unwrap();
std::fs::write(dir.path().join("notes.rtf"), rtf("Pangolin notes")).unwrap();
let mut a = auto(dir.path());
match a.call("doc_create_multiple", &json!({ "paths": ["notes.rtf"] })) {
Err(ToolError::Failed(e)) if e.contains("needs LibreOffice") => {
eprintln!("skipped: LibreOffice isn't installed");
None
}
_ => Some((dir, a)),
}
}
/// Each Office format converts to a PDF of its own that holds the document's text (and, for
/// the spreadsheet, the formula's value).
#[test]
fn office_documents_convert_separately_with_their_text() {
let Some((dir, mut a)) = office_files() else { return };
let paths = ["letter.docx", "budget.xlsx", "deck.pptx", "notes.rtf"];
let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "mode": "separate", "out_dir": "out" }));
let files = made["files"].as_array().unwrap();
assert_eq!(files.len(), 4, "{made}");
let expected = [
("letter.pdf", &["Quokka letter"][..]),
("budget.pdf", &["Narwhal budget", "42"]),
("deck.pdf", &["Axolotl deck"]),
("notes.pdf", &["Pangolin notes"]),
];
for (file, (name, words)) in files.iter().zip(expected) {
let output = file["output"].as_str().unwrap_or_else(|| panic!("{file}"));
// The tool reports the folder in its canonical form (`\\?\C:\…` on Windows).
assert_eq!(Path::new(output).file_name(), Some(std::ffi::OsStr::new(name)), "{output}");
assert!(dir.path().join("out").join(name).is_file(), "{name}");
let doc = ok(&mut a, "doc_open", json!({ "path": output }))["doc"].as_u64().unwrap();
let text = page_text(&mut a, doc).join("\n");
for word in words {
assert!(text.contains(word), "{output}: {word:?} not in {text:?}");
}
}
}
/// Office documents combine, in the order given, with a PDF made here, one bookmark each.
#[test]
fn office_documents_combine_in_order_with_a_bookmark_each() {
let Some((dir, mut a)) = office_files() else { return };
let made = ok(
&mut a,
"doc_create_multiple",
json!({ "paths": ["deck.pptx", "letter.docx", "budget.xlsx", "notes.rtf"], "out": "all.pdf", "open": true }),
);
let doc = made["document"]["doc"].as_u64().unwrap();
let pages = page_text(&mut a, doc);
assert_eq!(pages.len(), 4, "{pages:?}");
for (page, word) in pages.iter().zip(["Axolotl deck", "Quokka letter", "Narwhal budget", "Pangolin notes"]) {
assert!(page.contains(word), "{word:?} not in {page:?}");
}
let titles: Vec<String> = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"]
.as_array()
.unwrap()
.iter()
.map(|b| b["title"].as_str().unwrap().to_string())
.collect();
assert_eq!(titles, ["deck", "letter", "budget", "notes"]);
assert!(dir.path().join("all.pdf").is_file());
// doc_combine takes them beside a PDF, with a page range for one of them.
std::fs::write(dir.path().join("cover.txt"), "Cover page").unwrap();
ok(&mut a, "doc_create_multiple", json!({ "paths": ["cover.txt"], "out": "cover.pdf" }));
assert!(dir.path().join("cover.pdf").is_file());
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["cover.pdf", "notes.rtf", "letter.docx"], "pages": [null, "1", null], "open": true }));
let doc = combined["document"]["doc"].as_u64().unwrap();
let pages = page_text(&mut a, doc);
assert_eq!(pages.len(), 3, "{pages:?}");
for (page, word) in pages.iter().zip(["Cover page", "Pangolin notes", "Quokka letter"]) {
assert!(page.contains(word), "{word:?} not in {page:?}");
}
}
+5
View File
@@ -24,6 +24,11 @@ thiserror = { workspace = true }
# Already in the graph through lopdf; used only to read plain-text files (decode_text).
encoding_rs = "0.8"
[target.'cfg(unix)'.dependencies]
# Already in the graph (pdfcraft-platform, ui-egui); stops a timed-out LibreOffice's whole process
# group, soffice.bin included, without unsafe code.
rustix = { version = "1", features = ["process"] }
[dev-dependencies]
# An independent CCITT decoder, to check Create PDF's Group 4 images.
hayro-ccitt.workspace = true
+24
View File
@@ -28,6 +28,30 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Office documents (`office.rs`) are the exception: PdfCraft doesn't read Word, Excel, PowerPoint,
OpenDocument or RTF itself. `is_office(name, bytes)` recognises them (an `OFFICE_EXTENSIONS`
name and an OLE, ZIP or RTF signature; `source_kind` reports `SourceKind::Office`), and
`office_to_pdf(name, bytes)` returns PDF bytes made by an installed LibreOffice, run headless as
a separate process: a private temporary folder (owner-only on Unix; the document is always
`document.<ext>` there, never its own name), a fresh profile with macros disabled, untrusted
links blocked and Writer and Calc set never to update linked content (sections, files, external
data) on load, no console window on Windows, and a stop after `TIMEOUT` (120 s) or as soon as the
output grows past 512 MB (the whole process tree is stopped, not only `soffice`). LibreOffice is
found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders (`libreoffice()`).
A snap-installed LibreOffice is not among those folders: its private `/tmp` cannot see the work
folder. Where `/snap/bin` is on the `PATH` it can still be found there, so point
`PDFCRAFT_SOFFICE` at a non-snap install if conversions fail. It takes seconds, so callers keep it
off the frame thread. On the web it returns an error.
Impress has no setting for linked content, and the profile settings are LibreOffice's own:
PdfCraft doesn't sandbox the process's network access. Converting test documents whose linked
sections, pictures and external cell references point at a local listener made no connection,
with or without the Writer and Calc settings (LibreOffice 26.8.1, Windows, 2026-10-26), so the settings are a
second line of defence rather than a measured fix.
`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS` and `OFFICE_EXTENSIONS` are what the app's file pickers
offer, so detection and pickers agree.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
+23 -4
View File
@@ -1,4 +1,4 @@
//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README.
//! pdfcraft-create — create PDFs from nothing, images, text or (through LibreOffice) Office documents (L4). See the README.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -8,6 +8,10 @@ mod extract;
mod text_font;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
mod office;
#[cfg(not(target_arch = "wasm32"))]
pub use office::libreoffice;
pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf};
use pdfcraft_fonts::wrap_with;
use text_font::TextFont;
@@ -17,6 +21,9 @@ pub enum CreateError {
Image(String, String),
#[error("{0}")]
Invalid(String),
/// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed.
#[error("{0}")]
Office(String),
}
/// US Letter in points.
@@ -757,9 +764,18 @@ pub enum SourceKind {
Pdf,
Image,
Text,
/// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice.
Office,
}
/// File extensions Create converts to PDF (images and plain text).
/// File extensions of the images Create converts to PDF.
pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"];
/// File extensions of the plain-text files Create converts to PDF.
pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"];
/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`]
/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]).
pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"];
/// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8
@@ -792,8 +808,8 @@ fn is_image(bytes: &[u8]) -> bool {
&& bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124)))
}
/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or
/// `.text` file). `None` for anything else.
/// Whether `bytes` named `name` is a PDF, an image Create can embed, plain text (a `.txt` or
/// `.text` file) or an Office document ([`is_office`]). `None` for anything else.
pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default();
if head.windows(5).any(|w| w == b"%PDF-") {
@@ -802,6 +818,9 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
if is_image(bytes) {
return Some(SourceKind::Image);
}
if is_office(name, bytes) {
return Some(SourceKind::Office);
}
let lower = name.to_ascii_lowercase();
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
}
+599
View File
@@ -0,0 +1,599 @@
//! Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) to PDF through an installed
//! LibreOffice, run as a separate process: the "LibreOffice sidecar" of
//! `docs/file-format-parity.md`. PdfCraft never parses these formats itself.
//!
//! Detection ([`is_office`]) is pure and works everywhere. Conversion ([`office_to_pdf`]) runs
//! only on desktop builds: each run gets its own temporary folder and a fresh LibreOffice
//! profile with macros and untrusted links turned off, and is stopped after [`TIMEOUT`].
use crate::CreateError;
/// File extensions of the Office documents converted through LibreOffice.
pub const OFFICE_EXTENSIONS: &[&str] = &[
// Word and Writer
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", //
// Excel and Calc
"xls", "xlsx", "xlsm", "xlt", "xltx", "xltm", "ods", "ots", //
// PowerPoint and Impress
"ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm", "odp", "otp",
];
/// The OLE compound-file signature of Office 97–2003 files (`.doc`, `.xls`, `.ppt`).
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
/// The longest a conversion may run before LibreOffice is stopped. A fresh profile costs a
/// few seconds on its own; large spreadsheets take longer.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// The largest PDF read back from LibreOffice.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
const MAX_PDF: u64 = 512 * 1024 * 1024;
/// `name`'s extension as it appears in [`OFFICE_EXTENSIONS`], if it is one.
fn office_extension(name: &str) -> Option<&'static str> {
let (_, ext) = name.rsplit_once('.')?;
OFFICE_EXTENSIONS.iter().copied().find(|e| ext.eq_ignore_ascii_case(e))
}
/// Whether `bytes` named `name` is an Office document: an Office extension, and the bytes start
/// like one (an OLE compound file, a ZIP package as OOXML and OpenDocument are, or RTF).
pub fn is_office(name: &str, bytes: &[u8]) -> bool {
office_extension(name).is_some() && (bytes.starts_with(OLE) || bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"{\\rtf"))
}
/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice.
#[cfg(target_arch = "wasm32")]
pub fn office_to_pdf(name: &str, _bytes: &[u8]) -> Result<Vec<u8>, CreateError> {
Err(CreateError::Office(format!("{name}: Office documents open only in the desktop app, which converts them with LibreOffice")))
}
/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice.
#[cfg(not(target_arch = "wasm32"))]
pub fn office_to_pdf(name: &str, bytes: &[u8]) -> Result<Vec<u8>, CreateError> {
let program = native::libreoffice().ok_or_else(|| {
CreateError::Office(format!(
"{name}: opening Word, Excel and PowerPoint files needs LibreOffice, which isn't installed (get it free from libreoffice.org)"
))
})?;
native::convert_with(&program, name, bytes, TIMEOUT)
}
/// Where LibreOffice is installed, if it is: `PDFCRAFT_SOFFICE`, then the `PATH`, then the
/// usual install folders.
#[cfg(not(target_arch = "wasm32"))]
pub fn libreoffice() -> Option<std::path::PathBuf> {
native::libreoffice()
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::{CreateError, MAX_PDF, office_extension};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// The program names LibreOffice installs on the `PATH`.
#[cfg(windows)]
const PATH_NAMES: &[&str] = &["soffice.exe"];
#[cfg(not(windows))]
const PATH_NAMES: &[&str] = &["soffice", "libreoffice"];
pub(super) fn libreoffice() -> Option<PathBuf> {
let defaults = default_locations(&|key| std::env::var_os(key));
candidates(std::env::var_os("PDFCRAFT_SOFFICE"), std::env::var_os("PATH"), defaults).into_iter().find(|p| p.is_file())
}
/// Where to look, in order: the override, each `PATH` folder, then `defaults`.
pub(super) fn candidates(override_path: Option<OsString>, path: Option<OsString>, defaults: Vec<PathBuf>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = override_path.filter(|p| !p.is_empty()).map(PathBuf::from).into_iter().collect();
if let Some(path) = path {
for dir in std::env::split_paths(&path) {
out.extend(PATH_NAMES.iter().map(|name| dir.join(name)));
}
}
out.extend(defaults);
out
}
/// The usual install locations, given a way to read environment variables.
#[cfg(windows)]
pub(super) fn default_locations(env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
.iter()
.filter_map(|key| env(key))
.map(|dir| PathBuf::from(dir).join("LibreOffice").join("program").join("soffice.exe"))
.collect()
}
/// The usual install locations, given a way to read environment variables.
#[cfg(target_os = "macos")]
pub(super) fn default_locations(env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
let app = Path::new("LibreOffice.app/Contents/MacOS/soffice");
let mut out = vec![Path::new("/Applications").join(app)];
out.extend(env("HOME").map(|home| PathBuf::from(home).join("Applications").join(app)));
out
}
/// The usual install locations, given a way to read environment variables.
#[cfg(not(any(windows, target_os = "macos")))]
pub(super) fn default_locations(_env: &dyn Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
[
"/usr/bin/soffice",
"/usr/bin/libreoffice",
"/usr/local/bin/soffice",
"/usr/local/bin/libreoffice",
"/usr/lib/libreoffice/program/soffice",
"/opt/libreoffice/program/soffice",
]
.iter()
.map(PathBuf::from)
.collect()
}
/// The fresh profile's settings: no macros at all, no links to untrusted locations, and
/// linked content in Writer and Calc documents (sections, files, external data) never
/// updated on load, so a document can't make LibreOffice fetch from the network or a share.
/// The two `Link` settings number "Never" differently (Writer.xcs: 0 Always, 1 On request,
/// 2 Never; Calc.xcs: 0 Always, 1 Never, 2 On request). Impress has no such setting.
pub(super) const PROFILE_SETTINGS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="DisableMacrosExecution" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item>
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>2</value></prop></item>
<item oor:path="/org.openoffice.Office.Calc/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>1</value></prop></item>
</oor:items>
"#;
/// A private temporary folder, removed (with everything in it) when dropped.
pub(super) struct WorkDir(pub(super) PathBuf);
impl WorkDir {
pub(super) fn new() -> std::io::Result<Self> {
static NEXT: AtomicU64 = AtomicU64::new(0);
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
let dir = std::env::temp_dir().join(format!("pdfcraft-office-{}-{n}-{nanos}", std::process::id()));
// Not recursive: a folder that already exists is not ours. Owner-only on Unix, where the
// temporary folder is shared: the document and its PDF are not for other users.
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(&dir)?;
Ok(Self(dir))
}
}
impl Drop for WorkDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `path` as a `file://` URL, which `-env:UserInstallation` takes.
pub(super) fn file_url(path: &Path) -> String {
let path = path.to_string_lossy().replace('\\', "/");
let mut url = String::from("file://");
// A Windows path (`C:/…`) needs the third slash a Unix path already starts with.
if !path.starts_with('/') {
url.push('/');
}
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) {
url.push(char::from(b));
} else {
url.push_str(&format!("%{b:02X}"));
}
}
url
}
pub(super) enum Run {
Exited(ExitStatus),
TimedOut,
/// The watched folder grew past its limit while the command ran.
TooLarge,
}
/// The bytes in the files directly inside `dir` (LibreOffice writes its one output there).
/// Anything unreadable counts as nothing, and the sum saturates. Each size is read from the
/// file itself: on Windows a directory listing keeps the old size while a process still
/// has the file open for writing.
pub(super) fn folder_size(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
entries
.flatten()
.filter_map(|entry| std::fs::symlink_metadata(entry.path()).ok())
.filter(|meta| meta.is_file())
.fold(0u64, |sum, meta| sum.saturating_add(meta.len()))
}
/// Run `command` to completion, killing it and every process it started once `timeout`
/// passes, or once the files in `watch`'s folder add up to more than its limit (so a
/// document that converts to an enormous PDF can't fill the disk before it is checked).
/// `soffice` hands the conversion to its own child, `soffice.bin`, which would
/// otherwise outlive the kill, holding the profile folder and its memory.
pub(super) fn run_limited(mut command: Command, timeout: Duration, watch: Option<(&Path, u64)>) -> std::io::Result<Run> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// Its own process group, so the whole group can be stopped together.
command.process_group(0);
}
let mut child = command.spawn()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(Run::Exited(status)),
Ok(None) => {}
Err(error) => {
kill_tree(&mut child);
return Err(error);
}
}
if watch.is_some_and(|(dir, limit)| folder_size(dir) > limit) {
kill_tree(&mut child);
return Ok(Run::TooLarge);
}
if start.elapsed() >= timeout {
kill_tree(&mut child);
return Ok(Run::TimedOut);
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Kill `child` and every process it started, then reap it.
fn kill_tree(child: &mut Child) {
#[cfg(unix)]
{
use rustix::process::{Pid, Signal, kill_process_group};
// The group `process_group(0)` made has the child's id.
if let Some(group) = i32::try_from(child.id()).ok().and_then(Pid::from_raw) {
let _ = kill_process_group(group, Signal::KILL);
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// `/T` walks the tree from the child's id, so it runs while the child is still alive.
// Windows' own copy, not whatever `taskkill` the PATH finds first.
let taskkill = std::env::var_os("SystemRoot")
.map_or_else(|| PathBuf::from("taskkill.exe"), |root| PathBuf::from(root).join("System32").join("taskkill.exe"));
let _ = Command::new(taskkill)
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
// CREATE_NO_WINDOW, as for LibreOffice itself.
.creation_flags(0x0800_0000)
.status();
}
// The child itself, if the tree kill couldn't reach it.
let _ = child.kill();
let _ = child.wait();
}
/// Convert with the LibreOffice at `program`, giving up after `timeout`.
pub(super) fn convert_with(program: &Path, name: &str, bytes: &[u8], timeout: Duration) -> Result<Vec<u8>, CreateError> {
convert_capped(program, name, bytes, timeout, MAX_PDF)
}
/// [`convert_with`] with the converted PDF's size limit given: the output folder is watched
/// while LibreOffice runs, and the finished file is checked again.
pub(super) fn convert_capped(program: &Path, name: &str, bytes: &[u8], timeout: Duration, max_pdf: u64) -> Result<Vec<u8>, CreateError> {
let too_large = || format!("the converted PDF is larger than {} MB", max_pdf / (1024 * 1024));
let failed = |why: String| CreateError::Office(format!("{name}: {why}"));
let ext = office_extension(name).ok_or_else(|| failed("this isn't a Word, Excel or PowerPoint file".into()))?;
let work = WorkDir::new().map_err(|e| failed(format!("couldn't make a temporary folder: {e}")))?;
// A fixed input name: the document's own name never reaches LibreOffice's command line.
let input = work.0.join(format!("document.{ext}"));
let profile = work.0.join("profile");
let out = work.0.join("out");
let prepared = std::fs::write(&input, bytes)
.and_then(|()| std::fs::create_dir_all(profile.join("user")))
.and_then(|()| std::fs::write(profile.join("user").join("registrymodifications.xcu"), PROFILE_SETTINGS))
.and_then(|()| std::fs::create_dir(&out));
prepared.map_err(|e| failed(format!("couldn't prepare the conversion: {e}")))?;
let mut command = Command::new(program);
command
.arg(format!("-env:UserInstallation={}", file_url(&profile)))
.args(["--headless", "--invisible", "--nologo", "--norestore", "--nodefault", "--nolockcheck", "--convert-to", "pdf", "--outdir"])
.arg(&out)
.arg(&input)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: no console window flashes up during the conversion.
command.creation_flags(0x0800_0000);
}
let status = match run_limited(command, timeout, Some((&out, max_pdf))) {
Ok(Run::Exited(status)) => status,
Ok(Run::TooLarge) => return Err(failed(format!("{}; LibreOffice was stopped", too_large()))),
Ok(Run::TimedOut) => return Err(failed(format!("LibreOffice took longer than {} seconds and was stopped", timeout.as_secs()))),
Err(e) => return Err(failed(format!("couldn't start LibreOffice ({}): {e}", program.display()))),
};
let pdf = out.join("document.pdf");
let size = match std::fs::metadata(&pdf) {
Ok(meta) => meta.len(),
Err(_) if !status.success() => return Err(failed(format!("LibreOffice couldn't convert it ({status})"))),
Err(_) => return Err(failed("LibreOffice didn't produce a PDF; the file may be damaged or password-protected".into())),
};
if size > max_pdf {
return Err(failed(too_large()));
}
let bytes = std::fs::read(&pdf).map_err(|e| failed(format!("couldn't read the converted PDF: {e}")))?;
if !bytes.starts_with(b"%PDF-") {
return Err(failed("LibreOffice's output isn't a PDF".into()));
}
Ok(bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
const DOCX_HEAD: &[u8] = b"PK\x03\x04\x14\x00\x06\x00";
#[test]
fn office_files_are_recognised_by_extension_and_signature() {
assert!(is_office("Report.docx", DOCX_HEAD));
assert!(is_office("SHEET.XLSX", DOCX_HEAD));
assert!(is_office("deck.odp", DOCX_HEAD));
assert!(is_office("old.doc", OLE));
assert!(is_office("old.ppt", &[OLE, b"rest"].concat()));
assert!(is_office("letter.rtf", b"{\\rtf1\\ansi"));
// Word saves RTF under .doc too.
assert!(is_office("letter.doc", b"{\\rtf1\\ansi"));
}
#[test]
fn other_files_are_not_office_documents() {
// The right signature with another extension, or an Office name with other bytes.
assert!(!is_office("archive.zip", DOCX_HEAD));
assert!(!is_office("Report.docx", b"%PDF-1.7"));
assert!(!is_office("Report.docx", b"plain text"));
assert!(!is_office("Report.docx", b""));
assert!(!is_office("docx", DOCX_HEAD));
assert!(!is_office("", DOCX_HEAD));
// A multi-byte name never splits a character.
assert!(!is_office("résumé.dócx", DOCX_HEAD));
assert!(is_office("résumé.docx", DOCX_HEAD));
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::super::native::*;
use super::DOCX_HEAD;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[test]
fn file_urls_escape_spaces_and_keep_drive_letters() {
assert_eq!(file_url(Path::new("/tmp/a b/profile")), "file:///tmp/a%20b/profile");
assert_eq!(file_url(Path::new(r"C:\Users\Jo Ann\profile")), "file:///C:/Users/Jo%20Ann/profile");
assert_eq!(file_url(Path::new("/tmp/é")), "file:///tmp/%C3%A9");
}
#[test]
fn the_override_comes_first_then_the_path_then_the_defaults() {
let path = std::env::join_paths([Path::new("first"), Path::new("second")]).unwrap();
let found = candidates(Some(OsString::from("custom/soffice")), Some(path), vec![PathBuf::from("default/soffice")]);
assert_eq!(found.first(), Some(&PathBuf::from("custom/soffice")));
assert!(found.get(1).is_some_and(|p| p.starts_with("first")));
assert_eq!(found.last(), Some(&PathBuf::from("default/soffice")));
// An empty override is no override.
assert_eq!(candidates(Some(OsString::new()), None, Vec::new()), Vec::<PathBuf>::new());
}
#[test]
fn default_locations_read_only_the_given_environment() {
let found = default_locations(&|_| None);
assert!(found.iter().all(|p| p.is_absolute()), "{found:?}");
}
/// Each setting the fresh profile makes, as (path, property, value).
fn profile_settings() -> Vec<(&'static str, &'static str, &'static str)> {
let between = |s: &'static str, start: &str, end: &str| s.split_once(start).and_then(|(_, rest)| rest.split_once(end)).map(|(v, _)| v);
PROFILE_SETTINGS
.split("<item ")
.skip(1)
.filter_map(|item| {
Some((between(item, "oor:path=\"", "\"")?, between(item, "oor:name=\"", "\"")?, between(item, "<value>", "</value>")?))
})
.collect()
}
#[test]
fn the_profile_turns_off_macros_and_never_updates_links() {
let settings = profile_settings();
let scripting = "/org.openoffice.Office.Common/Security/Scripting";
assert!(settings.contains(&(scripting, "DisableMacrosExecution", "true")), "{settings:?}");
assert!(settings.contains(&(scripting, "MacroSecurityLevel", "3")), "{settings:?}");
assert!(settings.contains(&(scripting, "BlockUntrustedRefererLinks", "true")), "{settings:?}");
// "Never" is 2 for Writer but 1 for Calc (Writer.xcs and Calc.xcs); Writer's 1 is
// its default, "On request".
assert!(settings.contains(&("/org.openoffice.Office.Writer/Content/Update", "Link", "2")), "{settings:?}");
assert!(settings.contains(&("/org.openoffice.Office.Calc/Content/Update", "Link", "1")), "{settings:?}");
assert_eq!(settings.len(), 5, "{settings:?}");
}
#[test]
fn a_missing_converter_is_an_error_not_a_crash() {
let missing = std::env::temp_dir().join("pdfcraft-no-such-soffice").join("soffice");
let error = convert_with(&missing, "Report.docx", DOCX_HEAD, Duration::from_secs(5)).unwrap_err();
assert!(error.to_string().contains("couldn't start LibreOffice"), "{error}");
}
#[test]
fn a_converter_that_makes_no_pdf_is_reported() {
// This test binary stands in for LibreOffice: it rejects the arguments and exits
// without writing anything.
let program = std::env::current_exe().unwrap();
let error = convert_with(&program, "Report.docx", DOCX_HEAD, Duration::from_secs(60)).unwrap_err();
assert!(error.to_string().contains("LibreOffice"), "{error}");
}
#[test]
fn a_converter_that_hangs_is_stopped() {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command.args(["--ignored", "--exact", "office::tests::native::sleeps_for_a_minute", "--nocapture"]);
command.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
let started = std::time::Instant::now();
assert!(matches!(run_limited(command, Duration::from_millis(500), None), Ok(Run::TimedOut)));
assert!(started.elapsed() < Duration::from_secs(30));
}
/// The stand-in for a LibreOffice that never finishes (run only by the test above).
#[test]
#[ignore]
fn sleeps_for_a_minute() {
std::thread::sleep(Duration::from_secs(60));
}
/// Where the stand-ins below write their heartbeat.
const HEARTBEAT: &str = "PDFCRAFT_TEST_OFFICE_HEARTBEAT";
/// This test binary run as one of its own ignored tests, with the heartbeat file set.
fn stand_in(test: &str, heartbeat: &Path) -> std::process::Command {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command.args(["--ignored", "--exact", test, "--nocapture"]).env(HEARTBEAT, heartbeat);
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
command
}
/// `soffice` starts `soffice.bin` as its own child: a timeout must stop both, or the
/// child keeps running, holding the profile folder.
#[test]
fn a_timeout_stops_the_processes_the_converter_started() {
let work = WorkDir::new().unwrap();
let heartbeat = work.0.join("heartbeat");
let parent = stand_in("office::tests::native::starts_a_child_that_keeps_beating", &heartbeat);
assert!(matches!(run_limited(parent, Duration::from_secs(5), None), Ok(Run::TimedOut)));
let beats = || std::fs::metadata(&heartbeat).map_or(0, |m| m.len());
// A killed process may take a moment to go.
std::thread::sleep(Duration::from_millis(300));
let after_kill = beats();
assert!(after_kill > 0, "the grandchild started and was beating before the timeout");
std::thread::sleep(Duration::from_secs(1));
assert_eq!(beats(), after_kill, "the grandchild was stopped with its parent");
}
/// The stand-in for `soffice`: starts [`beats_until_stopped`] and waits (run only by the
/// test above).
#[test]
#[ignore]
fn starts_a_child_that_keeps_beating() {
let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return };
let mut child = stand_in("office::tests::native::beats_until_stopped", Path::new(&heartbeat)).spawn().unwrap();
std::thread::sleep(Duration::from_secs(60));
let _ = child.kill();
let _ = child.wait();
}
/// The stand-in for `soffice.bin`: appends to the heartbeat file every 50 ms for a minute.
#[test]
#[ignore]
fn beats_until_stopped() {
use std::io::Write;
let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return };
let mut file = std::fs::OpenOptions::new().create(true).append(true).open(heartbeat).unwrap();
for _ in 0..1200 {
file.write_all(b".").unwrap();
std::thread::sleep(Duration::from_millis(50));
}
}
/// Where the stand-in below writes.
const FILL_DIR: &str = "PDFCRAFT_TEST_OFFICE_FILL_DIR";
/// A converter that writes without end is stopped as soon as its output passes the
/// limit, not after it has run out its time.
#[test]
fn a_converter_whose_output_grows_past_the_limit_is_stopped() {
let work = WorkDir::new().unwrap();
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command.args(["--ignored", "--exact", "office::tests::native::writes_without_end", "--nocapture"]).env(FILL_DIR, &work.0);
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
let limit = 256 * 1024;
let started = std::time::Instant::now();
let run = run_limited(command, Duration::from_secs(60), Some((&work.0, limit)));
assert!(matches!(run, Ok(Run::TooLarge)), "stopped for size, not for time");
assert!(started.elapsed() < Duration::from_secs(30), "stopped long before the timeout");
// A killed process may take a moment to go; after that nothing is written.
std::thread::sleep(Duration::from_millis(300));
let after_kill = folder_size(&work.0);
assert!(after_kill > limit, "the stand-in had written past the limit");
std::thread::sleep(Duration::from_secs(1));
assert_eq!(folder_size(&work.0), after_kill, "the converter was stopped");
}
/// The stand-in for a LibreOffice that fills the disk: 64 KiB every 10 ms for a minute
/// (run only by the test above).
#[test]
#[ignore]
fn writes_without_end() {
use std::io::Write;
let Some(dir) = std::env::var_os(FILL_DIR) else { return };
let mut file = std::fs::File::create(Path::new(&dir).join("document.pdf")).unwrap();
let chunk = vec![0u8; 64 * 1024];
for _ in 0..6000 {
file.write_all(&chunk).unwrap();
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_folder_that_cannot_be_read_counts_as_empty() {
assert_eq!(folder_size(&std::env::temp_dir().join("pdfcraft-no-such-folder")), 0);
}
/// The temporary folder holds a confidential document and its PDF: on Unix only the owner
/// may enter it.
#[cfg(unix)]
#[test]
fn the_temporary_folder_is_private_to_its_owner() {
use std::os::unix::fs::PermissionsExt;
let work = WorkDir::new().unwrap();
let mode = std::fs::metadata(&work.0).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "{mode:o}");
}
#[test]
fn temporary_folders_are_removed_with_their_contents() {
let work = WorkDir::new().unwrap();
let other = WorkDir::new().unwrap();
assert_ne!(work.0, other.0);
let dir = work.0.clone();
std::fs::create_dir_all(dir.join("profile").join("user")).unwrap();
std::fs::write(dir.join("document.docx"), DOCX_HEAD).unwrap();
drop(work);
assert!(!dir.exists());
}
/// A real conversion, when LibreOffice is installed (skipped otherwise).
#[test]
fn libreoffice_converts_a_document_when_installed() {
let Some(program) = super::super::libreoffice() else {
eprintln!("skipped: LibreOffice isn't installed");
return;
};
let rtf = b"{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard Hello from PdfCraft.\\par}";
let pdf = convert_with(&program, "hello.rtf", rtf, super::super::TIMEOUT).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
}
}
}
+17 -1
View File
@@ -559,7 +559,7 @@ fn source_kind_tells_pdfs_images_and_text_apart() {
// Text that merely starts like a BMP stays text; a truncated BMP header is not an image.
assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text));
assert_eq!(source_kind("b.bmp", b"BM"), None);
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), Some(SourceKind::Office), "Office documents convert through LibreOffice");
assert_eq!(source_kind("", b""), None);
// Only the GIF versions Create decodes count as images, so detection never promises a
// conversion that then fails.
@@ -2952,3 +2952,19 @@ fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() {
// The helper puts a stray byte before the chunks, which the decoder skips: it stays.
assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat());
}
#[test]
fn convertible_is_the_image_and_text_extensions() {
let both: Vec<&str> = IMAGE_EXTENSIONS.iter().chain(TEXT_EXTENSIONS.iter()).copied().collect();
assert_eq!(both, CONVERTIBLE.to_vec());
assert!(OFFICE_EXTENSIONS.iter().all(|e| !CONVERTIBLE.contains(e) && *e != "pdf"));
}
#[test]
fn office_documents_are_their_own_kind() {
assert_eq!(source_kind("Report.docx", b"PK\x03\x04\x14\x00"), Some(SourceKind::Office));
assert_eq!(source_kind("Budget.xls", &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), Some(SourceKind::Office));
// A PDF saved with a Word name is still a PDF; a ZIP that isn't named like Office is nothing.
assert_eq!(source_kind("Report.docx", b"%PDF-1.7\n"), Some(SourceKind::Pdf));
assert_eq!(source_kind("photos.zip", b"PK\x03\x04\x14\x00"), None);
}
+4 -2
View File
@@ -92,7 +92,9 @@ pub use pdfcraft_organize::check_custom_key;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::{Algorithm, civil_from_days};
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_create::{
CONVERTIBLE, CreateError, IMAGE_EXTENSIONS, ImageResolution, OFFICE_EXTENSIONS, SourceKind, TEXT_EXTENSIONS, decode_text, source_kind,
};
pub use pdfcraft_edit::added::{
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
text_width as added_text_width,
@@ -166,7 +168,7 @@ use pictures::{embed_image, fit_picture, mark_source, model_poster};
use protection::unrestricted;
pub use protection::{Changes, Printing, Protection, SecuritySummary};
pub use session::Session;
pub use sources::{CombineSource, Combined, combine_source_check, source_page_count};
pub use sources::{CombineSource, Combined, combine_source_check, office_to_pdf, source_page_count};
use sources::{open_source, open_source_with};
#[cfg(test)]
use test_hooks::{
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden,
ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart,
StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list,
comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source,
comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, office_to_pdf, open_source,
open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours,
set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa,
xfa_sync_datasets,
+65 -14
View File
@@ -1,9 +1,17 @@
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI,
OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print,
OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, office_to_pdf, open_source, open_source_with, optimize, print,
print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full,
};
/// Whether a conversion may run LibreOffice for an Office document (seconds, in another
/// process) or must refuse it because the caller is on the frame thread.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Office {
Convert,
Refuse,
}
impl Session {
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
@@ -41,30 +49,63 @@ impl Session {
self.write_new(&doc)
}
/// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked
/// (it must open and allow copying pages) and returned as it is.
/// Convert a file Create understands (an image, plain text or an Office document) to PDF
/// bytes; a PDF is checked (it must open and allow copying pages) and returned as it is.
/// An Office document runs LibreOffice ([`office_to_pdf`]), which takes seconds: keep it off
/// the frame thread ([`Self::convert_to_pdf_inline`] there).
pub fn convert_to_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
self.convert(name, bytes, Office::Convert)
}
/// [`Self::convert_to_pdf`] for a caller on the frame thread, which must not wait seconds for
/// LibreOffice: an Office document is refused, pointing to File ▸ Open, which converts it in
/// the background.
pub fn convert_to_pdf_inline(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
self.convert(name, bytes, Office::Refuse)
}
fn convert(&self, name: &str, bytes: &Arc<Vec<u8>>, office: Office) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
let Some(kind) = source_kind(name, bytes) else {
return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file")));
return Err(EditError::Source(format!(
"{name}: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file"
)));
};
if kind == SourceKind::Office && office == Office::Refuse {
return Err(EditError::Source(format!(
"{name}: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF"
)));
}
let title = name.rsplit_once('.').map_or(name, |(s, _)| s);
// Image decoders read untrusted bytes: a panic in one must not take the app down.
let created = guard(|| match kind {
SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()),
SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]),
SourceKind::Text => self.create_from_text(title, &decode_text(bytes)),
SourceKind::Office => office_to_pdf(name, bytes),
})
.map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?;
Ok((kind, created?))
}
/// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image or text
/// file is converted, and a PDF is checked as the insert itself checks it, so a file under
/// `dest`'s own protection that withholds only page extraction is accepted, as pasted pages
/// are (#5081).
/// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image, text or
/// Office file is converted, and a PDF is checked as the insert itself checks it, so a file
/// under `dest`'s own protection that withholds only page extraction is accepted, as pasted
/// pages are (#5081). An Office document runs LibreOffice: keep it off the frame thread
/// ([`Self::insert_source_inline`] there).
pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(usize, Arc<Vec<u8>>), EditError> {
self.insert_source_with(dest, name, bytes, Office::Convert)
}
/// [`Self::insert_source`] on the frame thread: Office documents are refused, as
/// [`Self::convert_to_pdf_inline`] refuses them. PDFs are still checked as the insert itself
/// checks them (#5081).
pub fn insert_source_inline(&self, dest: DocId, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(usize, Arc<Vec<u8>>), EditError> {
self.insert_source_with(dest, name, bytes, Office::Refuse)
}
fn insert_source_with(&self, dest: DocId, name: &str, bytes: &Arc<Vec<u8>>, office: Office) -> Result<(usize, Arc<Vec<u8>>), EditError> {
if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) {
let (_, pdf) = self.convert_to_pdf(name, bytes)?;
let (_, pdf) = self.convert(name, bytes, office)?;
return Ok((self.page_count_of(name, &pdf)?, pdf));
}
let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?;
@@ -78,16 +119,26 @@ impl Session {
Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?)
}
/// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as
/// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf
/// without a `%PDF-` header, which the reader may still open.
/// A file for Combine as PDF bytes: an image, text or Office file is converted; a PDF is
/// returned as it is, unchecked (Combine reads it itself, with its password), as is a file
/// named .pdf without a `%PDF-` header, which the reader may still open.
pub fn as_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<Arc<Vec<u8>>, EditError> {
self.as_pdf_with(name, bytes, Office::Convert)
}
/// [`Self::as_pdf`] on the frame thread: Office documents are refused, as
/// [`Self::convert_to_pdf_inline`] refuses them.
pub fn as_pdf_inline(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<Arc<Vec<u8>>, EditError> {
self.as_pdf_with(name, bytes, Office::Refuse)
}
fn as_pdf_with(&self, name: &str, bytes: &Arc<Vec<u8>>, office: Office) -> Result<Arc<Vec<u8>>, EditError> {
let pdf = match source_kind(name, bytes) {
Some(SourceKind::Pdf) => true,
Some(SourceKind::Image | SourceKind::Text) => false,
Some(SourceKind::Image | SourceKind::Text | SourceKind::Office) => false,
None => name.to_ascii_lowercase().ends_with(".pdf"),
};
if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) }
if pdf { Ok(bytes.clone()) } else { self.convert(name, bytes, office).map(|(_, pdf)| pdf) }
}
/// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi,
+10
View File
@@ -15,6 +15,16 @@ pub struct Combined {
pub renamed_fields: Vec<(String, String)>,
}
/// Convert the Office document `bytes` named `name` (Word, Excel, PowerPoint, OpenDocument,
/// RTF) to PDF with an installed LibreOffice, and check the result opens. It runs a separate
/// process for seconds, so callers keep it off the frame thread. Desktop only: on the web it
/// returns an error.
pub fn office_to_pdf(name: &str, bytes: &Arc<Vec<u8>>) -> Result<Arc<Vec<u8>>, EditError> {
let pdf = Arc::new(pdfcraft_create::office_to_pdf(name, bytes)?);
open_source(name, &pdf)?;
Ok(pdf)
}
/// Parse another PDF to copy pages from.
pub(crate) fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
open_source_with(name, bytes, None)
+54 -4
View File
@@ -1575,8 +1575,13 @@ fn a_page_with_a_form_field_shown_twice_keeps_one_field() {
#[test]
fn files_that_cannot_be_converted_are_refused_clearly() {
let s = Session::new();
let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into()));
let err = s.convert_to_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert_eq!(
err,
EditError::Source(
"archive.zip: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file".into()
)
);
assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_))));
// Damaged inputs of a known type fail with an error, not a panic.
assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err());
@@ -1597,8 +1602,8 @@ fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() {
assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless));
assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-"));
assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-"));
let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted")));
let err = s.as_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert!(matches!(err, EditError::Source(m) if m.starts_with("archive.zip: this file type can't be converted")));
}
fn protected(user: &str, owner: &str, permissions: i32) -> Arc<Vec<u8>> {
@@ -15359,3 +15364,48 @@ fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_nat
assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}");
assert_eq!(total(&s), ["3244.75"]);
}
/// A Word document converts through LibreOffice when it is installed; without it, the error
/// says what is missing rather than calling the file a broken PDF.
#[test]
fn office_documents_convert_through_libreoffice_or_explain_why_not() {
let mut s = Session::new();
let text = s.create_from_text("t", "Quarterly report").unwrap();
let id = s.open("t.pdf", None, text, None).unwrap();
let docx = Arc::new(s.get(id).unwrap().export_office(compare::OfficeFormat::Docx));
assert_eq!(source_kind("Report.docx", &docx), Some(SourceKind::Office));
match s.convert_to_pdf("Report.docx", &docx) {
Ok((kind, pdf)) => {
assert_eq!(kind, SourceKind::Office);
let converted = s.open_new("Report.pdf", pdf).unwrap();
assert!(page_texts(&s, converted).concat().contains("Quarterly report"));
}
// Only "not installed" is an acceptable failure: a conversion that fails, times out or
// makes no PDF must fail this test.
Err(e) => assert!(e.to_string().contains("LibreOffice, which isn't installed"), "{e}"),
}
// Combine treats it as a file to convert, not as a PDF to pass through.
assert!(
s.as_pdf("Report.docx", &docx).map_or_else(|e| e.to_string().contains("LibreOffice, which isn't installed"), |pdf| pdf.starts_with(b"%PDF-"))
);
}
/// The frame thread never waits for LibreOffice: Insert, Combine and Create Multiple refuse an
/// Office document and point to File ▸ Open, while images and text still convert there.
#[test]
fn inline_conversions_refuse_office_documents_without_running_libreoffice() {
let s = Session::new();
let docx = Arc::new(b"PK\x03\x04\x14\x00".to_vec());
for err in
[s.convert_to_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err(), s.as_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err()]
{
assert_eq!(
err,
EditError::Source(
"Report.docx: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF".into()
)
);
}
assert_eq!(s.convert_to_pdf_inline("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().0, SourceKind::Text);
assert!(s.as_pdf_inline("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-"));
}
+26 -18
View File
@@ -257,6 +257,14 @@ impl PdfCraftApp {
Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
None => {}
},
open_worker::ResultKind::Converted(created) => match self.open_converted_pdf(&pending.name, created) {
Ok(()) => {
if let Some(command) = pending.then {
self.execute(command);
}
}
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
},
open_worker::ResultKind::Prepared { bytes, result } => match *result {
Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) {
Ok(id) => {
@@ -1009,24 +1017,29 @@ impl PdfCraftApp {
self.open_path_request(path, None, Some((layout, continuous)));
}
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
#[cfg(not(target_arch = "wasm32"))]
/// The File ▸ Open picker's file types, in order; the first, PDF, is the default. Images, text
/// and (on the desktop, through LibreOffice) Office documents open as new, unsaved PDFs.
/// No "All files" in the browser or on macOS, whose panel merges every type into one list
/// rather than offering a choice.
pub(crate) fn open_filters() -> Vec<(String, Vec<&'static str>)> {
let mut filters = vec![("PDF".to_string(), vec!["pdf"]), (tl!("Images").to_string(), pdfcraft_engine::IMAGE_EXTENSIONS.to_vec())];
#[cfg(not(target_arch = "wasm32"))]
filters.push((tl!("Office documents").to_string(), pdfcraft_engine::OFFICE_EXTENSIONS.to_vec()));
filters.push((tl!("Text").to_string(), pdfcraft_engine::TEXT_EXTENSIONS.to_vec()));
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
filters.push((tl!("All files").to_string(), vec!["*"]));
filters
}
/// The File ▸ Open picker ([`Self::open_filters`]), each type matched in any letter case.
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
Self::open_filters().into_iter().fold(rfd::AsyncFileDialog::new(), |dialog, (name, extensions)| dialog.add_filter_any_case(name, &extensions))
}
pub fn open_dialog(&mut self) {
self.startup_superseded = true;
#[cfg(not(target_arch = "wasm32"))]
self.pick(
pickers::PickFor::Open,
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
true,
);
self.pick(pickers::PickFor::Open, Self::open_picker(), true);
// Browsers pick files asynchronously; the bytes arrive through `inbox`.
#[cfg(target_arch = "wasm32")]
{
@@ -1035,12 +1048,7 @@ impl PdfCraftApp {
let read = super::input::WebRead::start(&self.web_reads);
wasm_bindgen_futures::spawn_local(async move {
let _read = read;
if let Some(h) = rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE)
.pick_file()
.await
{
if let Some(h) = Self::open_picker().pick_file().await {
let bytes = h.read().await;
if let Ok(mut q) = inbox.lock() {
q.push((h.file_name(), bytes));
+11 -3
View File
@@ -20,7 +20,12 @@ pub(crate) enum Input {
pub(crate) enum ResultKind {
Conversion(Arc<Vec<u8>>),
Prepared { bytes: Arc<Vec<u8>>, result: Box<Result<PreparedDocument, OpenError>> },
/// An Office document LibreOffice converted here, off the frame thread, or why it couldn't.
Converted(Result<Arc<Vec<u8>>, String>),
Prepared {
bytes: Arc<Vec<u8>>,
result: Box<Result<PreparedDocument, OpenError>>,
},
ReadFailed(String),
RemoteFailed(String),
RecoveryFailed(String),
@@ -213,8 +218,11 @@ fn prepare(job: Job) -> ResultKind {
},
Input::Bytes(bytes) => bytes.clone(),
};
if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) {
return ResultKind::Conversion(bytes);
match pdfcraft_engine::source_kind(&job.name, &bytes) {
Some(SourceKind::Image | SourceKind::Text) => return ResultKind::Conversion(bytes),
// LibreOffice takes seconds: it runs here, and the frame thread only opens the result.
Some(SourceKind::Office) => return ResultKind::Converted(pdfcraft_engine::office_to_pdf(&job.name, &bytes).map_err(|e| e.to_string())),
Some(SourceKind::Pdf) | None => {}
}
let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref().map(String::as_str));
ResultKind::Prepared { bytes, result: Box::new(result) }
@@ -130,10 +130,15 @@ impl HeldToolOpen {
self.started.recv_timeout(Duration::from_secs(5)).expect("the native open reached its preparation barrier");
}
#[allow(clippy::expect_used)]
fn complete(&self, harness: &mut Harness<'static, PdfCraftApp>) {
self.complete_within(harness, Duration::from_secs(5));
}
/// [`Self::complete`] for a preparation that may take up to `timeout`.
#[allow(clippy::expect_used)]
fn complete_within(&self, harness: &mut Harness<'static, PdfCraftApp>, timeout: Duration) {
self.release.send(()).expect("release the native preparation");
self.finished.recv_timeout(Duration::from_secs(5)).expect("the native open returned its result");
self.finished.recv_timeout(timeout).expect("the native open returned its result");
harness.run_steps(2);
}
}
@@ -997,3 +1002,55 @@ fn a_password_prompt_keeps_a_place_in_the_open_queue_for_its_retry() {
assert!(app.password_prompt.is_none(), "the retry is queued: {:?}", app.password_prompt.as_ref().and_then(|p| p.error.clone()));
assert!(app.password_retry.is_some());
}
/// A Word document built from the synthetic fixture (PdfCraft's own Word export).
#[allow(clippy::expect_used)]
fn synthetic_docx() -> Vec<u8> {
let mut session = pdfcraft_engine::Session::new();
let id = session.open("source.pdf", None, Arc::new(fixture(1)), None).expect("open the fixture");
session.get(id).expect("the fixture").export_office(pdfcraft_engine::compare::OfficeFormat::Docx)
}
/// File ▸ Open of a Word document: LibreOffice runs on the open worker, never on the frame
/// thread, and the result opens as a new, unsaved PDF. Without LibreOffice the user is
/// told it is needed, rather than that the file is a broken PDF.
#[test]
#[allow(clippy::expect_used)]
fn file_open_converts_an_office_document_on_the_worker() {
let mut harness = tool_open_harness();
let held = HeldToolOpen::install(&mut harness);
let file = tool_open_file("office", "docx", &synthetic_docx());
harness.state_mut().pick_override = Some(vec![file.0.to_string_lossy().into_owned()]);
harness.state_mut().run_command("file.open");
harness.run_steps(2);
held.wait_started();
assert!(harness.state().views.is_empty(), "nothing opens before the conversion finishes");
// An installed LibreOffice takes seconds; it is stopped after 120 s.
held.complete_within(&mut harness, Duration::from_secs(150));
let app = harness.state();
assert!(app.pending_opens.is_empty());
if let Some(view) = app.views.first() {
let doc = app.session.get(view.id).expect("the converted document");
// Named after the Word file: `<stem>.docx` opens as `<stem>.pdf`.
assert_eq!(doc.name, format!("pdfcraft-open-tool-{}-office.pdf", std::process::id()));
assert!(doc.path.is_none(), "the converted PDF is new and unsaved, not the .docx on disk");
} else {
let message = app.toast.as_ref().map(|(message, _)| message.clone()).unwrap_or_default();
assert!(message.contains("LibreOffice, which isn't installed"), "the failure names the missing dependency: {message}");
}
}
#[test]
fn the_open_picker_defaults_to_pdf_and_offers_images_office_text_and_all_files() {
let filters = PdfCraftApp::open_filters();
let names: Vec<&str> = filters.iter().map(|(name, _)| name.as_str()).collect();
#[cfg(not(target_os = "macos"))]
assert_eq!(names, ["PDF", "Images", "Office documents", "Text", "All files"]);
#[cfg(target_os = "macos")]
assert_eq!(names, ["PDF", "Images", "Office documents", "Text"]);
let extensions = |name: &str| filters.iter().find(|(n, _)| n == name).map(|(_, e)| e.clone()).unwrap_or_default();
assert_eq!(extensions("PDF"), ["pdf"]);
assert_eq!(extensions("Images"), pdfcraft_engine::IMAGE_EXTENSIONS);
assert!(["doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "rtf"].iter().all(|e| extensions("Office documents").contains(e)));
assert_eq!(extensions("Text"), pdfcraft_engine::TEXT_EXTENSIONS);
}
@@ -39,9 +39,15 @@ fn combinable(ext: &str) -> bool {
}
/// Whether Combine takes a file by what it is: a PDF (by its header or its name) or an image or
/// text file it converts, as `Session::as_pdf` tells them apart.
/// text file it converts, as `Session::as_pdf_inline` tells them apart. Not an Office document:
/// converting one runs LibreOffice for seconds, which Combine can't do on the frame thread, so
/// it is left out and named rather than listed as a file that can't be read.
pub(super) fn takes(name: &str, bytes: &[u8]) -> bool {
pdfcraft_engine::source_kind(name, bytes).is_some() || name.to_ascii_lowercase().ends_with(".pdf")
match pdfcraft_engine::source_kind(name, bytes) {
Some(pdfcraft_engine::SourceKind::Office) => false,
Some(_) => true,
None => name.to_ascii_lowercase().ends_with(".pdf"),
}
}
/// A file's bookmark in the combined PDF: its name without ".pdf", or without the extension of
@@ -279,7 +279,7 @@ impl PdfCraftApp {
notes: Vec::new(),
};
let source = bytes.clone();
let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) {
let (bytes, assessed) = match self.session.as_pdf_inline(&name, &bytes) {
Ok(pdf) => {
let assessed = assess(&pdf, None);
(pdf, assessed)
@@ -43,7 +43,7 @@ impl PdfCraftApp {
continue;
}
};
let (kind, pdf) = match self.session.convert_to_pdf(&name, &Arc::new(bytes)) {
let (kind, pdf) = match self.session.convert_to_pdf_inline(&name, &Arc::new(bytes)) {
Ok(converted) => converted,
Err(error) => {
refused.push(format!("{name} ({error})"));
@@ -111,7 +111,7 @@ impl PdfCraftApp {
let sources = std::iter::from_fn(|| {
loop {
match files.next()? {
Ok((name, bytes)) => match session.convert_to_pdf(&name, &Arc::new(bytes)) {
Ok((name, bytes)) => match session.convert_to_pdf_inline(&name, &Arc::new(bytes)) {
Ok((_, pdf)) => {
converted += 1;
return Some(Ok((stem(&name).to_string(), pdf, None)));
+12 -7
View File
@@ -43,9 +43,6 @@ pub(crate) fn image_import_body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> (bo
(go, cancel)
}
/// File types Open accepts besides PDF (converted on open).
pub use pdfcraft_engine::CONVERTIBLE;
/// What Create ▸ Clipboard found on the clipboard.
#[derive(Clone, Debug, PartialEq)]
pub enum Clip {
@@ -105,15 +102,23 @@ fn stem(name: &str) -> &str {
}
impl PdfCraftApp {
/// Convert a non-PDF file (image, text) into a new tab. Returns `None` when `bytes` is not
/// something Create understands (the caller then tries to open it as a PDF).
/// Convert a non-PDF file (image, text, Office document) into a new tab. Returns `None` when
/// `bytes` is not something Create understands (the caller then tries to open it as a PDF).
/// An Office document blocks while LibreOffice runs: File ▸ Open converts it on the open
/// worker instead ([`Self::open_converted_pdf`]), leaving this for startup and inline opens.
pub(crate) fn open_converted(&mut self, name: &str, bytes: &[u8]) -> Option<Result<(), String>> {
use pdfcraft_engine::SourceKind;
if !matches!(pdfcraft_engine::source_kind(name, bytes), Some(SourceKind::Image | SourceKind::Text)) {
if !matches!(pdfcraft_engine::source_kind(name, bytes), Some(SourceKind::Image | SourceKind::Text | SourceKind::Office)) {
return None;
}
let created = self.session.convert_to_pdf(name, &Arc::new(bytes.to_vec())).map(|(_, pdf)| pdf);
Some(self.open_created_bytes(&format!("{}.pdf", stem(name)), created.map_err(|e| e.to_string())))
Some(self.open_converted_pdf(name, created.map_err(|e| e.to_string())))
}
/// Open `created`, the PDF converted from the file `name`, as a new, unsaved tab named after
/// it (`Report.docx` → `Report.pdf`).
pub(crate) fn open_converted_pdf(&mut self, name: &str, created: Result<Arc<Vec<u8>>, String>) -> Result<(), String> {
self.open_created_bytes(&format!("{}.pdf", stem(name)), created)
}
fn open_created_bytes(&mut self, name: &str, created: Result<Arc<Vec<u8>>, String>) -> Result<(), String> {
+1 -1
View File
@@ -666,7 +666,7 @@ impl PdfCraftApp {
continue;
}
};
match self.session.insert_source(id, &name, &Arc::new(bytes)) {
match self.session.insert_source_inline(id, &name, &Arc::new(bytes)) {
Ok((pages, bytes)) => {
if self.apply_edit(Edit::InsertPagesFrom {
name,
+4 -4
View File
@@ -1839,10 +1839,10 @@ fn images_and_text_files_join_the_combine_list_as_pdfs() {
#[test]
fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() {
let mut h = combine_of(&[("one.pdf", 2)]);
let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("report.docx".to_string(), b"PK\x03\x04".to_vec())];
let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("archive.zip".to_string(), b"PK\x03\x04".to_vec())];
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, bad);
h.run_steps(3);
assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "report.docx"]);
assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "archive.zip"]);
for f in &h.state().combine_draft[1..] {
let why = f.problem.as_deref().expect("why it can't be combined");
assert!(why.starts_with("Can't be read: ") && !why.contains(&f.name), "{why}");
@@ -1859,7 +1859,7 @@ fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() {
h.run_steps(3);
h.get_by_label("Leave out files that can't be read?");
h.get_by_label("• broken.jpg");
h.get_by_label("• report.docx");
h.get_by_label("• archive.zip");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.query_by_label("Leave out files that can't be read?").is_none());
@@ -2136,7 +2136,7 @@ fn plus_between_pages_inserts_picked_files_there_in_order() {
assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "a note", "Page 2", "Page 3", "a note"]);
// A file that can't be converted changes nothing.
let before = texts_of(h.state(), 0);
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]);
h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("archive.zip".into(), b"PK\x03\x04".to_vec())]);
h.run_steps(2);
assert_eq!(texts_of(h.state(), 0), before);
}
+4 -3
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** patch (`.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Word, Excel, PowerPoint, OpenDocument and RTF open through an installed LibreOffice; `.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from
its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation
@@ -68,9 +68,9 @@ features total).
| Plain text | yes | yes | good | `create.from-text` |
| Clipboard (image or text) | yes | yes | good | `create.from-clipboard` |
| Screenshot | yes | no | none | `create.from-screenshot` planned |
| Word, Excel, PowerPoint (and Visio, Publisher on Windows) | yes | no | none | `create.from-office` planned (LibreOffice sidecar) |
| Word, Excel, PowerPoint (and Visio, Publisher on Windows) | yes | yes, desktop only, through an installed LibreOffice (File ▸ Open); not Visio or Publisher | good where LibreOffice is (its layout, not measured against Acrobat) | `create.from-office` partial |
| HTML file, web page URL | yes | no | none | `create.from-html`, `create.from-web-page` planned |
| RTF | yes | no | none | |
| RTF | yes | yes, through LibreOffice (as Office documents) | good where LibreOffice is | `create.from-office` partial |
| PostScript / EPS (Distiller) | yes | no | none | `create.distiller` planned (P3) |
| Markdown | no | no | none | `create.from-markdown` planned (P2; not an Acrobat feature) |
| Scanner | yes | no | none | `ocr.scanner-acquire` planned |
@@ -110,6 +110,7 @@ features total).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) open from File ▸ Open as new PDFs, converted by an installed LibreOffice; `create.from-office` partial. |
| 2026-10-19 | patch | Certificates row: `.p7b`/`.p7c` bundles (DER or PEM `PKCS7`/`CMS`) are read for trust import. |
| 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. |
| 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. |
+20 -7
View File
@@ -1,5 +1,6 @@
# Where PdfCraft falls short of Acrobat Pro
> **Last reviewed:** 2026-11-11 | **Last updated:** 2026-11-11 | **Change:** minor (gap 8: known residual risk for the LibreOffice sidecar, sandbox tracked in #6953; estimates unchanged)
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -45,7 +46,7 @@ update the numbers.
| 5 | Chinese UI lacks a web glyph face | localization | 6–12 | [localization-parity.md](localization-parity.md) |
| 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file |
| 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) |
| 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) |
| 8 | Office export weak, Office import basic | file format | 55–100 | [file-format-parity.md](file-format-parity.md) |
| 9 | No Preflight; PDF/A partial; PDF/X and PDF/UA absent | standards | 65–115 | [pdf-spec-parity.md](pdf-spec-parity.md) |
| 10 | Signatures: no PKCS #11, timestamp servers or online revocation | feature, hardware | 40–70 | [pdf-spec-parity.md](pdf-spec-parity.md) |
| 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) |
@@ -189,19 +190,29 @@ update the numbers.
- **Estimate:** 40–75 h, partly blocked on openly licensed models. **Doc:**
[file-format-parity.md](file-format-parity.md).
### 8. Office export weak, Office import absent
### 8. Office export weak, Office import basic
- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from Office,
HTML, web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export.
- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from HTML,
web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export. Office documents open from File ▸ Open
through an installed LibreOffice, but Insert pages, Combine files and Create ▸ Multiple files
don't take them yet.
- **Evidence:** `create.export-docx` partial; the synthetic invoice regression for
[#773](https://github.com/storytold/pdfcraft/issues/773) now covers absent inferred borders,
floating watermark text and per-page section sizes. Exact font matching, vector rules and
broader layout fidelity remain incomplete;
`create.export-xlsx`, `create.export-pptx`, `create.from-office`, `create.from-html` planned.
`create.from-office` partial (LibreOffice sidecar; Writer and Calc never update linked content,
but Impress has no such setting and the process's network access isn't sandboxed);
`create.export-xlsx`, `create.export-pptx`,
`create.from-html` planned.
- **Known residual risk:** conversion runs the user's installed LibreOffice. PdfCraft
opens no connection itself. Macros are disabled, untrusted referer links are blocked,
and Writer and Calc never update links. Impress has no link-update setting, and the
`soffice` process isn't network-sandboxed, so linked content in presentations could be
fetched. Sandboxing the converter is [#6953](https://github.com/storytold/pdfcraft/issues/6953).
- **Impact:** "Export PDF" and "Create PDF" are two of Acrobat's most used tools.
- **Gate:** lossy or missing sub-cases of the convert-and-share workflow (partial, not blocking).
First beta items: Create PDF from Office (LibreOffice sidecar, 10–20 h) and a Word export that
keeps layout (20–35 h).
First beta items: the rest of Create PDF from Office (Insert, Combine and Create ▸ Multiple files
on a worker, a Create ▸ From Office command; 5–10 h) and a Word export that keeps layout (20–35 h).
- **Estimate:** 55–100 h. **Doc:** [file-format-parity.md](file-format-parity.md).
### 9. No Preflight; PDF/A partial; PDF/X and PDF/UA absent
@@ -422,6 +433,8 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | minor | Gap 8: known residual risk for the LibreOffice sidecar (Impress has no link-update setting; the soffice process isn't network-sandboxed). Sandboxing tracked in #6953. Estimates unchanged. |
| 2026-10-26 | minor | Gap 8: the LibreOffice profile never updates Writer or Calc linked content on load; Impress has no such setting and the process's network access isn't sandboxed. Estimates unchanged. |
| 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. |
| 2026-10-18 | minor | Gap 3: #2517 a partial rewrite keeps a kept source row in the paragraph and leaves the next marked region closed around its text. Estimates unchanged. |
| 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. |
+1 -1
View File
@@ -288,7 +288,7 @@ total matches the parity estimate.
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: Word, Excel, PowerPoint, OpenDocument and RTF files open from File ▸ Open as new PDFs through an installed LibreOffice (`create.from-office` partial); embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
+1 -1
View File
@@ -212,7 +212,7 @@ full parity in that area.
| G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view |
| H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version |
| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners. Background removal, edge shadow removal and despeckle work on scanned pages (Enhance scanned file) but not yet as options of Recognize text, nor on CMYK, indexed, JBIG2 or CCITT scans |
| J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text |
| J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Office documents open through an installed LibreOffice (File ▸ Open only). Missing: HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text |
| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, CCITT and lossless JBIG2 monochrome compression, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, lossy/refinement JBIG2 |
| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) |
| M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit |
+19 -5
View File
@@ -7140,11 +7140,25 @@ title = "Create PDF from Office documents (LibreOffice sidecar)"
area = "J"
tier = "P2"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["file.open"]
tools = ["doc_create_multiple", "doc_combine"]
evidence = [
"crates/create/src/office.rs::office_files_are_recognised_by_extension_and_signature",
"crates/create/src/office.rs::other_files_are_not_office_documents",
"crates/create/src/office.rs::a_converter_that_hangs_is_stopped",
"crates/create/src/office.rs::a_converter_that_makes_no_pdf_is_reported",
"crates/create/src/office.rs::libreoffice_converts_a_document_when_installed",
"crates/create/src/office.rs::the_profile_turns_off_macros_and_never_updates_links",
"crates/create/src/office.rs::a_timeout_stops_the_processes_the_converter_started",
"crates/automation/tests/it/office.rs::office_documents_convert_separately_with_their_text",
"crates/automation/tests/it/office.rs::office_documents_combine_in_order_with_a_bookmark_each",
"crates/engine/src/tests.rs::office_documents_convert_through_libreoffice_or_explain_why_not",
"crates/engine/src/tests.rs::inline_conversions_refuse_office_documents_without_running_libreoffice",
"crates/ui-egui/src/platform/open_worker/tests.rs::file_open_converts_an_office_document_on_the_worker",
"crates/ui-egui/src/platform/open_worker/tests.rs::the_open_picker_defaults_to_pdf_and_offers_images_office_text_and_all_files",
]
notes = "File ▸ Open (desktop) opens Word, Excel, PowerPoint, OpenDocument and RTF files (Office documents filter; PDF stays the default) as new, unsaved PDFs, converted on the open worker by an installed LibreOffice run headless in a private temporary folder with a fresh profile (macros disabled, untrusted links blocked, Writer and Calc linked content never updated) and stopped after 120 s. Found through PDFCRAFT_SOFFICE, the PATH, or the usual install folders; without it the user is told LibreOffice is needed. The automation tools that convert files (doc_create_multiple, doc_combine, batch convert) take Office documents too. Not yet: Insert pages, Combine files and Create ▸ Multiple files in the app refuse Office documents (they convert on the frame thread) and point to File ▸ Open; no Create ▸ From Office command or conversion settings; Visio and Publisher; the web build; layout fidelity is LibreOffice's, not measured against Acrobat; Impress has no link-update setting and the process's network access isn't sandboxed (synthetic linked sections, pictures and cell references made no connection with LibreOffice 26.8.1); the real-conversion tests skip where LibreOffice isn't installed, CI included."
[[feature]]
id = "create.create-multiple"