Merge remote-tracking branch 'upstream/main' into lane7-4608b

# Conflicts:
#	crates/xfa/src/layout/flow.rs
#	parity/acrobat-features.toml
This commit is contained in:
mtlcl410
2026-11-09 19:13:08 +08:00
290 changed files with 20395 additions and 1534 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 3. Agent control (automation and MCP)
- **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool").
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token.
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token.
- **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token.
- **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed).
- **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist.
-1
View File
@@ -3244,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
Generated
+4 -14
View File
@@ -1289,7 +1289,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -4268,6 +4268,7 @@ dependencies = [
"fmv-macos-events",
"log",
"pdfcraft-engine",
"pdfcraft-platform",
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
@@ -4696,6 +4697,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"lopdf",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
@@ -4767,6 +4769,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"flate2",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
@@ -8766,7 +8769,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -8781,18 +8783,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+1 -1
View File
@@ -28,7 +28,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.18.1" }
pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
+6 -3
View File
@@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there.
```sh
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
@@ -472,11 +472,14 @@ Installers and executables are code-signed.
The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings,
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing;
`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them.
The MSI installs for your account only by default, without administrator privileges. Choose
"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
for all users. It adds "Edit with PdfCraft"
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the
running PdfCraft) to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
"Show more options"), without changing which app opens them by default. For unattended deployment
without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
@@ -488,7 +491,7 @@ msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
| Property | `=0` leaves out |
|---|---|
| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in PdfCraft" on PDF files |
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
+4
View File
@@ -313,6 +313,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -442,6 +443,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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-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.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
@@ -452,6 +454,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
@@ -523,6 +526,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+29 -8
View File
@@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
let bytes = read(path)?;
// A file that can't be read (past the input cap, say) is this file's record, not a crash of the child.
let bytes = match read(path) {
Ok(bytes) => bytes,
Err(e) => {
let status = if e.contains("larger than") { "input-too-large" } else { "read-error" };
let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0,
"ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::<String>() });
stdout_line(format_args!("{line}"))?;
// (the record is the answer `check` reads; the error and exit status are for a direct run)
return Err(e.into());
}
};
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
Ok(info) => {
@@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
// so a panic in cos shows up as a crash of this process.
if args.iter().any(|a| a == "--edit") {
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
edit_round_trip(&read(path)?);
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
@@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
fn edit_round_trip(bytes: &[u8]) {
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental};
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
// One allowance for every stream, charged for what each decode did (failed ones too), so
// many small bombs can't each inflate to the per-stream limit.
let mut budget = DecodeBudget::page();
for num in doc.object_numbers().into_iter().take(20_000) {
if budget.exhausted() {
break;
}
if let Ok(o) = doc.try_get(num)
&& let Object::Stream(s) = &*o
{
let _ = s.decoded();
let _ = budget.decode(s);
}
}
if let Some(root) = doc.root() {
@@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
eprintln!("{}: could not resolve directory: {error}", p.display());
eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug());
continue;
}
};
@@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
let status = r["status"].as_str().unwrap_or("?").to_string();
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
if status != "ok" {
// One record per line: a file name or detail with a line break or a terminal control in
// it is written escaped, so it can't forge another record (the JSON report keeps both).
let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect();
eprintln!(
"[{}/{}] {status:<28} {} {}",
"[{}/{}] {:<28} {} {}",
n + 1,
files.len(),
f.display(),
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
status.escape_debug().to_string(),
f.display().to_string().escape_debug(),
detail.escape_debug()
);
}
results.push(r);
+20 -1
View File
@@ -4,8 +4,19 @@ use super::options::{
};
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads).
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something
/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory.
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
use std::io::Read as _;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > MAX_INPUT {
return Err(format!("{path}: the file is larger than 512 MiB"));
}
Ok(Arc::new(bytes))
}
/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
@@ -387,6 +398,14 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
// A page image is an image taken out of the document, as a snapshot or Export is in the app:
// like `text`, it needs the document to allow copying (or the permissions password).
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
doc.check_copying().map_err(
|_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it",
)?;
}
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
+72 -15
View File
@@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
/// Most a control file may hold. The app writes under 100 bytes.
const CONTROL_FILE_MAX: u64 = 64 * 1024;
#[cfg(not(windows))]
const CONTROL_FILE_ADVICE: &str =
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
// cmd.exe doesn't expand `~` (#3922).
#[cfg(windows)]
const CONTROL_FILE_ADVICE: &str =
r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`";
/// Read the file written by `pdfcraft --control FILE`.
///
/// It names the port that receives the token and every command, typed text included, so it must
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
/// else or open to other users (the app writes it with mode 600).
/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or
/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
use std::io::Read as _;
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
if at_path.file_type().is_symlink() {
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
@@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
if !at_path.is_file() {
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
}
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
#[cfg(not(windows))]
let text = {
use std::io::Read as _;
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
text
};
// Windows: the same owner and access check, on the security descriptor of the very handle the
// contents are read through, so nothing swapped in at the path gets through (#3922). A file
// planted by another user, or one other users can read, is refused; if the check itself can't
// run, the file isn't trusted either.
#[cfg(windows)]
let text = {
use pdfcraft_platform::owner_only;
let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
.map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?
};
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
}
@@ -112,6 +137,38 @@ fn current_uid() -> u32 {
mod tests {
use super::control_file_problem;
/// #3922: on Windows the CLI checks the control file's owner and access list. A file other
/// users can read is refused; the same file restricted to its owner is accepted.
#[cfg(windows)]
#[test]
fn on_windows_a_control_file_other_users_can_read_is_refused() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
let file = dir.join("ctl.json");
std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
let path = file.to_string_lossy().into_owned();
let refused = super::read_control_file(&path).unwrap_err();
assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
// The contents come with the check, capped: exactly the limit is read, one byte more is refused.
let padded = |len: usize| {
let json = r#"{"port":4242,"token":"t0ken"}"#;
json.to_string() + &" ".repeat(len - json.len())
};
std::fs::write(&file, padded(64 * 1024)).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
// Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
assert!(super::read_control_file(&path).is_err(), "not UTF-8");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_control_file_must_be_yours_and_private() {
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
+15
View File
@@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
);
}
}
/// A failing file's name goes to stderr escaped: a line break in it can't start a second,
/// forged record. The JSON report keeps the name as it is.
#[cfg(unix)]
#[test]
fn a_file_name_cannot_forge_a_stderr_record() {
let dir = Workdir::new("escaped-name");
let name = "bad\n[9] ok forged.pdf";
std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap();
let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect();
assert_eq!(records.len(), 1, "{stderr}");
assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}");
}
+11
View File
@@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
assert!(!stderr.contains("panicked"), "{stderr}");
}
}
/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which
/// `check` relays, not an empty stdout that reads as a crash.
#[test]
fn check_one_reports_an_unreadable_file_as_a_record() {
let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli");
let stdout = String::from_utf8_lossy(&out.stdout);
let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}"));
assert_eq!(record["status"], "read-error");
assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}");
}
@@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() {
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
if pw == "user" {
// The user password opens it, but a page image is a copy the permissions forbid.
assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}");
assert!(!png.exists(), "{alg:?}: no image is written");
continue;
}
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
+18
View File
@@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) {
opts.mode(0o600);
}
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
// On Windows the file inherits its folder's access list, as the app's would before it
// restricts it (#3922).
#[cfg(windows)]
{
use pdfcraft_platform::owner_only;
owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap();
// The file the CLI is handed must already pass its check, or a refusal below would be
// the helper's fault, not the CLI's.
let sddl = owner_only::sddl_of(path).unwrap();
let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
let folder = path.parent().map(listing);
assert_eq!(
owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()),
None,
"restricted file: {sddl}; icacls: {:?}; folder: {folder:?}",
listing(path)
);
}
}
fn ui(control: &Path, method: &str) -> Output {
+6
View File
@@ -14,6 +14,8 @@ workspace = true
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# `owner_only`: the --control file is restricted to its user on Windows (#3922).
pdfcraft-platform = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
@@ -62,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
uds_windows = "=1.2.1"
# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons
# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree
# through eframe and covered by the workspace's winit patch.
winit = { version = "0.30", default-features = false }
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
+150 -33
View File
@@ -12,6 +12,8 @@
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//! `--combine files…` adds the files to the Combine files tab (File Explorer's "Combine files in
//! PdfCraft", #585), of the PdfCraft already running when there is one.
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
@@ -69,6 +71,7 @@ Options:
--two-paged open command-line PDFs in noncontinuous two-page view
--new-window open a window of its own instead of passing the files to a running PdfCraft
--create-images [images…] put the images in one PDF and ask for the page DPI
--combine files… add the files to the Combine files tab, of a running PdfCraft if there is one
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
--page N, --zoom 150, --layout continuous, --theme dark, …
view settings applied once the files are open
@@ -231,7 +234,7 @@ fn main() -> eframe::Result {
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
let may_hand_off = startup.may_hand_off();
let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup;
let startup::Arguments { files, options, control_file, create_images, combine, two_paged, .. } = startup;
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
@@ -255,13 +258,13 @@ fn main() -> eframe::Result {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
let instance = match single_instance::claim(&files, may_hand_off, combine) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged };
let launch = Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -337,7 +340,15 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
// eframe replaces ICON_BIG with a system-sized icon on its first active frame.
// An explicitly empty icon disables that setter (None selects eframe's default).
// Windows installs both sizes directly through winit in app_creator instead.
#[cfg(target_os = "windows")]
{
viewport = viewport.with_icon(egui::IconData::default());
}
// Dock, taskbar, Alt-Tab and launcher icon when running unbundled.
#[cfg(not(target_os = "windows"))]
match eframe::icon_data::from_png_bytes(APP_ICON_PNG) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(e) => log::warn!("app icon: {e}"),
@@ -368,6 +379,8 @@ struct Launch {
options: Vec<(String, String)>,
control_file: Option<String>,
create_images: bool,
/// `--combine`: the files go to the Combine files tab.
combine: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
@@ -386,13 +399,55 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
false
}
/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`).
#[cfg(target_os = "windows")]
fn taskbar_icon(png: &[u8]) -> Option<winit::window::Icon> {
let icon = match eframe::icon_data::from_png_bytes(png) {
Ok(icon) => icon,
Err(e) => {
log::warn!("taskbar icon: {e}");
return None;
}
};
match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
Ok(icon) => Some(icon),
Err(e) => {
log::warn!("taskbar icon: {e}");
None
}
}
}
/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds.
#[cfg(target_os = "windows")]
fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) {
if let Some(icon) = taskbar_icon(png) {
set(icon);
}
}
/// Install both window icons before the first frame. eframe's icon setter is disabled in
/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818).
#[cfg(target_os = "windows")]
fn set_taskbar_icon(window: &winit::window::Window) {
use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _};
install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon)));
// The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs).
let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32;
match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) {
Ok(icon) => window.set_window_icon(Some(icon)),
Err(e) => log::warn!("window icon: {e}"),
}
}
/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready.
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
let Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -413,6 +468,9 @@ fn app_creator<'a>(
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
if let Some(window) = cc.winit_window() {
set_taskbar_icon(window);
}
app.motion.source = Some(motion::source());
}
app.set_startup_update_check(true);
@@ -466,6 +524,9 @@ fn app_creator<'a>(
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
}
} else if combine {
app.reopen_last_files(&[]);
app.combine_paths(&files);
} else {
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
@@ -506,7 +567,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
Ok(())
}
Err(e) => {
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json");
// cmd.exe doesn't expand `~` (#3922).
let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" };
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}");
log::error!("{message}");
Err(message)
}
@@ -515,15 +578,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
/// Write the control endpoint so that only the current user can read the token.
///
/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
/// replaces `path`. Opening `path` itself would follow a link planted there and truncate whatever
/// it points at; a rename replaces the link and leaves its target alone. A reader polling for the
/// file also never sees it half-written.
/// The JSON goes to a new file, created fresh and owner-only, which then replaces `path`. On Unix
/// the file sits next to `path` with mode 0600. On Windows it sits in a new folder next to `path`
/// whose access list, of this user and SYSTEM only, it inherits when it is created, so no other
/// account can open it at any time (#3922; review on #3956). Opening `path` itself would follow a
/// link planted there and truncate whatever it points at; a rename replaces the link and leaves
/// its target alone. A reader polling for the file also never sees it half-written.
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
use std::io::{ErrorKind, Write};
use std::io::ErrorKind;
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
let path = std::path::Path::new(path);
let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?;
// Windows: the user's SID, read once for the private folder and for the file in it, since each
// lookup starts `whoami` (review on #3956).
#[cfg(windows)]
let me = pdfcraft_platform::owner_only::current_user_sid()?;
#[cfg(windows)]
let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me);
#[cfg(not(windows))]
let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(());
// Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from
// the operating system's random source.
let random = std::hash::RandomState::new();
@@ -532,35 +605,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
staged.push(name);
staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i)));
let staged = path.with_file_name(staged);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(&staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
// Windows: a file created next to `path` would inherit that folder's access list, which may
// let other users read it, and restricting it afterwards leaves a window in which one of
// them opens a handle; Windows checks access only when a handle is opened, so a later
// change of the list doesn't revoke it. The file is created in a private folder instead,
// and that folder is removed once the file has left it.
#[cfg(windows)]
let (staging, staged) = {
match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// As for a file below: something at the name we may not open still takes it.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
}
let file = staged.join(name);
(staged, file)
};
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(&staged, path));
if done.is_err() {
let _ = std::fs::remove_file(&staged);
let done = stage_control_file(&staged, path, &json, &restrict);
#[cfg(windows)]
let _ = std::fs::remove_dir(&staging);
match done {
Some(done) => return done,
None => continue,
}
return done;
}
Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
}
/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`.
/// `None` when something already has the name, so the caller tries another. On any other failure
/// the new file is removed and nothing is left at `path`'s side.
fn stage_control_file(
staged: &std::path::Path,
path: &std::path::Path,
json: &str,
restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
) -> Option<std::io::Result<()>> {
use std::io::{ErrorKind, Write};
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => return None,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None,
Err(e) => return Some(Err(e)),
};
// Windows: the file inherited the private folder's list. It also gets a list of its own, so it
// stays private wherever the rename puts it, and that is checked before the token is written;
// nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing.
if let Err(e) = restrict(staged) {
drop(file);
let _ = std::fs::remove_file(staged);
return Some(Err(e));
}
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(staged, path));
if done.is_err() {
let _ = std::fs::remove_file(staged);
}
Some(done)
}
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
///
/// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use
+96 -35
View File
@@ -14,7 +14,9 @@
//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is
//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the
//! paths exactly as File ▸ Open would.
//! paths exactly as File ▸ Open would. A `--combine` launch (File Explorer's "Combine files in
//! PdfCraft", #585, one launch per selected file) hands them over the same way with
//! `"combine": true`, and the running app adds them to its Combine files tab instead.
//!
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
@@ -58,7 +60,10 @@ pub enum Claim {
/// Files that later launches handed over, and how to wake the UI for them.
#[derive(Default)]
struct Inbox {
paths: Vec<String>,
/// The requests in the order they arrived: [`OsEvent::Open`] or, for `--combine`,
/// [`OsEvent::Combine`]. One queue, so the UI handles an Open that came after a Combine after
/// it, and that Open comes to the front instead of opening behind the Combine files tab.
events: Vec<OsEvent>,
wake: Option<Box<dyn Fn() + Send>>,
}
@@ -74,11 +79,20 @@ impl Server {
Self { inbox: Arc::default() }
}
/// Queue files from another launch and wake the UI.
/// Queue files from another launch (to open, or to combine) and wake the UI.
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
fn deliver(&self, paths: Vec<String>) {
fn deliver(&self, paths: Vec<String>, combine: bool) {
if paths.is_empty() {
return;
}
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox.paths.extend(paths);
// A run of requests of one kind (File Explorer starts one launch per selected file) stays
// one event; a request of the other kind starts a new one after it.
match (inbox.events.last_mut(), combine) {
(Some(OsEvent::Combine(queued)), true) | (Some(OsEvent::Open(queued)), false) => queued.extend(paths),
(_, true) => inbox.events.push(OsEvent::Combine(paths)),
(_, false) => inbox.events.push(OsEvent::Open(paths)),
}
if let Some(wake) = &inbox.wake {
wake();
}
@@ -95,8 +109,8 @@ impl Server {
let inbox = Arc::clone(&self.inbox);
let ctx = ctx.clone();
Box::new(move || {
let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
if paths.is_empty() {
let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events);
if events.is_empty() {
return Vec::new();
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
@@ -104,27 +118,37 @@ impl Server {
if native_wayland {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
}
vec![OsEvent::Open(paths)]
events
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
/// The request a launch sends: `{"open":[…]}` (with `"combine":true` for `--combine`) and a
/// newline.
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn request(paths: &[String]) -> String {
let mut line = serde_json::json!({ "open": paths }).to_string();
pub fn request(paths: &[String], combine: bool) -> String {
let mut value = serde_json::json!({ "open": paths });
if combine {
value["combine"] = serde_json::Value::Bool(true);
}
let mut line = value.to_string();
line.push('\n');
line
}
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
/// whose `open` is a non-empty list of non-empty strings.
/// The paths a request names and whether they are for Combine files, or `None` when it isn't a
/// request: anything but one JSON object whose `open` is a non-empty list of non-empty strings
/// (`combine`, when there, must be a boolean).
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn parse_request(line: &str) -> Option<Vec<String>> {
pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
let open = value.get("open")?.as_array()?;
let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
(!paths.is_empty()).then_some(paths)
let combine = match value.get("combine") {
None => false,
Some(c) => c.as_bool()?,
};
(!paths.is_empty()).then_some((paths, combine))
}
/// File arguments made absolute against this launch's working folder, which the running app
@@ -134,18 +158,19 @@ pub fn absolute(paths: &[String]) -> Vec<String> {
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
/// when the launch asked for anything besides opening files, so it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// when the launch asked for anything besides opening (or, with `combine`, combining) files, so
/// it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool, combine: bool) -> Claim {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
Some(path) => claim_at(&path, files, may_hand_off, combine),
None => Claim::Alone,
}
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
let _ = (files, may_hand_off);
let _ = (files, may_hand_off, combine);
Claim::Alone
}
}
@@ -239,7 +264,7 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
/// [`claim`] with the socket at `path`.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool, combine: bool) -> Claim {
// Released when this function returns: by then this launch listens, or has handed over.
let _election = election(path);
// Two launches can start together: the one that loses the race to bind hands over to the
@@ -250,7 +275,7 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
if !may_hand_off {
return Claim::Alone;
}
return match hand_off(&stream, files) {
return match hand_off(&stream, files, combine) {
Ok(()) => Claim::HandedOff,
Err(e) => {
log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window");
@@ -296,12 +321,12 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
/// Send `files` over `stream` and wait for the running app's `ok`.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
fn hand_off(stream: &UnixStream, files: &[String], combine: bool) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut writer = stream;
writer.write_all(request(files).as_bytes())?;
writer.write_all(request(files, combine).as_bytes())?;
let mut reply = String::new();
std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?;
if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) }
@@ -343,8 +368,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
if line.is_empty() {
return Ok(());
}
let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths);
let (paths, combine) = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths, combine);
let mut writer = stream;
writer.write_all(b"ok\n")
}
@@ -356,16 +381,31 @@ mod tests {
#[test]
fn a_request_names_the_paths_and_round_trips() {
let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
let line = request(&paths);
let line = request(&paths, false);
assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
assert_eq!(parse_request(&line), Some(paths));
assert_eq!(parse_request(&line), Some((paths.clone(), false)));
assert!(!line.contains("combine"), "an open request says nothing about combining");
let line = request(&paths, true);
assert_eq!(parse_request(&line), Some((paths, true)), "a --combine launch's request");
}
#[test]
fn anything_but_a_list_of_paths_is_refused() {
for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] {
for line in [
"",
"ok\n",
"{}",
r#"{"open":[]}"#,
r#"{"open":[""]}"#,
r#"{"open":["a.pdf",3]}"#,
r#"{"open":"a.pdf"}"#,
"[\"a.pdf\"]",
r#"{"open":["a.pdf"],"combine":"yes"}"#,
r#"{"open":["a.pdf"],"combine":1}"#,
] {
assert_eq!(parse_request(line), None, "{line:?}");
}
assert_eq!(parse_request(r#"{"open":["a.pdf"],"combine":false}"#), Some((vec!["a.pdf".to_string()], false)));
}
#[test]
@@ -398,13 +438,31 @@ mod tests {
fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
let server = Server::new();
// Before the window exists: queued, nothing to wake yet.
server.deliver(vec!["a.pdf".into()]);
server.deliver(vec!["a.pdf".into()], false);
let ctx = egui::Context::default();
let mut poll = server.connect(&ctx, false);
server.deliver(vec!["b.pdf".into()]);
server.deliver(vec!["b.pdf".into()], false);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
assert!(poll().is_empty(), "each file is delivered once");
// Files to combine (one launch each, from File Explorer) arrive as one Combine event,
// apart from files to open.
server.deliver(vec!["c.pdf".into()], true);
server.deliver(vec!["e.pdf".into()], true);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Combine(c)] if c == &["c.pdf", "e.pdf"]), "{events:?}");
// #2503 review: requests reach the UI in the order they arrived, so an Open after a
// Combine is handled after it and comes to the front instead of opening behind the
// Combine files tab.
server.deliver(vec!["f.pdf".into()], true);
server.deliver(vec!["g.pdf".into()], false);
server.deliver(vec!["h.pdf".into()], true);
let events = poll();
assert!(
matches!(events.as_slice(), [OsEvent::Combine(f), OsEvent::Open(g), OsEvent::Combine(h)] if f == &["f.pdf"] && g == &["g.pdf"] && h == &["h.pdf"]),
"{events:?}"
);
assert!(poll().is_empty());
}
#[test]
@@ -442,12 +500,15 @@ mod tests {
#[test]
fn a_second_launch_hands_its_files_to_the_first() {
let path = socket("hand-off");
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
let Claim::Primary(first) = claim_at(&path, &[], true, false) else { panic!("the first launch listens") };
let mut poll = first.connect(&egui::Context::default(), false);
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
// A --combine launch hands its file over for the Combine files tab (#585).
assert!(matches!(claim_at(&path, &["C:\\c.pdf".into()], true, true), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Combine(p)] if p == &["C:\\c.pdf"]));
// A launch with options keeps its own window and doesn't take over the socket.
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false, false), Claim::Alone));
assert!(poll().is_empty());
}
@@ -462,7 +523,7 @@ mod tests {
let (path, barrier) = (path.clone(), Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
claim_at(&path, &[format!("C:\\{i}.pdf")], true)
claim_at(&path, &[format!("C:\\{i}.pdf")], true, false)
})
})
.collect();
@@ -483,6 +544,6 @@ mod tests {
}));
// `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail.
assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::Primary(_)));
}
}
+5
View File
@@ -7,6 +7,8 @@ pub(super) struct Arguments {
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
/// `--combine`: the files go to the Combine files tab.
pub combine: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
@@ -33,6 +35,9 @@ impl Arguments {
}
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
// File Explorer's "Combine files in PdfCraft" (#585): the files go to the Combine
// files tab, of the running PdfCraft when there is one.
"--combine" => parsed.combine = true,
"--new-window" => parsed.new_window = true,
"--two-paged" => parsed.two_paged = true,
flag if flag.starts_with("--two-paged=") => {
+61
View File
@@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
}
#[test]
fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() {
let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon");
assert!(icon.width > 0 && icon.height > 0);
assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4);
#[cfg(not(target_os = "windows"))]
assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon));
#[cfg(target_os = "windows")]
{
// None would load eframe's default icon; only the explicit empty sentinel prevents
// its first-frame setter from overwriting our full-resolution ICON_BIG.
for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] {
let viewport = super::native_options(false, false, renderer).viewport;
assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default()));
}
assert_eq!((icon.width, icon.height), (256, 256));
let mut installed = None;
super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon));
assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG");
let mut bad = None;
super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon));
assert!(bad.is_none());
}
}
#[test]
fn a_software_renderer_is_shown_to_the_user() {
use eframe::wgpu::DeviceType;
@@ -654,6 +679,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #3922: a control file created in a folder that lets every user read what's in it is still
/// readable by its user and SYSTEM only, like mode 0600 on Unix.
#[cfg(windows)]
#[test]
fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() {
use pdfcraft_platform::owner_only;
let dir = scratch();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
// What the folder hands down, for comparison: a plain file there is readable by Users.
std::fs::write(dir.join("plain.txt"), "x").unwrap();
let me = owner_only::current_user_sid().unwrap();
assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some());
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap();
assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}");
let (json, names) = written(&dir);
assert_eq!(json["token"].as_str(), Some("t0ken"));
assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind");
let _ = std::fs::remove_dir_all(&dir);
}
/// Review on #3956: writing the control file reads the user's SID once, for both the private
/// folder and the file in it, rather than starting `whoami` for each.
#[cfg(windows)]
#[test]
fn writing_the_control_file_looks_up_the_users_sid_once() {
use pdfcraft_platform::owner_only::sid_lookups_on_this_thread;
let dir = scratch();
let before = sid_lookups_on_this_thread();
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file");
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
+7 -1
View File
@@ -106,7 +106,13 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
if !seen.insert(r) {
continue;
}
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
let target = doc.get(r);
if matches!(&*target, Object::Array(_) | Object::Ref(_)) {
// An indirect /K array is a container, not an element or a parent.
stack.push(((*target).clone(), pg, parent, depth));
continue;
}
let Some(d) = target.as_dict().cloned() else { continue };
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
+10
View File
@@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
/// What all pages together may decode and keep: pages can share one stream, so the per-page limits
/// alone let a small file retain a plan for every page. Real documents stay far under these.
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
const MAX_TOTAL_OPERATORS: usize = 2_000_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
let (mut total_bytes, mut total_ops) = (0usize, 0usize);
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
@@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
total_bytes = total_bytes.saturating_add(bytes.len());
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
total_ops = total_ops.saturating_add(parsed.ops.len());
if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS {
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
}
let spans = text_spans(&parsed.ops, index)?;
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
+149 -20
View File
@@ -10,6 +10,23 @@ fn reference(object: Option<&Object>, what: &str) -> Result<ObjRef, String> {
object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference"))
}
fn root_document_ref(doc: &Document, k: Option<&Object>) -> Result<ObjRef, String> {
let Some(k) = k else {
return Err("the structure tree root K must be an indirect reference".into());
};
if let Some(r) = k.as_ref()
&& doc.get(r).as_dict().is_some()
{
return Ok(r);
}
if let Some([item]) = doc.resolve(k).as_array().map(Vec::as_slice)
&& let Some(r) = item.as_ref()
{
return Ok(r);
}
Err("the structure tree root K must be an indirect reference".into())
}
fn nums(doc: &Document, parent_tree: ObjRef) -> Result<Vec<Object>, String> {
let tree = doc.get(parent_tree);
let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?;
@@ -31,10 +48,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let root = doc.root().ok_or("the PDF has no catalog")?;
let catalog_object = doc.get(root);
let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?;
if !catalog.contains(b"AcroForm") {
if !catalog.get(b"AcroForm").is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) {
return Err("the PDF has no AcroForm fields".into());
}
let existing_root = catalog.get(b"StructTreeRoot").cloned();
let existing_root = catalog.get(b"StructTreeRoot").filter(|v| !matches!(*doc.resolve(v), Object::Null)).cloned();
let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let mut widgets = Vec::new();
@@ -46,7 +63,9 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let Some(reference) = annot.as_ref() else { continue };
let resolved = doc.get(reference);
let Some(dict) = resolved.as_dict() else { continue };
if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) {
let is_widget = dict.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Widget");
let has_parent = dict.get(b"StructParent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).is_some_and(|k| k >= 0);
if is_widget && !has_parent && seen.insert(reference) {
widgets.push((page.obj, reference));
}
}
@@ -54,16 +73,29 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
if widgets.is_empty() {
return Err("the AcroForm has no untagged page widgets".into());
}
let count = i64::try_from(widgets.len()).map_err(|_| "too many form widgets")?;
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
let (struct_root, document_ref, mut existing_kids, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root
{
let struct_root = reference(Some(&existing_root), "structure tree root")?;
let structure_object = doc.get(struct_root);
let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?;
let document_ref = reference(structure.get(b"K"), "structure tree root K")?;
let document = doc.get(document_ref);
if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) {
let document_ref = root_document_ref(doc, structure.get(b"K"))?;
let document_obj = doc.get(document_ref);
let Some(document_dict) = document_obj.as_dict() else {
return Err("the structure tree root must point directly to a Document element".into());
};
if document_dict.get(b"S").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Document") {
return Err("the structure tree root must point directly to a Document element".into());
}
let existing_kids = match document_dict.get(b"K") {
None => Vec::new(),
Some(k) => match &*doc.resolve(k) {
Object::Null => Vec::new(),
Object::Array(items) => items.clone(),
_ => vec![k.clone()],
},
};
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
let parent_nums = nums(doc, parent_tree)?;
if parent_nums.len() % 2 != 0 {
@@ -71,14 +103,26 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
}
let mut greatest = -1_i64;
for pair in parent_nums.as_chunks::<2>().0 {
let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) };
let Some(key) = doc.resolve(&pair[0]).as_int() else {
return Err("the structure parent tree has a non-integer key".into());
};
if key < 0 {
return Err("the structure parent tree has a negative key".into());
}
greatest = greatest.max(key);
}
let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
(struct_root, document_ref, parent_tree, parent_nums, first_key, false)
let mut first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
if let Some(next_obj) = structure.get(b"ParentTreeNextKey") {
let resolved_next = doc.resolve(next_obj);
if !matches!(*resolved_next, Object::Null) {
let next_key = resolved_next.as_int().ok_or("the structure parent tree next key is invalid")?;
if next_key < 0 {
return Err("the structure parent tree next key is negative".into());
}
first_key = first_key.max(next_key);
}
}
(struct_root, document_ref, existing_kids, parent_tree, parent_nums, first_key, false)
} else {
let mut root_dict = Dict::new();
root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec()));
@@ -88,9 +132,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
document.set(b"P".to_vec(), Object::Ref(struct_root));
let document_ref = doc.add(Object::Dict(document));
let parent_tree = doc.add(Object::Dict(Dict::new()));
(struct_root, document_ref, parent_tree, Vec::new(), 0, true)
(struct_root, document_ref, Vec::new(), parent_tree, Vec::new(), 0, true)
};
let mut kids = Vec::with_capacity(widgets.len());
// ISO 32000-1 14.7.4.4: reserve the next unused key before adding any parent-tree entries.
let next_key = first_key.checked_add(count).ok_or("the structure parent tree has no available next key")?;
for (offset, (page, widget)) in widgets.iter().copied().enumerate() {
let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?;
let mut objr = Dict::new();
@@ -105,16 +150,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let form_ref = doc.add(Object::Dict(form));
doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?;
parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]);
kids.push(Object::Ref(form_ref));
existing_kids.push(Object::Ref(form_ref));
}
doc.update_dict(document_ref, |d| {
let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new()));
let mut all = match old {
Object::Array(items) => items,
item => vec![item],
};
all.extend(kids);
d.set(b"K".to_vec(), Object::Array(all));
d.set(b"K".to_vec(), Object::Array(existing_kids));
})
.map_err(|e| e.to_string())?;
doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?;
@@ -122,6 +161,12 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
doc.update_dict(struct_root, |d| {
d.set(b"K".to_vec(), Object::Ref(document_ref));
d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
} else {
doc.update_dict(struct_root, |d| {
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
}
@@ -136,3 +181,87 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
.map_err(|e| e.to_string())?;
Ok(widgets.len())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::tags;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn existing_tree(next: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree 7 0 R {next} >>"),
"<< /S /Document /P 5 0 R /K [] >>".into(),
"<< /Nums [7 []] >>".into(),
"null".into(),
])
}
#[test]
fn extending_tree_creates_missing_next_key() {
for next in ["", "/ParentTreeNextKey null", "/ParentTreeNextKey 8 0 R"] {
let mut doc = existing_tree(next);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
assert_eq!(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().int(b"StructParent"), Some(8));
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(9));
}
}
#[test]
fn exhausted_next_key_rejects_before_mutation() {
let mut doc = existing_tree(&format!("/ParentTreeNextKey {}", i64::MAX));
let before = doc.get(ObjRef::new(4, 0));
let objects = doc.object_numbers().len();
assert!(tag_form_fields(&mut doc).is_err());
assert_eq!(doc.get(ObjRef::new(4, 0)), before);
assert_eq!(doc.object_numbers().len(), objects);
}
#[test]
fn extends_existing_tree_with_array_root_k_indirect_document_kids_and_indirect_nums_keys() {
let mut doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] /StructParent null >>".into(),
"<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 8 0 R /ParentTreeNextKey 5 >>".into(),
"<< /S /Document /P 6 0 R /K 10 0 R >>".into(),
"<< /Nums [11 0 R [9 0 R]] >>".into(),
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
"[9 0 R]".into(),
"0".into(),
]);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
// Existing paragraph (9 0 R) and new Form tag remain flat siblings under /Document (7 0 R).
let doc_k = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().clone();
assert_eq!(doc_k.len(), 2);
assert_eq!(doc_k[0].as_ref(), Some(ObjRef::new(9, 0)));
let listed: Vec<_> = tags(&doc).into_iter().map(|t| t.kind).collect();
assert_eq!(listed, ["Document", "P", "Form"]);
// Widget got key 5 from /ParentTreeNextKey, and /ParentTreeNextKey advanced to 6.
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"StructParent"), Some(5));
assert_eq!(doc.get(ObjRef::new(6, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(6));
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ impl Walk<'_> {
// Marked-content stack: whether each level is tagged or an artifact.
let mut stack: Vec<bool> = Vec::new();
let mut untagged = 0;
for op in pdfcraft_content::parse(data).ops {
// One operator at a time: the walk keeps counts, never the stream's operators.
for op in pdfcraft_content::ops(data) {
let inside = covered || stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")),
+359
View File
@@ -0,0 +1,359 @@
//! A layout table as plain blocks: a table used only to place content (label and value pairs, a
//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One
//! edit turns the table, its row groups and rows into Div elements and its cells into P elements
//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside
//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105).
use std::collections::HashSet;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use crate::alt;
/// The most structure elements one table may have: past it the edit is refused.
const MAX_ELEMENTS: usize = 100_000;
/// The deepest nesting of direct (inline) elements followed.
const MAX_DEPTH: usize = 256;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum LayoutTableError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("the tag is not a table")]
NotATable,
#[error("the table has too many tags, or tags nested too deeply")]
TooLarge,
#[error(transparent)]
Cos(#[from] CosError),
}
/// What a table part becomes; `None` for anything else (which keeps its type and contents).
fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> {
match ty {
b"Table" if root => Some("Div"),
b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"),
b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }),
_ => None,
}
}
struct Retag<'a> {
doc: &'a Document,
role_map: Dict,
/// Indirect elements under the table still to look at.
queue: Vec<ObjRef>,
/// How many elements changed.
changed: usize,
}
impl Retag<'_> {
fn standard(&self, d: &Dict) -> Option<Vec<u8>> {
Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?))
}
/// Whether children `k` include structure elements (indirect or direct, also in an indirect
/// array of children).
fn has_tags(&self, k: &Object, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
match k {
Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) => d.contains(b"S"),
array @ Object::Array(_) => self.has_tags(array, depth + 1),
_ => false,
},
Object::Dict(d) => d.contains(b"S"),
_ => false,
}
}
/// Element `d` as a block when it is a part of the table (`root`: the table itself), with its
/// direct children converted the same way and its indirect ones queued; `None` otherwise.
fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result<Option<Dict>, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
let Some(ty) = self.standard(d) else { return Ok(None) };
// As written: a single child is often a reference to it, which must stay one.
let kids = d.get(b"K").cloned();
let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0));
let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) };
let mut out = d.clone();
out.set(b"S".to_vec(), Object::name(block));
self.drop_table_attributes(&mut out);
if let Some(kids) = kids {
let kids = self.convert_kids(&kids, depth + 1)?;
out.set(b"K".to_vec(), kids);
}
self.changed = self.changed.saturating_add(1);
Ok(Some(out))
}
/// Children `k` with direct table parts converted and indirect elements queued (references
/// stay references; an indirect array of children is written out only when a direct table
/// part in it changed).
fn convert_kids(&mut self, k: &Object, depth: usize) -> Result<Object, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
Ok(match k {
Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::<Result<_, _>>()?),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) if d.contains(b"S") => {
self.queue.push(*r);
k.clone()
}
array @ Object::Array(_) => {
let converted = self.convert_kids(array, depth + 1)?;
if converted == *array { k.clone() } else { converted }
}
_ => k.clone(),
},
Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? {
Some(block) => Object::Dict(block),
None => k.clone(),
},
_ => k.clone(),
})
}
/// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and
/// their revision numbers; an `/A` left empty goes.
fn drop_table_attributes(&self, d: &mut Dict) {
let Some(a) = d.get(b"A").cloned() else { return };
let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table"));
let resolved = self.doc.resolve(&a);
let Object::Array(items) = &*resolved else {
if is_table(&a) {
d.remove(b"A");
}
return;
};
let mut kept = Vec::with_capacity(items.len());
let mut dropping = false;
for item in items {
match item {
// A revision number goes with the attribute object before it.
Object::Int(_) if dropping => {}
_ => {
dropping = is_table(item);
if !dropping {
kept.push(item.clone());
}
}
}
}
if kept.is_empty() {
d.remove(b"A");
} else if kept.len() != items.len() {
d.set(b"A".to_vec(), Object::Array(kept));
}
}
}
/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into
/// Div and P elements as the module documentation says; returns how many elements changed. Tables
/// nested in its cells stay tables.
pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result<usize, LayoutTableError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else {
return Err(LayoutTableError::NotATag(table));
};
let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 };
if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) {
return Err(LayoutTableError::NotATable);
}
// Read everything first, then write: a refused table changes nothing.
let mut updates: Vec<(ObjRef, Dict)> = Vec::new();
let mut seen = HashSet::new();
if let Some(block) = retag.convert(&element.dict, true, 0)? {
seen.insert(table);
updates.push((table, block));
}
while let Some(r) = retag.queue.pop() {
if !seen.insert(r) {
continue;
}
if seen.len() > MAX_ELEMENTS {
return Err(LayoutTableError::TooLarge);
}
let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue };
if let Some(block) = retag.convert(&d, false, 0)? {
updates.push((r, block));
}
}
let changed = retag.changed;
for (r, block) in updates {
doc.update_dict(r, |d| *d = block)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object};
use super::*;
/// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries)
/// over the Document element (3), then `objects` from 4 on.
fn doc(root: &str, objects: &[&str]) -> Document {
let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")];
bodies.extend(objects.iter().map(|s| s.to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn r(n: u32) -> ObjRef {
ObjRef::new(n, 0)
}
fn dict(doc: &Document, n: u32) -> Dict {
doc.get(r(n)).as_dict().cloned().unwrap()
}
fn ty(doc: &Document, n: u32) -> Vec<u8> {
dict(doc, n).name(b"S").unwrap().to_vec()
}
#[test]
fn a_label_and_value_table_becomes_divs_and_paragraphs() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
// 4: the table, with a layout attribute and a summary (revision 1).
"<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
"<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>",
"<< /S /TD /K 1 >>",
"<< /S /TR /K [9 0 R 10 0 R] >>",
"<< /S /TD /K 2 >>",
"<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>",
],
);
let before: Vec<ObjRef> = crate::tags(&d).iter().map(|t| t.object).collect();
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
// The content and the order stay; Table attributes go, layout ones stay.
assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::<Vec<_>>(), before);
assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0)));
assert_eq!(dict(&d, 6).get(b"A"), None);
let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) };
assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}");
assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..]));
let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() };
assert_eq!(a.len(), 1);
}
#[test]
fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Table /K [5 0 R] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
// 6: a cell holding a paragraph; 7: a cell holding a table of its own.
"<< /S /TD /K [8 0 R] >>",
"<< /S /TD /K [9 0 R] >>",
"<< /S /P /K 0 >>",
"<< /S /Table /K [10 0 R] >>",
"<< /S /TR /K [11 0 R] >>",
"<< /S /TD /K 1 >>",
],
);
layout_table_to_blocks(&mut d, r(4)).unwrap();
for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
}
#[test]
fn direct_rows_and_cells_are_converted_in_place() {
let mut d = doc(
"/RoleMap << /Grid /Table /Row /TR >>",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>",
"<< /S /Span /K 1 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4));
let table = dict(&d, 4);
assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table");
let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() };
let row = rows[0].as_dict().unwrap();
assert_eq!(row.name(b"S"), Some(&b"Div"[..]));
let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag");
assert_eq!(ty(&d, 5), b"Span");
}
#[test]
fn only_a_reachable_table_is_changed() {
let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable));
assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree");
assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1))));
assert_eq!(ty(&d, 5), b"Table");
}
#[test]
fn a_single_child_written_as_a_reference_stays_its_own_object() {
// /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect
// array (object 9) holding a direct cell and a reference.
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R 7 0 R] >>",
"<< /S /Table /K 5 0 R >>",
"<< /S /TR /K 6 0 R >>",
"<< /S /TD /K 0 >>",
"<< /S /Table /K 8 0 R >>",
"<< /S /TR /K 9 0 R >>",
"[ << /S /TD /K 1 >> 10 0 R ]",
"<< /S /TD /K 2 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference");
assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6))));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed");
assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4));
let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1], Object::Ref(r(10)));
assert_eq!(ty(&d, 10), b"P");
}
#[test]
fn rows_that_point_at_each_other_end() {
// A row listed twice and a cell that lists its own row: each is converted once.
let mut d =
doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec()));
}
}
+2
View File
@@ -17,6 +17,7 @@ mod autotag;
mod autotag_fields;
mod content;
mod headings;
mod layout_table;
mod report;
mod structure;
mod tags;
@@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
pub use headings::{Heading, headings};
pub use layout_table::{LayoutTableError, layout_table_to_blocks};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
+267 -28
View File
@@ -101,6 +101,8 @@ pub enum TagError {
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag has too much or too deeply nested marked content")]
ContentLimit,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error("{0} applies only to {1} tags")]
@@ -118,7 +120,13 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
let Some(obj_elem) = reachable.iter().find(|e| e.object == object) else {
return Err(TagError::NotSiblings);
};
let Some(anc_elem) = reachable.iter().find(|e| e.object == anchor) else {
return Err(TagError::NotSiblings);
};
if obj_elem.parent != anc_elem.parent {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
@@ -126,8 +134,23 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let valid_parent = match obj_elem.parent {
Some(expected) => parent == expected,
None => doc
.root()
.and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"StructTreeRoot").and_then(Object::as_ref)))
.is_some_and(|root_ref| parent == root_ref),
};
if !valid_parent {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
let mut k_obj = owner.as_dict().and_then(|d| d.get(b"K")).cloned();
for _ in 0..16 {
let Some(Object::Ref(r)) = k_obj else { break };
k_obj = Some((*doc.get(r)).clone());
}
let Some(children) = k_obj.and_then(|k| k.as_array().cloned()) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
@@ -135,7 +158,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let mut reordered = children;
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
@@ -151,6 +174,10 @@ fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
fn tag_type_name(doc: &Document, d: &Dict) -> Option<Vec<u8>> {
d.get(b"S").map(|s| doc.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
@@ -162,7 +189,7 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
kind: String::from_utf8_lossy(&tag_type_name(doc, &e.dict)?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
@@ -176,13 +203,16 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
}
/// List marked-content references directly owned by an indirect tag.
/// Returns `ContentLimit` rather than a partial list beyond 65,536 marks or 512 child/reference
/// levels. Child structure elements do not count as direct marks.
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, TagError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) };
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
let mut marks = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(kids) = element.dict.get(b"K") {
direct_marks(doc, kids, element.page, &mut marks);
direct_marks(doc, kids, element.page, &mut seen, &mut marks)?;
}
// Each page's content is parsed once, however many of its MCIDs the tag owns.
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = std::collections::HashMap::new();
@@ -206,25 +236,63 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>
.collect())
}
fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut Vec<(Option<ObjRef>, i64)>) {
match item {
Object::Int(mcid) => out.push((page, *mcid)),
Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)),
Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) {
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
/// Bound both graph work and the queued array entries. Following references never uses the
/// call stack; the depth limit also rejects pathologically nested direct content explicitly.
fn direct_marks(
doc: &Document,
item: &Object,
page: Option<ObjRef>,
seen: &mut std::collections::HashSet<ObjRef>,
out: &mut Vec<(Option<ObjRef>, i64)>,
) -> Result<(), TagError> {
const MAX_DEPTH: usize = 512;
const MAX_MARKS: usize = 65_536;
// Account for the root `/K` container separately so an array of 65,536 entries passes.
let mut remaining = MAX_MARKS + 1;
let mut stack = vec![(std::sync::Arc::new(item.clone()), 0usize)];
while let Some((item, depth)) = stack.pop() {
remaining = remaining.checked_sub(1).ok_or(TagError::ContentLimit)?;
if depth > MAX_DEPTH {
return Err(TagError::ContentLimit);
}
Object::Ref(reference) => {
if let Some(dict) = doc.get(*reference).as_dict()
&& dict.name(b"Type") == Some(b"MCR")
&& let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int)
{
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
match &*item {
Object::Int(mcid) => {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((page, *mcid));
}
Object::Array(items) => {
if items.len() > remaining.saturating_sub(stack.len()) {
return Err(TagError::ContentLimit);
}
stack.extend(items.iter().rev().map(|item| (std::sync::Arc::new(item.clone()), depth + 1)));
}
Object::Dict(dict) if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").map(|m| doc.resolve(m)).and_then(|m| m.as_int()) {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
}
Object::Ref(reference) if seen.insert(*reference) => {
let target = doc.get(*reference);
// Resolved non-MCR dictionaries are child structure elements, not direct content,
// so do not push them onto the stack or charge them a second time.
let is_non_mcr_dict = matches!(
&*target,
Object::Dict(dict)
if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) != Some(b"MCR")
);
if !is_non_mcr_dict {
stack.push((target, depth + 1));
}
}
_ => {}
}
_ => {}
}
Ok(())
}
/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally
@@ -236,8 +304,8 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) };
let mut out: Vec<(Object, Option<Object>)> = Vec::new();
for item in items {
match (item, out.last_mut()) {
(Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
match (doc.resolve(item).as_int(), out.last_mut()) {
(Some(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
_ => out.push((item.clone(), None)),
}
}
@@ -245,13 +313,18 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
}
/// Whether attribute object `o` (resolved) is a Table attribute object.
fn is_table_attributes(o: &Object) -> bool {
o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table"))
fn is_table_attributes(doc: &Document, o: &Object) -> bool {
o.as_dict().and_then(|d| d.get(b"O")).map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) == Some(b"Table")
}
/// The value of `key` in the first Table attribute object of tag `d` that has it.
fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option<Object> {
attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned())
attribute_entries(doc, d)
.0
.iter()
.map(|(o, _)| doc.resolve(o))
.filter(|o| is_table_attributes(doc, o))
.find_map(|o| o.as_dict()?.get(key).map(|v| (*doc.resolve(v)).clone()).filter(|v| !matches!(v, Object::Null)))
}
/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first
@@ -266,11 +339,12 @@ fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Op
let mut entries: Vec<(Object, Option<Object>, Option<Dict>)> = entries
.into_iter()
.map(|(o, r)| {
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned());
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(doc, a)).and_then(|a| a.as_dict().cloned());
(o, r, table)
})
.collect();
let holding = |e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().is_some_and(|d| d.contains(key));
let holding =
|e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().and_then(|d| d.get(key)).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null));
match value {
Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) {
Some(i) => {
@@ -331,7 +405,7 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
if let Some(owner) = property.table_attribute_of() {
let role_map =
alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default();
let ty = tag_type_name(doc, &element.dict).map(|s| alt::standard(doc, &role_map, &s)).unwrap_or_default();
if ty != owner.as_bytes() {
let name = if property == TagProperty::Summary { "summary" } else { "scope" };
return Err(TagError::WrongTagType(name, owner));
@@ -357,3 +431,168 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn tag_content_and_table_attributes_resolve_indirect_entries() {
let content = "/P << /MCID 0 >> BDC BT /F1 12 Tf (Hello) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf (World) Tj ET EMC";
let doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /StructTreeRoot /K 6 0 R >>".into(),
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
"<< /S /Table /P 6 0 R /Pg 3 0 R /A << /O 11 0 R /Summary 12 0 R >> /K 9 0 R >>".into(),
"<< /S /TH /P 6 0 R /Pg 3 0 R /A [ << /O 11 0 R /Scope 13 0 R >> 14 0 R ] /K 10 0 R >>".into(),
"[15 0 R << /Type /MCR /MCID 16 0 R >>]".into(),
"15 0 R".into(),
"/Table".into(),
"(Indirect summary)".into(),
"/Column".into(),
"1".into(),
"0".into(),
"1".into(),
]);
let listed = tags(&doc);
let table = listed.iter().find(|t| t.object == ObjRef::new(7, 0)).unwrap();
let th = listed.iter().find(|t| t.object == ObjRef::new(8, 0)).unwrap();
assert_eq!(table.summary.as_deref(), Some("Indirect summary"));
assert_eq!(th.scope.as_deref(), Some("Column"));
let table_marks = tag_content(&doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(
table_marks,
[TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }, TagContentItem { page: Some(0), mcid: 1, text: "World".into() },]
);
let th_marks = tag_content(&doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(th_marks, [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }]);
}
#[test]
fn move_tag_resolves_indirect_array_children_and_cycles() {
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K 7 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"[4 0 R 6 0 R 5 0 R]".into(),
"6 0 R".into(),
]);
let first = ObjRef::new(4, 7);
let second = ObjRef::new(5, 9);
doc.set(first, (*doc.get(ObjRef::new(4, 0))).clone());
doc.set(second, (*doc.get(ObjRef::new(5, 0))).clone());
doc.set(ObjRef::new(6, 0), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(6, 0)), Object::Ref(second)]));
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), first, second]);
move_tag(&mut doc, second, first, TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), second, first]);
}
#[test]
fn tag_content_rejects_excessive_indirect_depth() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kid = doc.add(Object::Int(42));
for _ in 0..128 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
for _ in 0..1024 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_rejects_excessive_mark_count() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_537]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_accepts_full_mark_limit() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_536]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().len(), 65_536);
}
#[test]
fn tag_content_skips_many_child_elements_without_spending_mark_budget() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kids = Vec::new();
for _ in 0..33_000 {
let mut child = Dict::new();
child.set(b"S".to_vec(), Object::name("P"));
kids.push(Object::Ref(doc.add(Object::Dict(child))));
}
kids.push(Object::Int(42));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(kids))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
}
#[test]
fn tag_content_keeps_order_and_stops_indirect_cycles() {
let doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /P /P 2 0 R /K 4 0 R >>".into(),
"[1 5 0 R 4 0 R 3]".into(),
"<< /Type /MCR /MCID 2 >>".into(),
]);
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [1, 2, 3]);
}
#[test]
fn move_tag_rejects_non_sibling_tags_sharing_stale_parent_ref() {
// 8 0 R is a child of 6 0 R, while 9 0 R is a child of 7 0 R, even though both point /P at 6 0 R.
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K [6 0 R 7 0 R] >>".into(),
"<< /S /P >>".into(),
"<< /S /P >>".into(),
"<< /S /Sect /P 3 0 R /K [8 0 R 9 0 R] >>".into(),
"<< /S /Sect /P 3 0 R /K [9 0 R] >>".into(),
"<< /S /P /P 6 0 R >>".into(),
"<< /S /P /P 6 0 R >>".into(),
]);
// Give 6 0 R only [8 0 R] in the reachable tree, and put [8 0 R 9 0 R] on an unreachable object 4 0 R.
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap();
doc.update_dict(ObjRef::new(4, 0), |d| {
d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(9, 0))]));
})
.unwrap();
doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
assert_eq!(move_tag(&mut doc, ObjRef::new(9, 0), ObjRef::new(8, 0), TagPlacement::Before), Err(TagError::NotSiblings));
}
}
+17
View File
@@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
}
#[test]
fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() {
// Each page is just under the per-page limit; sharing one stream makes the total 21 times it.
let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990));
let pages = 21;
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect();
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")),
stream(&content),
];
bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string()));
let mut doc = pdf(&bodies, "");
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
assert!(reason.contains("too much content"), "{reason}");
}
+128 -22
View File
@@ -3,8 +3,8 @@
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the
//! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with
//! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box.
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints`
//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy
//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints`
//! itself; for the other values, callers that hold the document follow references first
//! (`draw::with_direct_values`).
//!
@@ -55,7 +55,9 @@ pub fn arrow_size(w: f64) -> f64 {
6.0 + 3.0 * w.max(0.0)
}
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1).
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2
/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so
/// squares, circles and text boxes match them.
pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
@@ -180,6 +182,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2.
/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0,
/// which draws the border plain.
pub(crate) fn cloud_intensity(d: &Dict) -> Option<f64> {
let be = d.get(b"BE").and_then(|b| b.as_dict())?;
if be.name(b"S") != Some(b"C") {
return None;
}
let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0);
(i.is_finite() && i > 0.0).then(|| i.min(2.0))
}
/// The bump radius for a cloudy border of width `w` at `intensity` (0–2].
pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 {
cloud_radius(w) * intensity.clamp(0.5, 2.0)
}
/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter`
/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three
/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget.
pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 {
1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64))
}
/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180).
fn rd_of(d: &Dict) -> Option<Vec<f64>> {
nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0))
}
/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences.
fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] {
let at = |i: usize| rd.get(i).copied().unwrap_or(0.0);
[rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)]
}
/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table
/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or
/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it
/// leaves less than a point either way, so the caller draws in `/Rect` as without one.
fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> {
let inner = less_rd(rect, &rd_of(d)?);
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD`
/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and
/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half
/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin.
fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> {
// The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is
// followed, one leaving less is not.
let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1]));
let m = cloud_reach(perimeter, bump) + w / 2.0;
let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m];
let inner = match rd_of(d) {
Some(rd) => {
let r = less_rd(rect, &rd);
[r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])]
}
None => safe,
};
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump`
/// (each edge of the outline becomes one bump), for a cloudy circle.
fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> {
let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0);
// Ramanujan's approximation of the perimeter; the count stays within the bump budget.
let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt());
let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32;
(0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect()
}
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
/// half-circle bumps of about `r`, bulging outwards.
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
@@ -536,7 +612,8 @@ struct AppearanceContext<'a> {
dict: &'a Dict,
subtype: &'a [u8],
rect: [f64; 4],
cloudy: bool,
/// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one.
cloud: Option<f64>,
/// The line's opacity (`parts.line`).
opacity: f64,
parts: PartOpacity,
@@ -599,11 +676,13 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>
return None;
}
let subtype = d.name(b"Subtype")?;
let rect = normalised_rect(d)?;
// A number too long for a double reads as infinite, and two near its limit are an infinite
// width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195).
let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?;
let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect);
// Cloudy borders (§12.5.4) are drawn for polygons only.
let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
if cloudy && subtype != b"Polygon" {
// Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes.
let cloud = cloud_intensity(d);
if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") {
return None;
}
Some(AppearanceContext {
@@ -611,7 +690,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>
dict: d,
subtype,
rect,
cloudy,
cloud,
opacity: part_opacity(d).line,
parts: part_opacity(d),
stroke: color(d, b"C")?,
@@ -698,14 +777,18 @@ fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec<f64>, f64)> {
if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS {
return None;
}
let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C"));
// A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`).
let reach = match subtype {
b"Polygon" if cloudy => 2.0 * cloud_radius(w),
b"PolyLine" if has_ending() => arrow_size(w),
let vertices = nums(d, b"Vertices")?;
// A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says.
let reach = match (subtype, cloud_intensity(d)) {
(b"Polygon", Some(intensity)) => {
let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity))
}
(b"PolyLine", _) if has_ending() => arrow_size(w),
_ => 0.0,
};
(nums(d, b"Vertices")?, w / 2.0 + reach)
(vertices, w / 2.0 + reach)
}
b"Line" => {
let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?;
@@ -921,15 +1004,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) ->
if stroke.is_none() && fill.is_none() {
return Some(());
}
// A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred
// half the width inside that box.
let shape = rect_less_rd(d, rect).unwrap_or(rect);
let inset = w / 2.0;
let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset];
let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset];
if let Some(f) = fill {
c.push_str(&rg(f));
}
if let Some(s) = stroke {
c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d)));
}
if subtype == b"Square" {
if let Some(intensity) = context.cloud {
let bump = cloud_bump(w, intensity);
let inner = cloud_inner(d, rect, bump, w)?;
let pts = if subtype == b"Square" {
vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]
} else {
ellipse_points(inner, bump)
};
c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump)));
} else if subtype == b"Square" {
c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0)));
} else {
c.push_str(&ellipse(x0, y0, x1, y1));
@@ -990,7 +1085,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
let subtype = context.subtype;
let stroke = context.stroke;
let w = context.width;
let cloudy = context.cloudy;
let c = &mut drawing.content;
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
@@ -1018,9 +1112,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
if let Some(s) = col {
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d)));
}
if cloudy {
let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0);
c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0)));
if let Some(intensity) = context.cloud {
c.push_str(&cloud_path(&pts, cloud_bump(w, intensity)));
} else {
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
@@ -1332,7 +1425,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
// `/RD` and keeps its place: background and border take the shape, the text is laid out
// in the text box as in a rectangle.
let shape = crate::BorderShape::of(d);
if shape != crate::BorderShape::Rectangle {
// A cloudy box (not a callout): its background and border follow the cloud around
// the text, which keeps to the cloud's inner rectangle.
if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) {
let cw = if bw > 0.0 { bw } else { 1.0 };
let bump = cloud_bump(cw, intensity);
let inner = cloud_inner(d, rect, bump, cw)?;
let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])];
c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d)));
c.push_str(&cloud_path(&pts, bump));
c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" });
rect = inner;
} else if context.cloud.is_some() {
return None;
} else if shape != crate::BorderShape::Rectangle {
rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?;
} else {
if let Some(bg) = bg {
+15 -6
View File
@@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
}
grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
}
// A new cloud has intensity 1 (`/BE /I 1`).
Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?,
Shape::PolyLine { vertices, start, end } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
@@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
Ok(r)
}
/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a
/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`).
pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option<f64>) -> Result<[f64; 4], AnnotError> {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?;
let reach = cloud.map_or(0.0, |intensity| {
let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum();
appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity))
});
Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach))
}
pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
+7 -1
View File
@@ -154,7 +154,13 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
grouped: d.name(b"RT") == Some(b"Group"),
rect,
color,
state: text_value(doc, d, b"State"),
// A reply that gives only `/StateModel` has the model's default state (ISO 32000-2
// Table 175), as `last_state_reply` and the inspector read it.
state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() {
Some("Marked") => Some("Unmarked".into()),
Some("Review") => Some("None".into()),
_ => None,
}),
quads,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
+47 -3
View File
@@ -503,10 +503,24 @@ fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> {
Some((end, format!("mailto:{address}")))
}
/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly
/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets
/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it,
/// not a pass over the rest of the page each.
const MAX_URL_CHARS: usize = 2048;
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
///
/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with
/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it:
/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it
/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Characters found inside an address but not between words: a `www.` after one is part of
// a longer address, and so is an e-mail address after a slash.
let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.contains(*c));
// Case-insensitive prefix test that never steps past the end. Comparing against the original
// characters keeps every offset in `chars`'s own index space: case-folding a copy can change
// its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply
@@ -517,11 +531,38 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let mut i = 0;
while i < chars.len() {
let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric());
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) {
let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#");
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) {
let mut j = i;
while chars.get(j).is_some_and(|c| url_char(*c)) {
// Brackets pair up inside the address: `depth` are open, the outermost at `opened`.
let (mut depth, mut opened) = (0usize, i);
let mut too_long = false;
loop {
if j.saturating_sub(i) >= MAX_URL_CHARS {
too_long = true;
break;
}
match chars.get(j) {
Some('(') => {
if depth == 0 {
opened = j;
}
depth = depth.saturating_add(1);
}
Some(')') if depth > 0 => depth -= 1,
Some(c) if url_char(*c) => {}
_ => break,
}
j += 1;
}
// A bracket left open isn't the address's: it ends before it. A run of address
// characters longer than any address is none, and the search goes on after it.
if depth > 0 {
j = opened;
} else if too_long {
i = j;
continue;
}
// Trailing punctuation belongs to the sentence.
while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) {
j -= 1;
@@ -534,7 +575,10 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
continue;
}
}
if boundary && let Some((end, uri)) = email_at(chars, i) {
if boundary
&& !inside_address(i, "/\\")
&& let Some((end, uri)) = email_at(chars, i)
{
out.push((i..end, uri));
i = end;
continue;
+73 -16
View File
@@ -1,5 +1,6 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect,
rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
@@ -260,8 +261,9 @@ pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, auth
reply(doc, page, index, text, author, meta, None)
}
/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a
/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins.
/// Set a comment's review status ("Set status ▸ Accepted"…). This adds a hidden state reply
/// (`/State`, `/StateModel /Review`) by `author`, in reply to `author`'s previous one when there is
/// one; each reviewer's latest is their status.
pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author });
reply(doc, page, index, &text, author, meta, Some(state))
@@ -294,6 +296,11 @@ fn reply(
d.set(b"State".to_vec(), PdfString::text(s.name()));
d.set(b"StateModel".to_vec(), PdfString::text("Review"));
d.set(b"Subj".to_vec(), PdfString::text("Status"));
// Hidden (Table 167, bit 2): the state "is not specified in the annotation itself but in a
// separate text annotation" (ISO 32000-2 §12.5.6.3), so this reply is a state record, not
// a note, and is neither drawn nor listed as one.
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Review").unwrap_or(parent)));
}
// An empty form: nothing is drawn for the reply itself.
let mut fd = Dict::new();
@@ -330,8 +337,9 @@ pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -
Ok(())
}
/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with
/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status.
/// Mark or unmark a comment with a checkmark. This adds a hidden state reply with `/StateModel
/// /Marked` by `author`, in reply to `author`'s previous one when there is one; their latest one
/// wins. It's each reviewer's private bookkeeping, not a status.
pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let (p, parent) = annot_ref(doc, page, index)?;
let pd = annot_dict(doc, parent);
@@ -342,7 +350,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author });
let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta);
d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4])));
d.set(b"IRT".to_vec(), Object::Ref(parent));
d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Marked").unwrap_or(parent)));
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"State".to_vec(), PdfString::text(state));
d.set(b"StateModel".to_vec(), PdfString::text("Marked"));
@@ -354,6 +362,56 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
Ok(i)
}
/// `author`'s latest `model` state reply about `target` on page `p`, for the next change to reply
/// to: ISO 32000-2 §12.5.6.3, "Additional state changes shall be made by adding text annotations
/// in reply to the previous reply for a given user". A state reply is about the
/// annotation its `/IRT` chain reaches past other state replies; a loop reaches nothing. Each
/// reply's answer is kept, so a long history is walked once.
fn last_state_reply(doc: &Document, p: ObjRef, target: ObjRef, author: &str, model: &str) -> Option<ObjRef> {
use std::collections::{HashMap, HashSet};
// As the readers see it: a readable `/State` (not null, empty or another type), or a known
// `/StateModel` whose default state applies.
let is_state = |r: ObjRef| {
doc.get(r).as_dict().is_some_and(|d| {
text_value(doc, d, b"State").is_some() || matches!(text_value(doc, d, b"StateModel").as_deref(), Some("Marked" | "Review"))
})
};
let irt = |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"IRT").and_then(Object::as_ref));
let mut reaches: HashMap<ObjRef, Option<ObjRef>> = HashMap::new();
let mut mine = Vec::new();
for r in annots(doc, p).iter().filter_map(Object::as_ref).filter(|&r| r != target) {
let d = annot_dict(doc, r);
if text_value(doc, &d, b"StateModel").as_deref() != Some(model) || text_value(doc, &d, b"T").unwrap_or_default() != author.trim() {
continue;
}
let (mut path, mut seen, mut at) = (vec![r], HashSet::from([r]), irt(r));
let about = loop {
let Some(up) = at else { break None };
if let Some(&known) = reaches.get(&up) {
break known;
}
if !is_state(up) {
break Some(up);
}
if !seen.insert(up) {
break None;
}
path.push(up);
at = irt(up);
};
for x in path {
reaches.insert(x, about);
}
if about == Some(target) {
mine.push(r);
}
}
// The end of the chain, whatever order the page lists it in: a reply none of the others
// replies to; among several (changes that don't chain), the last listed.
let answered: HashSet<ObjRef> = mine.iter().filter_map(|&r| irt(r)).collect();
mine.iter().rev().copied().find(|r| !answered.contains(r))
}
/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`.
pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> {
if !finite(&[dx, dy]) {
@@ -568,22 +626,21 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
let style = Style { width: appearance::border_width(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
// Each checks the points too: finite, and far enough apart to draw.
let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"),
// Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity
// (Table 169), which may reach further than a new cloud's intensity 1.
("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"),
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
+518 -3
View File
@@ -213,6 +213,122 @@ fn replies_and_status_thread_onto_the_parent() {
assert_eq!(text(&all[2], b"Contents"), "Accepted set by Bob");
}
/// A state reply records a state, not a note (ISO 32000-2 §12.5.6.3: the state is kept "in a
/// separate text annotation"), so it is written Hidden; a reviewer's next change replies to their
/// previous state reply ("Additional state changes shall be made by adding text annotations in
/// reply to the previous reply for a given user"). Another reviewer's state, and another state
/// model, start their own chain on the comment.
#[test]
fn status_and_checkmark_replies_are_hidden_and_chain_per_reviewer() {
let mut doc = fixture();
let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap();
let carol = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Carol", &meta("s2")).unwrap();
let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap();
let mark = set_marked(&mut doc, 0, 0, true, "Bob", &meta("m1")).unwrap();
let unmark = set_marked(&mut doc, 0, 0, false, "Bob", &meta("m2")).unwrap();
let doc = reopen(&doc);
let p = page_refs(&doc).unwrap()[0];
let entries = annots(&doc, p);
let at = |i: usize| entries[i].as_ref().unwrap();
let all = list(&doc, 0);
for i in [first, carol, second, mark, unmark] {
assert_eq!(all[i].int(b"F"), Some(30), "hidden, printable, no zoom, no rotate: {i}");
}
assert_eq!(all[first].reference(b"IRT"), Some(at(0)));
assert_eq!(all[carol].reference(b"IRT"), Some(at(0)), "another reviewer starts on the comment");
assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "Bob's change replies to Bob's last status");
assert_eq!(all[mark].reference(b"IRT"), Some(at(0)), "a checkmark is its own chain");
assert_eq!(all[unmark].reference(b"IRT"), Some(at(mark)));
// A reply that isn't a state stays visible.
let mut doc = doc;
let r = add_reply(&mut doc, 0, 0, "Agreed", "Bob", &meta("r")).unwrap();
assert_eq!(list(&doc, 0)[r].int(b"F"), Some(28));
}
/// Review on #4126: a history has no length limit. The 70th change still replies to the 69th,
/// and a reviewer whose name was typed with spaces continues their own chain.
#[test]
fn a_long_status_history_keeps_chaining_to_the_latest() {
let mut doc = fixture();
let mut prev: Option<usize> = None;
for i in 0..70 {
let state = if i % 2 == 0 { ReviewState::Accepted } else { ReviewState::Rejected };
let at = set_review_state(&mut doc, 0, 0, state, "Bob", &meta(&format!("s{i}"))).unwrap();
if let Some(p) = prev {
let entries = annots(&doc, page_refs(&doc).unwrap()[0]);
assert_eq!(list(&doc, 0)[at].reference(b"IRT"), entries[p].as_ref(), "change {i}");
}
prev = Some(at);
}
let padded = set_review_state(&mut doc, 0, 0, ReviewState::Completed, " Bob ", &meta("pad")).unwrap();
let entries = annots(&doc, page_refs(&doc).unwrap()[0]);
assert_eq!(list(&doc, 0)[padded].reference(b"IRT"), prev.and_then(|p| entries[p].as_ref()));
// Listed newest first, the first reply read walks the whole chain at once.
let latest = entries[padded].as_ref();
let p = page_refs(&doc).unwrap()[0];
let mut reversed = entries.clone();
reversed.reverse();
set_annots(&mut doc, p, reversed).unwrap();
let next = set_review_state(&mut doc, 0, 71, ReviewState::Accepted, "Bob", &meta("next")).unwrap();
assert_eq!(list(&doc, 0)[next].reference(b"IRT"), latest);
}
/// Review on #4126: a reply with `/StateModel` but no `/State` has the model's default state
/// (ISO 32000-2 Table 175), for the reader as for the writer: it is Bob's latest status, None,
/// and his next change replies to it.
#[test]
fn a_state_model_without_a_state_is_the_default_state() {
let mut doc = fixture();
let bare = add_reply(&mut doc, 0, 0, "", "Bob", &meta("bare")).unwrap();
let p = page_refs(&doc).unwrap()[0];
let r = annots(&doc, p)[bare].as_ref().unwrap();
doc.update_dict(r, |d| {
d.set(b"StateModel".to_vec(), PdfString::text("Review"));
d.set(b"F".to_vec(), Object::Int(30));
})
.unwrap();
let summary = summaries(&doc).into_iter().find(|s| s.index == bare).unwrap();
assert_eq!(summary.state.as_deref(), Some("None"));
let next = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("next")).unwrap();
assert_eq!(list(&doc, 0)[next].reference(b"IRT"), Some(r));
// A model PdfCraft doesn't know isn't a state: a status replying to such a reply is about
// that reply, not about the comment, so Bob's next change on the comment starts afresh.
let mut doc = fixture();
let other = add_reply(&mut doc, 0, 0, "", "Ann", &meta("other")).unwrap();
let p = page_refs(&doc).unwrap()[0];
let o = annots(&doc, p)[other].as_ref().unwrap();
// Nor is a `/State` the readers can't read, such as a number (review on #4126).
doc.update_dict(o, |d| {
d.set(b"StateModel".to_vec(), PdfString::text("Other"));
d.set(b"State".to_vec(), Object::Int(5));
})
.unwrap();
assert_eq!(summaries(&doc).into_iter().find(|s| s.index == other).unwrap().state, None);
set_review_state(&mut doc, 0, other, ReviewState::Rejected, "Bob", &meta("on-other")).unwrap();
let fresh = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("fresh")).unwrap();
assert_eq!(list(&doc, 0)[fresh].reference(b"IRT"), annots(&doc, p)[0].as_ref());
}
/// A third change replies to the second, not the first; and Bob's status on another comment
/// is never the one his next change on this comment replies to.
#[test]
fn a_reviewers_chain_continues_from_their_latest_status_on_that_comment() {
let mut doc = fixture();
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap();
let elsewhere = set_review_state(&mut doc, 0, note, ReviewState::Cancelled, "Bob", &meta("s2")).unwrap();
let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap();
let third = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("s4")).unwrap();
let doc = reopen(&doc);
let entries = annots(&doc, page_refs(&doc).unwrap()[0]);
let at = |i: usize| entries[i].as_ref().unwrap();
let all = list(&doc, 0);
assert_eq!(all[elsewhere].reference(b"IRT"), Some(at(note)));
assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "not Bob's status on the note");
assert_eq!(all[third].reference(b"IRT"), Some(at(second)), "the latest, not the first");
}
#[test]
fn delete_takes_popup_and_reply_chain() {
let mut doc = fixture();
@@ -1314,6 +1430,44 @@ fn urls_are_found_after_length_changing_lowercase() {
assert_eq!(range, 21..39, "range is in source-character indices");
}
/// Review on #4472: an address inside one written with another scheme isn't taken out of it
/// (`ftp://www.example.com` opened as `http://www.example.com`), and brackets that pair up
/// inside an address belong to it.
#[test]
fn urls_keep_their_written_scheme_and_paired_brackets() {
let found = |s: &str| -> Vec<String> { crate::links::find_urls(&s.chars().collect::<Vec<_>>()).into_iter().map(|(_, u)| u).collect() };
for s in
["ftp://www.example.com/pub", "file://www.example.com/x", "javascript:www.example.com", "ftp://user@example.com/x", "smb://a@example.org/s"]
{
assert_eq!(found(s), Vec::<String>::new(), "{s}");
}
assert_eq!(found("mailto:someone@example.com and (www.example.com)"), ["mailto:someone@example.com", "http://www.example.com"]);
assert_eq!(found("See https://en.wikipedia.org/wiki/Mercury_(planet)."), ["https://en.wikipedia.org/wiki/Mercury_(planet)"]);
assert_eq!(found("(https://example.com/a_(b)_c)"), ["https://example.com/a_(b)_c"]);
assert_eq!(found("https://example.com/a(b c"), ["https://example.com/a"]);
// Deeply nested or unbalanced brackets end the address without trouble.
let deep = format!("https://example.com/{}x{}", "(".repeat(10_000), ")".repeat(9_999));
assert_eq!(found(&deep), ["https://example.com/"]);
}
/// Review on #4472: brackets that never close in text without spaces (`https://a/(` again and
/// again) made each address in it read the rest of the text, then start again one character on,
/// a pass over the page per address. Each is read 2,048 characters at most now, and the
/// addresses around them are still found. A run of address characters longer than that is no
/// address.
#[test]
fn unclosed_brackets_in_text_without_spaces_are_read_once_per_address() {
let found = |s: &str| -> Vec<String> { crate::links::find_urls(&s.chars().collect::<Vec<_>>()).into_iter().map(|(_, u)| u).collect() };
let text = format!("www.before.org {} www.after.org", "https://a/(".repeat(60_000));
let started = std::time::Instant::now();
assert_eq!(found(&text), ["http://www.before.org", "http://www.after.org"]);
assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed());
let long = format!("https://example.com/{} www.after.org", "a".repeat(3000));
assert_eq!(found(&long), ["http://www.after.org"]);
let fits = format!("https://example.com/{}", "a".repeat(2000));
assert_eq!(found(&fits), std::slice::from_ref(&fits));
}
#[test]
fn url_scheme_and_www_match_case_insensitively() {
let t: Vec<char> = "HTTP://Example.COM".chars().collect();
@@ -3012,6 +3166,366 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
assert!(!long.contains(&"w".repeat(257)));
}
/// A comment's built appearance for the dictionary `body` (no `/AP`), as text, or `None` when
/// PdfCraft can't draw it.
fn built(body: &str) -> Option<String> {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
body.as_bytes(),
]);
let d = list(&doc, 0).into_iter().next().unwrap();
crate::appearance::build(&doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
}
/// The x and y range a drawing's path covers: its points, and each curve's middle (a curve's
/// control points lie outside it, so they aren't counted).
fn extent(ops: &str) -> [f64; 4] {
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
let mut at = (0.0, 0.0);
let add = |p: (f64, f64), b: &mut [f64; 4]| *b = [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)];
for line in ops.lines() {
let t: Vec<&str> = line.split_whitespace().collect();
let Some((op, nums)) = t.split_last() else { continue };
let v: Vec<f64> = nums.iter().filter_map(|n| n.parse().ok()).collect();
match (*op, v.as_slice()) {
("m" | "l", [x, y]) => {
at = (*x, *y);
add(at, &mut b);
}
("c", [x1, y1, x2, y2, x3, y3]) => {
let mid = ((at.0 + 3.0 * x1 + 3.0 * x2 + x3) / 8.0, (at.1 + 3.0 * y1 + 3.0 * y2 + y3) / 8.0);
add(mid, &mut b);
at = (*x3, *y3);
add(at, &mut b);
}
_ => {}
}
}
b
}
/// ISO 32000-2 §12.5.4, Table 169: a cloudy border (`/BE /S /C`) applies to squares, circles,
/// polygons and (PDF 1.6) text boxes, honours the `/BS` width and dashes, and its intensity
/// `/I` defaults to 0. Squares, circles and text boxes without an appearance were not drawn at
/// all, so a reviewer's marks vanished; intensity 0 (or none) drew a cloud on polygons.
#[test]
fn cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes() {
let base = "/Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 >>";
for kind in ["Square", "Circle"] {
let ops = built(&format!("<< {base} /Subtype /{kind} /BE << /S /C /I 1 >> >>")).unwrap_or_else(|| panic!("{kind} is drawn"));
assert!(ops.matches(" c\n").count() > 8 && !ops.contains(" re\n"), "{kind} as bumps: {ops}");
let e = extent(&ops);
assert!(e[0] >= 100.0 && e[1] >= 100.0 && e[2] <= 300.0 && e[3] <= 250.0, "{kind} stays in /Rect: {e:?}");
}
let dashed = built(&format!("<< {base} /Subtype /Square /BS << /W 2 /S /D /D [6 3] >> /BE << /S /C /I 1 >> >>")).unwrap();
assert!(dashed.contains("[6 3] 0 d") && dashed.contains(" c\n"), "dashes honoured: {dashed}");
let text = built(&format!("<< {base} /Subtype /FreeText /DA (0 0 1 rg /Helv 10 Tf) /Contents (Look here) /BE << /S /C /I 1 >> >>")).unwrap();
assert!(text.contains(" c\n") && text.contains("(Look here) Tj"), "a cloud around the text: {text}");
let dashed_text = built(
"<< /Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 /S /D /D [4 2] >> /Subtype /FreeText /DA (0 g /Helv 10 Tf) /Contents (x) /BE << /S /C /I 1 >> >>",
)
.unwrap();
assert!(dashed_text.contains("[4 2] 0 d"), "a text box's cloud is dashed too: {dashed_text}");
// Intensity 0, or none: a plain border (the default is 0).
for be in ["<< /S /C /I 0 >>", "<< /S /C >>", "<< /S /S /I 2 >>"] {
let square = built(&format!("<< {base} /Subtype /Square /BE {be} >>")).unwrap();
assert!(square.contains(" re\n") && !square.contains(" c\n"), "{be}: {square}");
let polygon =
built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE {be} >>"))
.unwrap();
assert!(!polygon.contains(" c\n"), "{be}: polygon edges stay straight: {polygon}");
}
let cloudy_polygon =
built("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE << /S /C /I 1 >> >>")
.unwrap();
assert!(cloudy_polygon.contains(" c\n"));
// A cloudy callout is still left to its own appearance.
assert!(
built(&format!("<< {base} /Subtype /FreeText /DA (0 g /Helv 10 Tf) /CL [10 10 100 100] /RD [5 5 5 5] /BE << /S /C /I 1 >> >>")).is_none()
);
}
/// Table 180: `/RD` gives the differences between "the left, top, right, and bottom coordinates
/// of Rect and those of the square or circle, respectively"; a cloudy square's bumps sit on that
/// inner square.
#[test]
fn a_cloudy_square_follows_rd_left_top_right_bottom() {
let ops = built("<< /Type /Annot /Subtype /Square /Rect [100 100 300 300] /C [1 0 0] /BS << /W 1 >> /BE << /S /C /I 1 >> /RD [20 40 30 25] >>")
.unwrap();
// The corners of the inner square are path points: left 120, bottom 125, right 270, top 260.
for corner in ["120 125", "270 125", "270 260", "120 260"] {
assert!(ops.contains(corner), "corner {corner} in {ops}");
}
}
/// Review on #4195: without `/RD`, a small cloudy square still keeps its bumps inside `/Rect`.
/// Its edges are short enough that each rounds to a single bump about three radii long, which
/// bulges further than one radius. An `/RD` leaving less than that margin is widened to it, so
/// the cloud stays inside `/Rect` too.
#[test]
fn a_small_cloudy_square_without_rd_stays_inside_its_rect() {
for side in [40.0, 48.8, 60.0, 82.0] {
let ops = built(&format!(
"<< /Type /Annot /Subtype /Square /Rect [100 100 {x} {x}] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> >>",
x = 100.0 + side
))
.unwrap_or_else(|| panic!("side {side} is drawn"));
let e = extent(&ops);
assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 101.0 + side && e[3] <= 101.0 + side, "side {side}: {e:?}");
}
let small_rd =
built("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> /RD [1 1 1 1] >>").unwrap();
let e = extent(&small_rd);
assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 201.0 && e[3] <= 201.0, "/RD [1 1 1 1] keeps the cloud inside /Rect: {e:?}");
}
/// ISO 32000-2 Table 180: `/RD` gives the differences between `/Rect` and "the actual boundaries
/// of the underlying square or circle", so a plain square or circle (no cloudy border) is drawn
/// inside `/Rect` less `/RD` (left, top, right, bottom) too, its stroke centred half the width
/// inside that box. An `/RD` that isn't four finite non-negative numbers, or leaves no box, is
/// ignored and the shape fills `/Rect` as before.
#[test]
fn plain_squares_and_circles_are_drawn_inside_rect_less_rd() {
let square = |rd: &str| built(&format!("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> {rd} >>")).unwrap();
// Rect + 15 + 1.5 on every side: a 67 × 67 square from 116.5.
assert!(square("/RD [15 15 15 15]").contains("116.5 116.5 67 67 re"), "{}", square("/RD [15 15 15 15]"));
// Left 10, top 40, right 20, bottom 30: the box is 110..180 × 130..160.
assert!(square("/RD [10 40 20 30]").contains("111.5 131.5 67 27 re"), "{}", square("/RD [10 40 20 30]"));
for ignored in ["", "/RD [-1 0 0 0]", "/RD [1 2 3]", "/RD [60 0 60 0]", "/RD [0 50 0 49.5]", "/RD (x)"] {
assert!(square(ignored).contains("101.5 101.5 97 97 re"), "{ignored}: {}", square(ignored));
}
let circle = built("<< /Type /Annot /Subtype /Circle /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> /RD [15 15 15 15] >>").unwrap();
// The ellipse starts at its rightmost point: 200 - 15 - 1.5, at mid-height.
assert!(circle.starts_with("1 0 0 RG\n3 w\n183.5 150 m"), "{circle}");
}
/// The circles and rectangles PdfCraft draws write no `/RD`, so their appearance is unchanged by
/// it: the stroke still sits half its width inside `/Rect`.
#[test]
fn pdfcraft_rectangles_and_ellipses_still_fill_their_rect() {
let mut doc = fixture();
let rect = [10.0, 10.0, 110.0, 60.0];
for shape in [Shape::Rectangle { rect }, Shape::Oval { rect }] {
let oval = matches!(shape, Shape::Oval { .. });
let i = add_annotation(&mut doc, &new(0, shape), &meta("r")).unwrap();
let d = list(&doc, 0).remove(i);
assert!(!d.contains(b"RD"));
let w = appearance::border_width(&d);
let ap = ap_content(&doc, &d);
let r: Vec<f64> = d.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect();
let expected = if oval {
format!("{} {} m", n(r[2] - w / 2.0), n((r[1] + r[3]) / 2.0))
} else {
format!("{} {} {} {} re", n(r[0] + w / 2.0), n(r[1] + w / 2.0), n(r[2] - r[0] - w), n(r[3] - r[1] - w))
};
assert!(ap.contains(&expected), "{expected} in {ap}");
}
}
/// Review on #4195: a polygon reshaped with `/BE /S /C` and no `/I` (or `/I 0`) is a plain one,
/// as its appearance is drawn (Table 169: intensity 0 is no cloud), so its `/Rect` gets no room
/// for bumps; with an intensity above 0 it does.
#[test]
fn a_reshaped_polygon_pads_its_rect_only_for_a_drawn_cloud() {
// `/BE << /S /C >>` with `/I` when given; no `/BE` at all for `None`.
let reshaped = |be: Option<Option<i64>>| {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: false };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
if let Some(intensity) = be {
let mut b = pdfcraft_cos::Dict::new();
b.set(b"S".to_vec(), Object::name("C"));
if let Some(i) = intensity {
b.set(b"I".to_vec(), Object::Int(i));
}
doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(b))).unwrap();
}
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
rect(&list(&doc, 0)[i])
};
let plain = reshaped(None);
for be in [None, Some(0)] {
assert_eq!(reshaped(Some(be)), plain, "/I {be:?}: no cloud drawn, no room for one");
}
let cloudy = reshaped(Some(Some(1)));
assert!(cloudy[0] < plain[0] && cloudy[2] > plain[2], "room for the bumps: {cloudy:?} around {plain:?}");
}
/// Review on #4195: a reshaped polygon whose cloud has intensity 2 gets room for its bumps at
/// that intensity. With room only for intensity 1, the bumps on a 200 pt edge reached past
/// `/Rect` and the appearance's `/BBox` clipped them.
#[test]
fn a_reshaped_intensity_two_cloud_stays_inside_its_rect() {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
let mut be = pdfcraft_cos::Dict::new();
be.set(b"S".to_vec(), Object::name("C"));
be.set(b"I".to_vec(), Object::Int(2));
doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(be))).unwrap();
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
let d = &list(&doc, 0)[i];
let bbox = rect(d);
// Every point along the drawn bumps, with half the 2 pt line, is inside `/Rect`.
let content = ap_content(&doc, d);
let (mut at, mut lowest, mut leftmost) = ((0.0, 0.0), f64::MAX, f64::MAX);
for line in content.lines() {
let v: Vec<f64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
if line.ends_with(" m") && v.len() == 2 {
at = (v[0], v[1]);
} else if line.ends_with(" c") && v.len() == 6 {
for step in 0..=20 {
let t = f64::from(step) / 20.0;
let u = 1.0 - t;
let (a, b, c, e) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
lowest = lowest.min(a * at.1 + b * v[1] + c * v[3] + e * v[5]);
leftmost = leftmost.min(a * at.0 + b * v[0] + c * v[2] + e * v[4]);
}
at = (v[4], v[5]);
}
}
assert!(lowest < 100.0 - 8.0, "the bumps bulge below the bottom edge: {lowest}");
assert!(lowest - 1.0 >= bbox[1], "the bottom bumps ({lowest}) and the line fit in /Rect {bbox:?}");
assert!(leftmost - 1.0 >= bbox[0], "the left bumps ({leftmost}) and the line fit in /Rect {bbox:?}");
}
/// Review on #4195: a reshaped polygon gets room for the border it is drawn with. With no `/BS`,
/// a `/Border [0 0 5]` is drawn 5 pt wide (`appearance::border_width`), so the new `/Rect` is the
/// one `/BS /W 5` gets; it was sized for a 1-pt border, and the cloud's outer bumps were clipped.
#[test]
fn a_reshaped_polygon_has_room_for_the_border_it_is_drawn_with() {
let reshaped = |border: &[u8], width: Object| {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
doc.update_dict(r, |d| {
d.remove(b"BS");
if border == b"BS" {
let mut bs = pdfcraft_cos::Dict::new();
bs.set(b"W".to_vec(), width);
d.set(b"BS".to_vec(), Object::Dict(bs));
} else {
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), width]));
}
})
.unwrap();
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
rect(&list(&doc, 0)[i])
};
let bs = reshaped(b"BS", Object::Int(5));
assert_eq!(reshaped(b"Border", Object::Int(5)), bs, "the same 5-pt border, the same room");
assert_ne!(reshaped(b"BS", Object::Int(1)), bs, "a 1-pt border needs less");
}
/// Every number a drawing writes, which must all be finite.
fn drawn_numbers(ops: &str) -> Vec<f64> {
ops.split_whitespace().filter(|t| t.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '.')).filter_map(|t| t.parse().ok()).collect()
}
/// Review on #4195: a cloud around a zero-size, inverted, sliver or huge `/Rect`, at any border
/// width and any `/BE /I` (tiny, negative, far above Table 169's 2), is either drawn inside
/// `/Rect` with finite numbers and a bounded number of bumps, or not built at all, never a
/// panic, a division by zero or a NaN.
#[test]
fn hostile_cloud_sizes_widths_and_intensities_draw_inside_rect_or_not_at_all() {
let rects = [
"0 0 0 0",
"100 100 100 100",
"100 100 100.5 300",
"100 100 101 101",
"300 250 100 100",
"100 100 300 250",
"100 100 110 110",
"-1000000000000000 -1000000000000000 1000000000000000 1000000000000000",
"0 0 99999999999999999999999 1",
&format!("0 0 {} 100", "9".repeat(400)),
&format!("-{n} -{n} {n} {n}", n = "9".repeat(308)),
];
let (mut drawn, mut refused) = (0, 0);
for rect in rects {
let r: Vec<f64> = rect.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let bounds = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
for width in ["0", "0.001", "2", "1000000", "-3"] {
for intensity in ["0.0000001", "2", "1000000000000000000000000000000", "-1"] {
for kind in ["Square", "Circle", "FreeText"] {
let body = format!(
"<< /Type /Annot /Subtype /{kind} /Rect [{rect}] /C [1 0 0] /BS << /W {width} >> /BE << /S /C /I {intensity} >> /DA (0 g /Helv 10 Tf) /Contents (x) >>"
);
let Some(ops) = built(&body) else {
refused += 1;
continue;
};
drawn += 1;
assert!(!ops.contains("NaN") && !ops.contains("inf"), "{body}: {ops}");
assert!(drawn_numbers(&ops).iter().all(|v| v.is_finite()), "{body}");
assert!(ops.matches(" c\n").count() <= crate::appearance::MAX_CLOUD_BUMPS + 8, "{body}: bumps are capped");
// A cloud (intensity above 0); a negative one is a plain border (Table 169).
if !intensity.starts_with('-') && ops.contains(" c\n") {
let e = extent(&ops);
let slack = 0.5 + 1e-9 * (bounds[2] - bounds[0]).abs().max((bounds[3] - bounds[1]).abs());
assert!(
e[0] >= bounds[0] - slack && e[1] >= bounds[1] - slack && e[2] <= bounds[2] + slack && e[3] <= bounds[3] + slack,
"{body}: the cloud {e:?} stays in /Rect"
);
}
}
}
}
}
assert!(drawn > 0 && refused > 0, "both outcomes are exercised: {drawn} drawn, {refused} refused");
}
/// Review on #4195: a cloud around a rotated rectangle (a polygon turned 45°, its corners in
/// either order) bulges outwards the same way and stays inside its `/Rect`, and `cloud_path`
/// itself takes a zero, negative, infinite or NaN bump size and repeated corners without
/// dividing by zero.
#[test]
fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() {
let ccw = "150 100 200 150 150 200 100 150";
let cw = "150 100 100 150 150 200 200 150";
let extents: Vec<[f64; 4]> = [ccw, cw]
.iter()
.map(|v| {
let ops = built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [80 80 220 220] /C [1 0 0] /Vertices [{v}] /BE << /S /C /I 1 >> >>"))
.unwrap();
assert!(ops.contains(" c\n"), "{ops}");
let e = extent(&ops);
assert!(e[0] >= 80.0 && e[1] >= 80.0 && e[2] <= 220.0 && e[3] <= 220.0, "inside /Rect: {e:?}");
// Each bump's middle lies outside the turned square |x − 150| + |y − 150| = 50.
let mut at = (0.0, 0.0);
for line in ops.lines() {
let v: Vec<f64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
if line.ends_with(" m") && v.len() == 2 {
at = (v[0], v[1]);
} else if line.ends_with(" c") && v.len() == 6 {
let mid = ((at.0 + 3.0 * v[0] + 3.0 * v[2] + v[4]) / 8.0, (at.1 + 3.0 * v[1] + 3.0 * v[3] + v[5]) / 8.0);
assert!((mid.0 - 150.0).abs() + (mid.1 - 150.0).abs() > 50.0 + 1.0, "the bump at {mid:?} bulges outwards");
at = (v[4], v[5]);
}
}
e
})
.collect();
for k in 0..4 {
assert!((extents[0][k] - extents[1][k]).abs() < 0.5, "the same cloud either way round: {extents:?}");
}
let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
for r in [0.0, -1.0, f64::INFINITY, f64::NAN, 1e-300, 1e300] {
let path = crate::appearance::cloud_path(&square, r);
assert!(!path.contains("NaN") && !path.contains("inf"), "r {r}: {path}");
assert!(path.ends_with("h\n"));
}
for pts in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0); 4], vec![(0.0, 0.0), (10.0, 0.0), (10.0, 0.0), (0.0, 0.0)]] {
let path = crate::appearance::cloud_path(&pts, 2.0);
assert!(!path.contains("NaN") && !path.contains("inf"), "{pts:?}: {path}");
}
}
#[test]
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
@@ -3503,15 +4017,16 @@ fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() {
fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() {
let (_, display) = displayed(&[
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>",
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>",
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C /I 1 >> /Vertices [100 100 200 100 150 180] >>",
]);
let shown = list(&display, 0);
let rect = |d: &Dict| nums_of(d, b"Rect");
assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices");
assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0]));
assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn");
// Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices.
assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border");
// Half the width (1) and the cloud's reach (`cloud_reach`: 1.5 bumps of 6 + 1.5 × 2 at
// intensity 1) past the vertices.
assert_eq!(rect(&shown[1]), [10.0, 10.0, 214.5, 194.5], "and its cloudy border");
}
/// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader
+53 -14
View File
@@ -213,25 +213,25 @@ const MAX_TAG_PAGE: usize = 1000;
impl Automation {
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagText)?;
let tags = self.accessibility_tags(a)?;
Ok(json!({
let out = json!({
"tags": tags["tags"], "count": tags["count"],
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
}))
});
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?;
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagFormFields)?;
let report = self
.doc(a)?
.accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None })
.ok_or_else(|| failed("the document can't be read"))?;
let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?;
Ok(
json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }),
)
let out = json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
@@ -432,10 +432,11 @@ impl Automation {
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
let edit = self.doc(a)?.accessibility_fix(rule, a.opt_str("value")?).map_err(failed)?;
let doc = self.doc(a)?.id;
self.session.apply(doc, edit).map_err(failed)?;
let warnings = self.apply_with_warnings(doc, edit)?;
let r =
self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?;
Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) }))
let out = json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_figures(&self, a: &Args) -> Result<Value> {
@@ -466,8 +467,8 @@ impl Automation {
pdfcraft_engine::Edit::SetAltText { figure, alt: a.opt_str("alt")?.map(str::to_owned) }
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.accessibility_figures(a)?, warnings))
}
/// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages
@@ -551,7 +552,22 @@ impl Automation {
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?;
let warnings =
self.apply_with_warnings(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
pub(crate) fn accessibility_layout_table_to_blocks(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
}
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let id = self.doc(a)?.id;
self.session
.apply(id, pdfcraft_engine::Edit::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) })
.map_err(failed)?;
self.accessibility_tags(a)
}
@@ -573,9 +589,32 @@ impl Automation {
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
/// Apply `edit` to document `id` and take out the errors it added to the document's script
/// output, such as the values of an XFA form that could not be written to its data.
fn apply_with_warnings(&mut self, id: DocId, edit: pdfcraft_engine::Edit) -> Result<Vec<String>> {
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(out.errors)
}
}
/// `out` with `warnings` added to its `warnings` list (made when it has none).
fn with_warnings(mut out: Value, warnings: Vec<String>) -> Value {
if warnings.is_empty() {
return out;
}
if let Some(map) = out.as_object_mut() {
match map.get_mut("warnings").and_then(Value::as_array_mut) {
Some(list) => list.extend(warnings.into_iter().map(Value::from)),
None => {
map.insert("warnings".into(), json!(warnings));
}
}
}
out
}
#[cfg(test)]
+5 -5
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
@@ -103,8 +103,8 @@ impl Automation {
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
self.pdfa_verify(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::ConvertPdfA { level })?;
Ok(with_warnings(self.pdfa_verify(a)?, warnings))
}
pub(crate) fn action_list(&self) -> Result<Value> {
@@ -209,7 +209,7 @@ impl Automation {
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
let (old, new) = self.compare_ids(a)?;
let n = self.session.mark_differences(old, new).map_err(failed)?;
Ok(json!({ "comments": n }))
let (n, out) = self.session.with_own_js_output(new, |s| s.mark_differences(old, new)).map_err(failed)?;
Ok(with_warnings(json!({ "comments": n }), out.errors))
}
}
+20 -11
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
@@ -28,10 +28,18 @@ impl Automation {
/// messages, console lines, requests, error, the failing document scripts' errors and result.
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
let (script, field) = (a.str("script")?, a.opt_str("field")?);
// Applying the script's field changes runs the field scripts again; their output is reported with the script's.
let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?;
o.alerts.extend(edit.alerts);
o.console.extend(edit.console);
o.requests.extend(edit.requests);
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
Ok(with_warnings(
json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
edit.errors,
))
}
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
@@ -42,8 +50,8 @@ impl Automation {
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.js_document_scripts(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
}
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
@@ -53,8 +61,7 @@ impl Automation {
script: a.opt_str("script")?.map(str::to_string),
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
let out = self.session.take_js_output(id);
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
}
@@ -90,10 +97,12 @@ impl Automation {
v
})
.collect();
let mut warnings = Vec::new();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
let (_, out) = self.session.with_own_js_output(id, |s| s.auto_detect_fields(id, &pages)).map_err(failed)?;
warnings = out.errors;
}
Ok(json!({ "fields": list }))
Ok(with_warnings(json!({ "fields": list }), warnings))
}
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
@@ -157,8 +166,8 @@ impl Automation {
let edit =
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.form_actions(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.form_actions(a)?, warnings))
}
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
+4 -3
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
/// The settings every OCR tool shares: dpi, language, the engine/strategy choice, the four
@@ -258,9 +258,10 @@ impl Automation {
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
match out {
None => {
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let (found, out) = self.session.with_own_js_output(id, |s| s.recognize_text(id, &pages, settings)).map_err(failed)?;
let warnings = out.errors;
let words = found.iter().map(|p| p.words.len()).sum::<usize>();
Ok(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }))
Ok(with_warnings(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }), warnings))
}
Some(out) => {
let job = self.session.ocr_job(id, &pages, settings).ok_or_else(|| failed("the document could not be read"))?;
+3 -2
View File
@@ -36,8 +36,9 @@ impl Automation {
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
// its line) by `pdfcraft_create::from_text`, as for every other caller.
// The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and
// Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line)
// by `pdfcraft_create::from_text`, as for every other caller.
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
e => failed(e),
+16 -14
View File
@@ -224,24 +224,27 @@ impl Automation {
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. A status or
// checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the
// Comments panel: one set on a reply is not the comment's.
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
// checkmark replies are read through `review` instead.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in &all {
for r in all.iter().filter(|r| r.state.is_none()) {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
let key = match r.parent {
Some(parent) if r.state.is_some() => parent,
_ => top,
};
threads.entry(key).or_default().push(r);
threads.entry(top).or_default().push(r);
}
// An annotation's review status and checkmark: the latest of each set on it.
// An annotation's review states. A state reply's `/IRT` refers to the annotation whose
// state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2
// §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status`
// is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the
// specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one
// on, and `marked` whether anyone has.
let review = pdfcraft_render::ReviewStates::new(&all);
let states = |c: &Annotation, v: &mut Value| {
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
v["status"] = json!(review.latest_status(c));
v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::<Vec<_>>());
let marked_by = review.marked(c);
v["marked"] = json!(!marked_by.is_empty());
v["marked_by"] = json!(marked_by);
};
let roots: Vec<Value> = all
.iter()
@@ -253,7 +256,6 @@ impl Automation {
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["replies"] = thread
.iter()
.filter(|r| r.state.is_none())
.map(|r| {
let mut reply = view(r);
states(r, &mut reply);
+2
View File
@@ -747,6 +747,7 @@ impl Automation {
"content_delete" => self.content_delete(&a)?,
"content_copy" => self.content_copy(&a)?,
"doc_print" => self.doc_print(&a)?,
"doc_fix_hairlines" => self.fix_hairlines(&a)?,
"attachment_add" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -834,6 +835,7 @@ impl Automation {
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
"accessibility_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
+3
View File
@@ -361,6 +361,7 @@ impl Automation {
("discard_alternate_images", &mut settings.discard_alternate_images),
("discard_tags", &mut settings.discard_tags),
("discard_print_settings", &mut settings.discard_print_settings),
("discard_form_actions", &mut settings.discard_form_actions),
("flate_unencoded", &mut settings.flate_unencoded),
("object_streams", &mut settings.object_streams),
("lzw_to_flate", &mut settings.lzw_to_flate),
@@ -471,6 +472,8 @@ impl Automation {
"thumbnails": o.thumbnails,
"alternate_images": o.alternate_images,
"tags_removed": o.tags_removed,
"print_settings": o.print_settings,
"form_actions": o.form_actions,
"streams_compressed": o.streams_compressed,
"lzw_streams": o.lzw_streams,
"invalid_links": o.invalid_links,
+41 -9
View File
@@ -15,8 +15,8 @@
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
//! than the root's is refused by name, before anything is resolved: resolving it would contact
//! the host.
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside the root, compared by whole canonical components (`/work2` isn't
//! inside `/work`), with the drive letter compared without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Without roots nothing is checked. A path is checked, then opened: a process that can change the
@@ -204,22 +204,23 @@ pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
/// Is `path` (resolved) `root` or inside it, component by component (the drive letter without
/// regard to case on Windows)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
use std::path::Prefix;
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
match (a, b) {
(Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) {
(Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b),
_ => false,
},
_ => false,
}
}
@@ -466,6 +467,37 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// #4003: canonical path components are compared as the file system gave them, with only the
/// Windows drive letter matched ASCII case-insensitively, so a sibling whose name `to_lowercase`
/// folds onto the root's (`K` U+212A → `k`) or a differently-cased canonical component is not
/// taken as inside the root.
#[test]
fn canonical_components_are_compared_exactly() {
let (base, _) = folders("canonical-exact");
std::fs::create_dir_all(base.join("ink")).unwrap();
let kelvin = base.join("in\u{212A}");
std::fs::create_dir_all(&kelvin).unwrap();
std::fs::write(kelvin.join("secret.pdf"), "x").unwrap();
// U+212A folds to K. A case-insensitive volume stores both names as one directory, so
// `check` would be asked to reject a path inside its own root. The component comparison
// itself does not need that volume.
let distinct = base.join("ink").canonicalize().ok() != kelvin.canonicalize().ok();
if distinct {
let roots = AutomationRoots::new(Some(&base.join("ink")), None).unwrap().unwrap();
let e = roots.check(&s(kelvin.join("secret.pdf")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
}
assert!(!inside(Path::new("/work/in\u{212A}/secret.pdf"), Path::new("/work/ink")));
assert!(!inside(Path::new(r"\\?\C:\work\IN\a.pdf"), Path::new(r"\\?\C:\work\in")));
#[cfg(windows)]
{
assert!(inside(Path::new(r"\\?\c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(inside(Path::new(r"c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(!inside(Path::new(r"\\?\D:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
}
let _ = std::fs::remove_dir_all(base);
}
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
/// roots. A function that reaches for the file system without resolving a path first would
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
+15
View File
@@ -213,6 +213,16 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
Ok(match (f.kind, v) {
(FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b),
// {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it
// off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229).
(FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => {
let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?;
let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?;
if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() {
return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len())));
}
FieldValue::Widget(i)
}
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
@@ -294,6 +304,11 @@ impl Automation {
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
// Buttons sharing an on state have the same value: which is on (1-based).
o.insert(
"on_widgets".into(),
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::<Vec<_>>()),
);
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
+40
View File
@@ -0,0 +1,40 @@
//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a
//! width (`doc_fix_hairlines`).
use pdfcraft_engine::{Edit, Hairlines};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
impl Automation {
/// Strokes thinner than `narrower_than` points on the page (after every transformation;
/// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on
/// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere`
/// counts the glyphs and patterns changed that something outside `pages` also uses.
pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result<Value> {
let n = self.doc(a)?.info.pages.len();
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let settings = Hairlines {
narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0),
width: a.opt_num("width")?.unwrap_or(0.01 * 72.0),
type3: a.opt_bool("type3")?.unwrap_or(false),
patterns: a.opt_bool("patterns")?.unwrap_or(false),
};
if !settings.in_range() {
return Err(ToolError::InvalidArgs(format!(
"narrower_than must be 0 or more and width above 0, both at most {} (points)",
Hairlines::MAX_POINTS
)));
}
let id = self.doc(a)?.id;
let found = self.session.count_hairlines(id, &pages, &settings);
if found.lines == 0 {
return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 }));
}
let r = self.apply(a, Edit::FixHairlines { pages, settings })?;
Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] }))
}
}
+1
View File
@@ -22,6 +22,7 @@ mod content;
mod conventions;
pub mod file_access;
mod forms;
mod hairlines;
mod layers;
mod links;
#[cfg(feature = "mcp")]
+35 -1
View File
@@ -113,7 +113,41 @@ impl Automation {
v
})
.collect();
Ok(json!({ "count": items.len(), "links": items }))
let mut out = json!({ "count": items.len(), "links": items });
// Addresses written as plain page text, which the viewer makes clickable (#4207). Reading
// the pages' text costs more than the list above, so only on request.
if a.opt_bool("text_links")?.unwrap_or(false) {
let id = doc.id;
// One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based
// `text_links_from`; `text_links_next_page` says where the next share starts.
let pages = doc.info.pages.len().max(1);
let from = match a.opt_int("text_links_from")? {
None => 0,
Some(p) => usize::try_from(p)
.ok()
.filter(|p| (1..=pages).contains(p))
.map(|p| p - 1)
.ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?,
};
let scan = self.session.text_links(id, from);
if let Some(next) = scan.next_page {
out["text_links_next_page"] = json!(next.saturating_add(1));
}
let found: Vec<Value> = scan
.links
.into_iter()
.map(|(page, l)| {
let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri });
// What the viewer does on a click: the same check every document link gets.
if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) {
v["blocked"] = json!(e.to_string());
}
v
})
.collect();
out["text_links"] = json!(found);
}
Ok(out)
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
+4
View File
@@ -235,6 +235,10 @@ impl Automation {
let (mut html_left, mut spent, mut cut) = (html_budget, false, false);
for (&p, t) in pages.iter().zip(texts) {
let mut page = json!({ "page": p + 1, "text": text(&t) });
// Content cut at the renderer's limits (#623): the text is incomplete, and said so.
if t.truncated {
page["truncated"] = json!(true);
}
if html {
// Copy with formatting: the same glyphs as an HTML fragment, within one budget for
// the whole call; a page past it gets none and the call says so.
+33 -2
View File
@@ -38,8 +38,14 @@ fn bad(m: impl Into<String>) -> ToolError {
}
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
/// index and size; US Letter when none is given.
/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when
/// none is given.
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
// A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in
// `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it.
if let Some(size) = name.and_then(paper_size) {
return Ok((PAPERS.len(), size));
}
let mut papers = PAPERS.iter().enumerate();
let found = match name {
None => papers.next(),
@@ -51,7 +57,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
};
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
bad(format!(
"unknown paper {:?}; choose one of {}",
"unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
name.unwrap_or_default(),
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
))
@@ -237,6 +243,19 @@ impl Automation {
}
}
/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200
/// inches (PDF's own page limit).
fn paper_size(spec: &str) -> Option<(f64, f64)> {
let s = spec.trim().to_ascii_lowercase();
let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)]
.into_iter()
.find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?;
let (w, h) = body.split_once('x')?;
let side = |t: &str| t.trim().parse::<f64>().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v));
let (w, h) = (side(w)?, side(h)?);
Some((w.min(h), w.max(h)))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -289,7 +308,19 @@ mod tests {
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
// A size of its own is laid out as asked and names no media.
assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0))));
assert!(job(json!({}), Some("8.5x14in")).options.is_empty());
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
}
#[test]
fn a_paper_size_reads_its_numbers_and_needs_a_unit() {
assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0)));
assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0)));
for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] {
assert_eq!(paper_size(spec), None, "{spec}");
}
}
}
+10 -4
View File
@@ -2,7 +2,7 @@
//! (removing what they cover for good) or clear them.
use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
};
use serde_json::{Value, json};
@@ -235,6 +235,9 @@ impl Automation {
Ok(json!({ "findings": items, "count": items.len() }))
}
/// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them
/// (Sanitize Document) when none are listed, and report how many items the listed
/// categories lost as `removed`.
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let which = match a.get("categories") {
None => None,
@@ -253,8 +256,11 @@ impl Automation {
// One scan, for this check and for what was removed. The removal refuses when it counts
// every listed category as 0 (an AcroForm with no fields counts 1), with an error about
// redaction marks; this reads the same scan, so it refuses exactly then. A document that
// can't be edited has no scan: `apply` says why.
let info = self.doc(a)?.hidden_info();
// can't be edited has no scan: `apply` says why. Only the listed categories are counted,
// here and after: the others would read every page's content and every object for
// nothing (#5873).
let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec());
let info = self.doc(a)?.hidden_info_of(&listed);
let edit = match which {
None => Edit::Sanitize,
Some(which) => {
@@ -266,7 +272,7 @@ impl Automation {
};
let before: usize = info.iter().map(|c| c.1).sum();
let mut out = self.apply(a, edit)?;
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum();
out["removed"] = json!(before.saturating_sub(after));
Ok(out)
}
+4 -1
View File
@@ -310,6 +310,9 @@ impl Automation {
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
let mut certs: Vec<Certificate> =
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };
// Which certificates are already in, by their bytes: a set, so a bundle of many distinct
// certificates isn't compared against every one before it.
let mut known: std::collections::HashSet<Vec<u8>> = certs.iter().map(|c| c.raw.clone()).collect();
let paths: Vec<String> = match a.get("paths") {
None => Vec::new(),
Some(v) => v
@@ -330,7 +333,7 @@ impl Automation {
sign::x509::load_certificates(&bytes).map_err(failed)?
};
for c in found {
if !certs.iter().any(|t| t.raw == c.raw) {
if known.insert(c.raw.clone()) {
certs.push(c);
}
}
+30 -13
View File
@@ -154,6 +154,17 @@ pub fn tools() -> Vec<ToolDef> {
"dpi": {"type":"number","minimum":72,"maximum":600},
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
}), &["paths","out_dir","steps"])),
t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.")
.cmd("prepress.hairlines")
.with(schema(
json!({
"doc": doc(), "pages": pages("to fix (default all)"),
"narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." },
"width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." },
"type3": { "type": "boolean" }, "patterns": { "type": "boolean" },
}),
&["doc"],
)),
t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.")
.cmd("edit.attach")
.with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])),
@@ -187,7 +198,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx:<name> when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
.ro()
@@ -235,7 +246,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"),
"exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" }
}), &["doc"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.")
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.")
.ro()
.cmd("edit.column_select")
.with(schema(
@@ -497,13 +508,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@@ -708,7 +719,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_print",
"Print",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
)
.with(schema(
json!({
@@ -730,7 +741,7 @@ pub fn tools() -> Vec<ToolDef> {
"cut_marks": { "type": "boolean" },
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
"comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] },
"paper": { "type": "string" },
"paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." },
"path": { "type": "string" },
"printer": { "type": "string" },
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
@@ -813,9 +824,9 @@ pub fn tools() -> Vec<ToolDef> {
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).")
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
.with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
@@ -846,7 +857,7 @@ pub fn tools() -> Vec<ToolDef> {
t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.")
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links, leaving out text on a layer the viewer hides (layer_set). Returns the URLs. Undoable.")
.cmd("edit.links_from_urls")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.")
@@ -1321,6 +1332,11 @@ pub fn tools() -> Vec<ToolDef> {
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
}), &["doc", "object", "generation"])),
t("accessibility_layout_table_to_blocks", "Change layout table to blocks", "Turn a table used only for layout (label and value pairs, a letterhead grid) into plain blocks in one step: the Table tag, its THead/TBody/TFoot and TR tags become Div, its TH, TD and Caption tags P (Div when they hold tags of their own). Content, reading order and the tags inside cells stay; a table nested in a cell stays a table; Table attributes (Summary, Scope, spans) are removed, other attributes kept. The tag's type must be Table after the role map; use object and generation from accessibility_tags. Undoable. Returns the tags as accessibility_tags does.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
}), &["doc", "object", "generation"])),
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
@@ -1332,7 +1348,7 @@ pub fn tools() -> Vec<ToolDef> {
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
@@ -1364,7 +1380,7 @@ pub fn tools() -> Vec<ToolDef> {
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: or pdfx: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
.with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])),
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
.ro()
@@ -1532,7 +1548,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_create",
"Create a PDF",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; set in Helvetica, or in the bundled Inter face when it has Latin, Greek or Cyrillic letters outside Western European (WinAnsi); other characters are refused). Save it with doc_save and a path.",
)
.with(schema(
json!({
@@ -1662,7 +1678,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1679,6 +1695,7 @@ pub fn tools() -> Vec<ToolDef> {
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
"discard_print_settings": { "type": "boolean" },
"discard_form_actions": { "type": "boolean" },
"flate_unencoded": { "type": "boolean" },
"object_streams": { "type": "boolean" },
"lzw_to_flate": { "type": "boolean" },
+623 -1
View File
@@ -2107,6 +2107,162 @@ fn destinations_are_listed_with_their_position() {
assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None));
}
/// Web, `www.` and email addresses written as plain page text, which the viewer opens on a click
/// (#4207), are listed by link_list with text_links: true; one under a link annotation is not.
#[test]
fn addresses_written_as_page_text_are_listed_with_text_links() {
let dir = workdir("text-links");
let content = [
(700, "Visit https://example.com/plain-text-url."),
(650, "or www.example.com/no-scheme"),
(600, "Write to someone@example.com, please"),
(550, "https://example.org/covered"),
(500, "Version 1.2.3 and file:///etc/passwd"),
]
.iter()
.map(|(y, s)| format!("BT /F1 14 Tf 72 {y} Td ({s}) Tj ET\n"))
.collect::<String>();
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".into(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Annot /Subtype /Link /Rect [60 545 400 565] /Border [0 0 0] /A << /S /URI /URI (https://example.org/annotation) >> >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(dir.join("text.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "text.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc })).get("text_links"), None, "only on request");
let listed = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true }));
let found = listed["text_links"].as_array().unwrap();
let urls: Vec<&str> = found.iter().map(|l| l["url"].as_str().unwrap()).collect();
assert_eq!(urls, ["https://example.com/plain-text-url", "https://www.example.com/no-scheme", "mailto:someone@example.com"], "{listed}");
assert!(found.iter().all(|l| l["page"] == 1 && l.get("blocked").is_none()), "{listed}");
// Rectangles from the top-left of the page: the first line's baseline is 792 − 700 = 92 down.
let r = found[0]["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!(r[0] > 100.0 && r[0] < 140.0 && r[1] < 92.0 && r[3] > 80.0, "{r:?}");
}
/// One PDF of `pages` pages that all draw `content` in Helvetica.
fn same_text_pages(pages: usize, content: &str) -> Vec<u8> {
let kids: String = (0..pages).map(|k| format!("{} 0 R ", k + 5)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 612 792] >>"),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 3 0 R >>".to_string()));
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
pdf
}
/// Review on #4472: link_list with text_links reads a share of a large document's pages per
/// call and says where to go on, so a 10,000-page document is never read all at once; a call
/// also stops after the page that brings it to 2,000 addresses.
#[test]
fn text_links_of_a_large_document_are_read_a_share_at_a_time() {
let dir = workdir("text-links-large");
std::fs::write(dir.join("long.pdf"), same_text_pages(10_000, "BT /F1 14 Tf 72 700 Td (See https://example.com/page now) Tj ET\n")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap();
let first = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true }));
let found = first["text_links"].as_array().unwrap();
assert_eq!(found.len(), 100, "the first 100 pages");
assert_eq!((found[0]["page"].clone(), found[99]["page"].clone()), (json!(1), json!(100)));
assert_eq!(first["text_links_next_page"], json!(101));
let last = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 9_951 }));
let found = last["text_links"].as_array().unwrap();
assert_eq!((found.len(), found[0]["page"].clone()), (50, json!(9_951)));
assert_eq!(last.get("text_links_next_page"), None, "nothing after the last page");
for from in [0, -1, 10_001, i64::MAX] {
err(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": from }));
}
// 52 lines of 10 addresses: 520 on a page, kept to 512; the fourth page brings a call to
// 2,048, so it stops there.
let line = "x@example.org ".repeat(10);
let content: String = (0..52).map(|k| format!("BT /F1 6 Tf 20 {} Td ({line}) Tj ET\n", 760 - k * 7)).collect();
std::fs::write(dir.join("dense.pdf"), same_text_pages(6, &content)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "dense.pdf" }))["doc"].as_u64().unwrap();
let dense = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true }));
assert_eq!(dense["text_links"].as_array().unwrap().len(), 4 * 512);
assert_eq!(dense["text_links_next_page"], json!(5));
let rest = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 5 }));
assert_eq!((rest["text_links"].as_array().unwrap().len(), rest.get("text_links_next_page")), (2 * 512, None));
}
/// An address on a layer the viewer hides is not a text link, and Create links from URLs makes
/// no link there: link_list and links_from_urls read the page text with the layers as layer_set
/// left them (ISO 32000-2 §8.11.3.1: hidden optional content "shall not be drawn").
#[test]
fn text_links_leave_out_addresses_on_hidden_layers() {
let dir = workdir("text-links-layers");
let content =
"BT /F1 14 Tf 72 700 Td (https://example.com/shown) Tj ET\n/OC /L1 BDC BT /F1 14 Tf 72 650 Td (https://example.com/layered) Tj ET EMC\n";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /OCG /Name (Addresses) >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(dir.join("layers.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap();
let urls = |a: &mut Automation| -> Vec<String> {
let listed = ok(a, "link_list", json!({ "doc": doc, "text_links": true }));
listed["text_links"].as_array().unwrap().iter().map(|l| l["url"].as_str().unwrap().to_owned()).collect()
};
assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]);
ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": false }));
assert_eq!(urls(&mut a), ["https://example.com/shown"]);
let created = ok(&mut a, "links_from_urls", json!({ "doc": doc }));
assert_eq!(created["created"], json!(["https://example.com/shown"]), "no link over the hidden address: {created}");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": true }));
assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]);
let created = ok(&mut a, "links_from_urls", json!({ "doc": doc }));
assert_eq!(created["created"], json!(["https://example.com/shown", "https://example.com/layered"]), "{created}");
}
#[test]
fn bookmarks_from_structure_through_tools() {
let dir = workdir("bookmarks-structure");
@@ -2155,6 +2311,98 @@ fn numbering_pages_through_tools() {
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_))));
}
/// ISO 32000-2 §12.5.6.3: a state is "for the specified user" (`/T`), so there is one status per
/// reviewer (their latest), and a checkmark ("marked by the user", Table 174) belongs only to the
/// reviewer who set it. `comment_list` reports each reviewer's status and who has a checkmark on;
/// the replies written are hidden (a state reply records a state, not a note) and a reviewer's
/// later change replies to their earlier one.
#[test]
fn review_statuses_are_per_reviewer_through_tools() {
let dir = workdir("statuses");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let id =
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"]
["id"]
.as_str()
.unwrap()
.to_string();
for (status, author) in [("accepted", "Bob"), ("completed", "Carol"), ("rejected", "Bob")] {
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": status, "author": author }));
}
ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Bob" }));
ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol" }));
ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol", "marked": false }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "statuses.pdf" }));
let doc = ok(&mut a, "doc_open", json!({ "path": "statuses.pdf" }))["doc"].as_u64().unwrap();
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
assert_eq!(list["count"], 1, "status replies aren't comments: {list}");
let c = &list["comments"][0];
assert_eq!(c["statuses"], json!([{ "author": "Bob", "status": "Rejected" }, { "author": "Carol", "status": "Completed" }]));
assert_eq!(c["status"], "Rejected", "the latest set by anyone");
assert_eq!((c["marked"].clone(), c["marked_by"].clone()), (json!(true), json!(["Bob"])));
assert_eq!(c["replies"], json!([]));
let bytes = std::fs::read(dir.join("statuses.pdf")).unwrap();
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
let replies: Vec<pdfcraft_cos::Dict> = saved
.object_numbers()
.into_iter()
.filter_map(|n| saved.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().cloned())
.filter(|d| d.contains(b"StateModel"))
.collect();
assert_eq!(replies.len(), 6);
assert!(replies.iter().all(|d| d.int(b"F") == Some(30)), "every state reply is hidden: {replies:?}");
}
/// ISO 32000-2 §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", which
/// for a status set on a reply is that reply. The reviewer's next change replies to their previous
/// state reply, and the chain, found by reference, stays the reply's: it is reported on the reply
/// and never on its comment.
#[test]
fn statuses_set_on_a_reply_chain_on_that_reply_through_tools() {
let dir = workdir("reply-statuses");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let id =
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"]
["id"]
.as_str()
.unwrap()
.to_string();
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "ANSWER", "author": "Bob" }));
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
let reply_id = listed["comments"][0]["replies"][0]["id"].as_str().unwrap().to_string();
for (status, author) in [("accepted", "Carol"), ("completed", "Dan"), ("rejected", "Carol")] {
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": reply_id, "status": status, "author": author }));
}
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "reply-statuses.pdf" }));
let doc = ok(&mut a, "doc_open", json!({ "path": "reply-statuses.pdf" }))["doc"].as_u64().unwrap();
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
let c = &list["comments"][0];
assert_eq!((&c["status"], &c["statuses"]), (&Value::Null, &json!([])), "not the comment's: {c}");
assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "{c}");
let reply = &c["replies"][0];
assert_eq!(reply["statuses"], json!([{ "author": "Carol", "status": "Rejected" }, { "author": "Dan", "status": "Completed" }]), "{reply}");
// Carol's second status replies to her first, which replies to the reply.
let bytes = std::fs::read(dir.join("reply-statuses.pdf")).unwrap();
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
let dicts: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Dict)> = saved
.object_numbers()
.into_iter()
.map(|n| pdfcraft_cos::ObjRef { num: n, generation: 0 })
.filter_map(|r| Some((r, saved.get(r).as_dict()?.clone())))
.collect();
let find = |text: &str| -> pdfcraft_cos::ObjRef {
dicts.iter().find(|(_, d)| d.get(b"Contents").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().contains(text))).unwrap().0
};
let irt = |r: pdfcraft_cos::ObjRef| dicts.iter().find(|(x, _)| *x == r).and_then(|(_, d)| d.get(b"IRT").and_then(|o| o.as_ref()));
let (answer, first, second) = (find("ANSWER"), find("Accepted set by Carol"), find("Rejected set by Carol"));
assert_eq!(irt(first), Some(answer));
assert_eq!(irt(second), Some(first));
}
/// Typed text and text boxes take an oval or a circle for their border through the tools;
/// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes
/// have one, and only the three shapes.
@@ -2818,6 +3066,31 @@ fn form_fill_warns_when_a_value_does_not_fit() {
assert!(r.get("warnings").is_none(), "{r}");
}
/// `form_fill` with {"widget": n} turns on that radio button alone when others share its on
/// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on.
#[test]
fn form_fill_picks_one_of_radio_buttons_sharing_an_on_state() {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
"<< /FT /Btn /T (pick) /Ff 49152 /V /Off /Kids [6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 26 266] /P 3 0 R /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 56 266] /P 3 0 R /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let dir = workdir("radio-shared-state");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pick": { "widget": 2 } } }));
let after = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!((&after["fields"][0]["value"], &after["fields"][0]["on_widgets"]), (&json!("Yes"), &json!([2])), "{after}");
for bad in [json!({ "widget": 0 }), json!({ "widget": 3 }), json!({ "widget": -1 }), json!({ "widget": "1" }), json!({})] {
assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "pick": bad } })), Err(ToolError::InvalidArgs(_))), "{bad}");
}
}
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
/// resets through the tools, and the leniency shows up as a repair note.
#[test]
@@ -3380,6 +3653,59 @@ trailer << /Root 1 0 R >>
assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column"));
}
/// A layout table becomes plain blocks through `accessibility_layout_table_to_blocks` (#4105): the
/// table findings go, the content keeps its order, and the change is undone and saved.
#[test]
fn a_layout_table_becomes_blocks_through_automation() {
let dir = workdir("a11y-layout-table");
let content = "/TD << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj
6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj
7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj
8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj
9 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 0 >> endobj
10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj
11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
std::fs::write(dir.join("layout.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let fd = ok(&mut a, "doc_open", json!({ "path": "layout.pdf" }))["doc"].as_u64().unwrap();
let table_rules = |a: &mut Automation| -> Vec<String> {
let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary", "table-headers"] }));
["table-summary", "table-headers"]
.map(|id| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned())
.to_vec()
};
assert_eq!(table_rules(&mut a), ["failed", "failed"]);
let types = |tags: &Value| tags["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap().to_owned()).collect::<Vec<_>>();
let tags = ok(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 7, "generation": 0 }));
assert_eq!(types(&tags), ["Document", "Div", "Div", "P", "P"]);
assert_eq!(table_rules(&mut a), ["passed", "passed"]);
let content = |a: &mut Automation, object: u32| {
ok(a, "accessibility_tag_content", json!({ "doc": fd, "object": object, "generation": 0 }))["content"][0]["mcid"].clone()
};
assert_eq!((content(&mut a, 9), content(&mut a, 10)), (json!(0), json!(1)), "each cell keeps its content");
// Only a table: a cell is refused.
assert!(matches!(err(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 9, "generation": 0 })), ToolError::Failed(_)));
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties");
assert_eq!(types(&ok(&mut a, "accessibility_tags", json!({ "doc": fd }))), ["Document", "Table", "TR", "TD", "TD"]);
ok(&mut a, "edit_redo", json!({ "doc": fd }));
ok(&mut a, "doc_save", json!({ "doc": fd, "path": "blocks.pdf" }));
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("blocks.pdf")).unwrap())).unwrap();
assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|t| t.kind).collect::<Vec<_>>(), ["Document", "Div", "Div", "P", "P"]);
}
#[test]
fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() {
let dir = workdir("a11y-artifact");
@@ -5059,6 +5385,40 @@ fn removing_hidden_information_through_tools() {
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
}
/// `removed` counts what went from the listed categories. Flattening a field whose appearance
/// draws invisible text puts that text on the page, where hidden text (not listed) counts it;
/// a count over every category took it off what was removed (#5873).
#[test]
fn removed_hidden_information_counts_the_listed_categories() {
let appearance = "BT /Helv 12 Tf 2 4 Td (Ada) Tj 3 Tr (Secret) Tj ET";
let appearance = format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 18] /Resources << /Font << /Helv 7 0 R >> >> /Length {} >>\nstream\n{appearance}\nendstream",
appearance.len()
);
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] /Resources << /Font << /Helv 7 0 R >> >> >>",
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 250 150 268] /P 3 0 R /AP << /N 6 0 R >> >>",
&appearance,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
let dir = workdir("hidden-listed");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
let count = |a: &mut Automation, c: &str| {
let info = ok(a, "doc_hidden_info", json!({ "doc": doc }));
info["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap()
};
assert_eq!((count(&mut a, "form-fields"), count(&mut a, "hidden-text")), (1, 0));
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["form-fields"] }));
assert_eq!(r["removed"], 1, "{r}");
assert_eq!(count(&mut a, "form-fields"), 0);
assert_eq!(count(&mut a, "hidden-text"), 6, "the flattened appearance's invisible word is page content now");
}
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
fn faux_redacted_fixture() -> Vec<u8> {
helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f")
@@ -7350,6 +7710,32 @@ fn optimizing_through_tools() {
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn optimizing_discards_form_actions_through_tools() {
let dir = workdir("optimize-form-actions");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Send" }));
ok(
&mut a,
"form_set_actions",
json!({ "doc": doc, "field": "Send", "actions": [
{ "trigger": "mouse_up", "submit": "https://example.com/submit" },
{ "trigger": "mouse_down", "reset": [] },
{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }
] }),
);
let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" }));
assert_eq!(kept["form_actions"], 0, "{kept}");
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_form_actions": true }));
assert_eq!(r["form_actions"], 2, "{r}");
let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap();
let fields = ok(&mut a, "form_fields", json!({ "doc": small }));
assert!(fields.to_string().contains("Send"), "the button stays: {fields}");
let actions = ok(&mut a, "form_actions", json!({ "doc": small, "field": "Send" }));
assert_eq!(actions["actions"], json!([{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }]), "{actions}");
}
/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream
/// needs is not what viewers read: the header is raised, and the later of the two is reported.
#[test]
@@ -7370,6 +7756,35 @@ fn retained_pdf_version_reports_the_header_raised_for_object_streams() {
assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}");
}
/// #3227: retaining the version, the later of the written header and the catalog `/Version` is
/// reported: a catalog below the header cannot hide it, and equal versions are reported as is.
#[test]
fn retained_pdf_version_is_the_later_of_header_and_catalog() {
let dir = workdir("optimize-retain-version-later");
let mut a = auto(&dir);
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" }));
let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap();
let root = cos.root().unwrap();
for (label, header, catalog, saved, reported) in [
("higher-header", "1.7", "1.4", "1.7", "1.7"),
// PDF versions compare as major/minor numbers: 2.0 is newer than 1.7.
// Retain keeps the header. The catalog /Version only sets the reported version.
("higher-catalog", "1.7", "2.0", "1.7", "2.0"),
("equal", "1.4", "1.4", "1.4", "1.4"),
] {
cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name(catalog))).unwrap();
cos.set_version(header);
let input = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
std::fs::write(dir.join(format!("{label}-input.pdf")), input).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": format!("{label}-input.pdf") }))["doc"].as_u64().unwrap();
let out = format!("{label}-output.pdf");
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": out, "compatibility": "retain", "object_streams": false }));
assert!(std::fs::read(dir.join(&out)).unwrap().starts_with(format!("%PDF-{saved}\n").as_bytes()), "{label}: written header");
assert_eq!(r["pdf_version"].as_str(), Some(reported), "{label}: {r}");
}
}
/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for
/// fewer than 250 bytes, before the code width grows.
fn lzw_literals(data: &[u8]) -> Vec<u8> {
@@ -8940,6 +9355,46 @@ fn links_in_xfa_rich_text_are_listed_through_tools() {
assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two");
}
/// What a date typed into an XFA date field leaves in the form's data, for each kind of picture:
/// what another viewer kept for the same typing on a synthetic form. A date one of the edit
/// picture's patterns reads is kept as an ISO date; anything else, ISO text included, as typed,
/// and nothing is refused.
#[test]
fn xfa_date_fields_keep_typed_dates_as_another_viewer_does() {
let dir = workdir("xfa-typed-dates");
std::fs::write(dir.join("dates.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_pictures_template())).unwrap();
let mut a = auto(&dir);
// typed, then the data for display, edit, either and bare.
let seen = [
("2016-10-18", ["2016-10-18", "2016-10-18", "2016-10-18", "2016-10-18"]),
("10/18/2016", ["10/18/2016", "2016-10-18", "10/18/2016", "10/18/2016"]),
("20161018", ["20161018", "20161018", "2016-10-18", "20161018"]),
("18OCT16", ["18OCT16", "18OCT16", "2016-10-18", "18OCT16"]),
("26 10 18", ["26 10 18", "26 10 18", "26 10 18", "26 10 18"]),
("xyz", ["xyz", "xyz", "xyz", "xyz"]),
];
for (typed, kept) in seen {
for (field, want) in ["display", "edit", "either", "bare"].into_iter().zip(kept) {
let doc = ok(&mut a, "doc_open", json!({ "path": "dates.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { field: typed } }));
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" }));
let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap();
assert!(xml.contains(&format!("<{field}>{want}</{field}>")), "{typed} in {field}: want {want}, got {xml}");
ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true }));
}
}
// What the fields show: an ISO date in the field's own date pattern (MM/DD/YYYY, YYYYMMDD),
// read from the appearances flattening draws into the page.
let doc = ok(&mut a, "doc_open", json!({ "path": "dates.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "edit": "2016-10-18", "either": "2016-10-18" } }));
ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true }));
let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string();
assert!(text.contains("10/18/2016") && text.contains("20161018") && !text.contains("2016-10-18"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A dynamic XFA form opens laid out from its template through the tools, renders, takes values
/// and saves them.
#[test]
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
let dir = workdir("xfa");
@@ -9016,7 +9471,8 @@ fn cut_stack_printing_through_tools() {
);
assert_eq!(r["pages"], 5);
let printed = ok(&mut a, "doc_open", json!({"path": "range.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, printed), ["Page 10\nPage 4", "Page 8\nPage 2", "Page 6"]);
// Two pages side by side: their labels share a baseline, so a line.
assert_eq!(page_text(&mut a, printed), ["Page 10 Page 4", "Page 8 Page 2", "Page 6"]);
assert_eq!(page_text(&mut a, doc), before);
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false);
for duplex in ["long-edge", "short-edge"] {
@@ -9227,6 +9683,94 @@ fn measurement_snap_tool_covers_all_targets() {
let _ = std::fs::remove_dir_all(dir);
}
/// An XFA button click through js_run returns only what the click produced, not the
/// alerts of an earlier fill.
#[test]
fn an_xfa_click_through_js_run_does_not_return_an_earlier_fills_alerts() {
let dir = workdir("xfa-click-output");
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
// 500 fails qty's validation, which shows its message.
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "500" } }));
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
assert_eq!(r["alerts"], json!([{ "message": "Hello 500", "title": null, "icon": "error", "buttons": "ok" }]), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
/// form_set_script returns only the console lines its own edit produced, not those of an
/// earlier fill.
#[test]
fn form_set_script_does_not_return_an_earlier_fills_console_lines() {
let dir = workdir("set-script-output");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (i, name) in ["a", "b"].into_iter().enumerate() {
let y = 20 + 30 * i;
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "a", "event": "validate", "script": "console.println('validated ' + event.value);" }));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "a": "7" } }));
let r = ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "b", "event": "format", "script": "event.value = event.value;" }));
assert_eq!(r["console"], json!([]), "{r}");
assert_eq!(r["errors"], json!([]), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
/// js_run returns the output of the field scripts that its field changes ran, with the
/// script's own.
#[test]
fn js_run_returns_the_output_of_the_calculations_its_changes_ran() {
let dir = workdir("js-run-calc-output");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (i, name) in ["a", "b"].into_iter().enumerate() {
let y = 20 + 30 * i;
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
let calc = "console.println('calculated ' + getField('a').value); event.value = getField('a').value;";
ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "b", "event": "calculate", "script": calc }));
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "getField('a').value = '4'; console.println('set');" }));
let console: Vec<&str> = r["console"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(console.first().is_some_and(|l| l.starts_with("set")), "{r}");
assert!(console.iter().any(|l| l.starts_with("calculated 4")), "{r}");
let _ = std::fs::remove_dir_all(dir);
}
/// The tools that edit a document without going through form_fill's path report the values of
/// an XFA form that could not be written to its data in `warnings`, once, and leave nothing for
/// the next tool to report as its own.
#[test]
fn edits_report_the_xfa_data_they_could_not_write_once() {
let dir = workdir("xfa-data-warnings");
// The data holds structured content where the price goes, so its value can't be written.
let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(
"</xdp:xdp>",
"<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><price><p>7</p></price></page1></form></xfa:data></xfa:datasets>\n</xdp:xdp>",
);
std::fs::write(dir.join("data.pdf"), pdfcraft_xfa::fixtures::shell(&tpl)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "data.pdf" }))["doc"].as_u64().unwrap();
let structured = |out: &Value, key: &str| {
out[key].as_array().map_or(0, |w| w.iter().filter(|w| w.as_str().is_some_and(|w| w.contains("structured content"))).count())
};
let r = ok(&mut a, "js_set_document_script", json!({ "doc": doc, "name": "init", "script": "var ready = true;" }));
assert_eq!(structured(&r, "warnings"), 1, "{r}");
assert_eq!(r["scripts"][0]["name"], "init", "{r}");
let r = ok(
&mut a,
"form_set_actions",
json!({ "doc": doc, "field": "hello", "actions": [{ "trigger": "mouse_enter", "javascript": "var entered = true;" }] }),
);
assert_eq!(structured(&r, "warnings"), 1, "{r}");
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "getField('qty').value = 3;" }));
assert_eq!(structured(&r, "warnings"), 1, "{r}");
// form_set_script lists its edit's own warning in errors, and the earlier tools' are not repeated.
let r = ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "qty", "event": "format", "script": "event.value = event.value;" }));
assert_eq!(structured(&r, "errors"), 1, "{r}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn growable_xfa_text_paginates_after_fill_save_and_print() {
let dir = workdir("xfa-growable-text");
@@ -10617,6 +11161,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
let _ = std::fs::remove_dir_all(base);
}
/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres,
/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is
/// refused.
#[test]
fn doc_print_takes_a_paper_size_of_its_own() {
let dir = workdir("print-paper-size");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (paper, want) in
[("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))]
{
let file = format!("{}.pdf", want.0);
ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file }));
let bytes = std::fs::read(dir.join(&file)).unwrap();
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&out)[0].display_size(&out);
assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}");
}
for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] {
match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) {
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"),
other => panic!("{paper}: {other:?}"),
}
}
}
/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside
/// 72–600 is refused.
#[test]
@@ -10655,6 +11225,30 @@ fn a_root_less_write_output_is_atomic_too() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Fix hairlines through the tools: counts what changed, and reports zero without an edit.
#[test]
fn fix_hairlines_through_tools() {
let dir = workdir("hairlines");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
// The fixture's text pages have no strokes.
let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc }));
assert_eq!((r["fixed"].as_u64(), r["undo"].is_null()), (Some(0), true));
// A zero-width rule drawn by the page content tools counts as a hairline.
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [10, 10], "to": [100, 10], "width": 0 }));
let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc, "pages": [1] }));
// Comments are annotations, not page content: still nothing in the page's own content.
assert_eq!(r["fixed"].as_u64(), Some(0), "{r}");
assert_eq!(r["elsewhere"].as_u64(), Some(0), "{r}");
for bad in
[json!({ "width": 0 }), json!({ "width": -1 }), json!({ "width": 10000 }), json!({ "narrower_than": -1 }), json!({ "narrower_than": 10000 })]
{
let mut args = bad.clone();
args["doc"] = json!(doc);
assert!(matches!(a.call("doc_fix_hairlines", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
}
}
#[test]
fn attaching_and_deleting_files_through_the_tools() {
let dir = workdir("attach");
@@ -11594,6 +12188,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// text_extract says when a page's text is incomplete because its content was cut at the
/// renderer's limits (#623); a page read whole carries no such mark.
#[test]
fn text_extract_says_when_a_pages_content_was_cut() {
use std::io::Write as _;
let dir = workdir("cut-content");
// 288 MiB of whitespace, past the renderer's 256 MiB per-stream limit, as one Flate stream.
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
let chunk = vec![0u8; 1 << 20];
for _ in 0..288 {
z.write_all(&chunk).unwrap();
}
let stream = z.finish().unwrap();
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes());
pdf.extend_from_slice(&stream);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
std::fs::write(dir.join("cut.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let cut = ok(&mut a, "doc_open", json!({ "path": "cut.pdf" }))["doc"].as_u64().unwrap();
let out = ok(&mut a, "text_extract", json!({ "doc": cut }));
assert_eq!(out["pages"][0]["truncated"], json!(true), "{out}");
let whole = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let out = ok(&mut a, "text_extract", json!({ "doc": whole, "pages": [1] }));
assert!(out["pages"][0].get("truncated").is_none(), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
+52 -20
View File
@@ -11,9 +11,10 @@
//!
//! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so
//! every call must come back as `Ok` or as a `ToolError` the agent can read.
//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute
//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and
//! afterwards nothing outside the root may have been created.
//! 2. **Nothing escapes `--root` or the automation roots.** Every path argument is tried with `..`
//! traversal, absolute paths, Windows drive and UNC paths, and a planted symbolic link, under
//! both `--root` and `--automation-read-root`/`--automation-write-root` (#4003). The call must
//! fail, and afterwards nothing outside the root may have been created.
//!
//! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves
//! its own paths and that no tool dispatches another internally; this keeps that true as tools are
@@ -22,6 +23,7 @@
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use pdfcraft_automation::file_access::AutomationRoots;
use pdfcraft_automation::{Automation, ToolError, tools};
use serde_json::{Value, json};
@@ -84,6 +86,7 @@ fn zoo() -> Vec<Value> {
/// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link
/// to `base`.
fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
let root = base.join("root");
let mut out: Vec<String> = vec![
"../outside.pdf".into(),
"../../outside.pdf".into(),
@@ -93,13 +96,16 @@ fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
base.join("outside.pdf").to_string_lossy().into_owned(),
base.join("new-outside.pdf").to_string_lossy().into_owned(),
base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(),
root.join("..").join("outside.pdf").to_string_lossy().into_owned(),
];
if linked {
out.push("link-out/outside.pdf".into());
out.push(root.join("link-out").join("outside.pdf").to_string_lossy().into_owned());
}
// Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root.
if cfg!(windows) {
out.push(r"..\..\outside.pdf".into());
out.push(r"\\.\pipe\pdfcraft-hostile-escape.pdf".into());
}
out
}
@@ -220,6 +226,20 @@ fn empty_dir(dir: &Path) {
}
}
/// `action_run` keeps going when one input path is refused and reports that refusal on the file.
/// That is still a confinement refusal: every file has an error naming the boundary, and none
/// has an output.
fn per_file_confinement_refusal(content: &[pdfcraft_automation::Content]) -> bool {
let Some(files) = content.iter().find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v.get("files").and_then(Value::as_array),
pdfcraft_automation::Content::Png { .. } => None,
}) else {
return false;
};
!files.is_empty()
&& files.iter().all(|file| file.get("output").is_none() && file.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("outside")))
}
/// Real values for the arguments a tool checks before the others, so a hostile value reaches the
/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its
/// digital ID first and its document second: this ID opens with its password, and each call gets
@@ -234,6 +254,12 @@ fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map<Strin
let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" });
args.as_object().cloned().unwrap_or_default()
}
// Without a step, action_run returns "give action or steps" before it resolves folder
// or paths, and that error would be counted as a confinement refusal.
"action_run" => {
let args = json!({ "steps": [{ "step": "set_title", "arg": "Confined" }] });
args.as_object().cloned().unwrap_or_default()
}
_ => serde_json::Map::new(),
}
}
@@ -313,28 +339,34 @@ fn no_tool_escapes_the_root() {
}
};
let mut a = Automation::new().with_root(&root).expect("root");
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let doc = blank_doc(&mut a);
let roots = AutomationRoots::new(Some(&root), Some(&root)).expect("roots");
let modes = [("--root", Automation::new().with_root(&root).expect("root")), ("automation roots", Automation::new().with_automation_roots(roots))];
let mut leaked = Vec::new();
let mut tried = 0usize;
let mut refused = 0usize;
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);
tried += 1;
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) {
// A path that leaves the root must be refused. Succeeding means the tool
// either used it or ignored an argument it declares — both are faults.
Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)),
Ok(Err(_)) => refused += 1,
Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)),
for (mode, mut a) in modes {
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let doc = blank_doc(&mut a);
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);
tried += 1;
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) {
// A path that leaves the root must be refused. Succeeding means the tool
// either used it or ignored an argument it declares — both are faults.
// action_run reports a refused input inside an otherwise successful
// result, one error per file, and writes nothing for that file.
Ok(Ok(content)) if per_file_confinement_refusal(&content) => refused += 1,
Ok(Ok(_)) => leaked.push(format!("{mode}: {} accepted {prop}={escape:?}", def.name)),
Ok(Err(_)) => refused += 1,
Err(_) => leaked.push(format!("{mode}: {} panicked on {prop}={escape:?}", def.name)),
}
}
}
}
+1
View File
@@ -17,4 +17,5 @@ mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
mod text_copy;
mod unreadable_objects;
+106
View File
@@ -0,0 +1,106 @@
//! `text_extract` and `text_find` on a page of the cases text copy has to get right: words drawn out
//! of order, runs of different sizes on one baseline, a ligature glyph named in the encoding and a
//! line turned upwards in a row of text.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// A fresh directory for one test.
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-text-copy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
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}"),
}
}
/// One page of labelled rows (`/F1` Helvetica, `/F2` Helvetica with code 128 named `/fi`):
/// 01 `of<fi>ce`; 06 `exam-` over `ple`; 07 two columns of two lines; 08 "second" drawn before
/// "first", to its right; 11 a 14 pt invisible word and a 9 pt note on one baseline; 12 letter
/// spacing; 13 "up" set upwards; 14 two words placed by `Td`.
fn copy_pdf() -> Vec<u8> {
let rows = [
("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"),
("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"),
("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"),
("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"),
("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"),
("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"),
("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"),
("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"),
];
let mut body = String::new();
let mut y = 740;
for (tag, row) in rows {
body += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", row.replace("{Y}", &y.to_string()));
y -= match tag {
"06" | "13" => 48,
"07" => 44,
_ => 38,
};
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>"
.to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Copy keeps a baseline on one line in left-to-right order, columns column by column, the
/// ligature as its letters and the turned line in its row; Find matches what Copy reads, and its
/// rectangles cover the glyphs it names.
#[test]
fn text_extract_and_find_read_one_line_per_baseline() {
let dir = tmp("baseline");
std::fs::write(dir.join("copy.pdf"), copy_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "copy.pdf" }))["doc"].as_u64().unwrap();
let text = ok(&mut a, "text_extract", json!({ "doc": doc }));
assert_eq!(
text["pages"][0]["text"],
"01 office\n06 exam-\nple\n07 left one\nleft two\nright one\nright two\n08 first second\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words"
);
let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }));
let first_second = find(&mut a, "first second");
assert_eq!(first_second["count"], 1);
assert_eq!(first_second["matches"][0]["text"], "first second");
// One rectangle across both words, from the left edge of "first" (x = 90) past the right edge of "second".
let rects = first_second["matches"][0]["rects"].as_array().unwrap();
assert_eq!(rects.len(), 1, "{rects:?}");
let x = |k: usize| rects[0][k].as_f64().unwrap();
assert!((x(0) - 90.0).abs() < 0.5 && x(2) > 200.0, "{rects:?}");
let office = find(&mut a, "office");
assert_eq!(office["count"], 1);
assert_eq!(office["matches"][0]["text"], "office");
assert_eq!(find(&mut a, "hidden <- invisible")["count"], 1);
assert_eq!(find(&mut a, "left one right one")["count"], 0, "the columns stay apart");
let _ = std::fs::remove_dir_all(&dir);
}
+10 -6
View File
@@ -67,8 +67,9 @@ pub struct Parsed {
/// Bytes that could not be read as operands or operators.
pub skipped: usize,
/// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it
/// accepts). Their end here is a guess, so the operators after them may not be the ones the
/// page draws.
/// accepts), so their end here is a guess and the operators after them may not be the ones
/// the page draws; and images it reads whose parameters this parser can't, which are missing
/// from `ops` although the page draws them.
pub ambiguous_images: usize,
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
@@ -139,8 +140,8 @@ impl<'a> Ops<'a> {
self.skipped
}
/// Inline images read so far whose end is this parser's guess (as in
/// [`Parsed::ambiguous_images`]).
/// Inline images read so far that are ambiguous (as in [`Parsed::ambiguous_images`]): either
/// their end is this parser's guess, or the renderer reads them and this parser cannot.
pub fn ambiguous_images(&self) -> usize {
self.ambiguous_images
}
@@ -291,7 +292,8 @@ struct Inline {
image: Option<(Dict, Vec<u8>)>,
/// The position after its `EI` (or the end of the stream).
end: usize,
/// The renderer's tokenizer reads no image here, so `end` is this parser's guess.
/// The renderer's tokenizer reads no image here, so `end` is this parser's guess; or it reads
/// one whose parameters this parser can't, so `image` is `None` for an image the page draws.
ambiguous: bool,
}
@@ -339,7 +341,9 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline {
{
samples = rest;
}
return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false };
// The renderer draws an image whose parameters this parser can't read: it is not
// represented here, so a redaction can't inspect or remove it and must not trust the page.
return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: !readable };
}
if !readable {
// Without an `EI` after it, the image runs to the end of the stream.
+10
View File
@@ -209,6 +209,16 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them()
}
}
/// The renderer reads an image whose parameters this parser can't (a stray operand before `ID`):
/// the page draws an image `ops` doesn't hold, so it is counted as ambiguous rather than dropped
/// silently.
#[test]
fn an_inline_image_read_by_the_renderer_but_not_here_is_ambiguous() {
let parsed = parse(b"q BI /W 1 /H 1 /BPC 8 /CS /G 7 ID \x80 EI Q");
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"Q"]);
assert_eq!(parsed.ambiguous_images, 1);
}
/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41,
/// the way a stored deflate block copies incompressible samples verbatim (#3579).
const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\
+33 -19
View File
@@ -2,7 +2,8 @@ use std::collections::HashSet;
use super::reader::find;
use super::state::{
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads,
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member,
overlay_reads,
};
use super::xref::{Revision, XrefEntry, XrefIndex};
use super::{
@@ -705,23 +706,7 @@ impl Document {
match self.entries.lookup(num, &self.data) {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| {
if self.overlay.contains_key(&r.num) {
note_overlay_read();
}
// A length resolved through this document's edits counts as an edit read
// on every reuse.
if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
length
};
let resolve = |r: ObjRef| self.stream_length(r);
let off = offset as usize;
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
@@ -795,6 +780,29 @@ impl Document {
Ok(stm)
}
/// An indirect stream `/Length`, read through the loader's guard against cycles. Kept in
/// [`LENGTHS`] only during a load, which clears it when it ends: a scan of the file from
/// [`Self::objstm`] can come here without one.
fn stream_length(&self, r: ObjRef) -> Option<i64> {
if self.overlay.contains_key(&r.num) {
note_overlay_read();
}
let cached = loading();
// A length resolved through this document's edits counts as an edit read on every reuse.
if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
if cached {
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
}
length
}
/// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss
/// reads the whole file, and a file can refer to such an object from everywhere.
fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> {
@@ -805,6 +813,8 @@ impl Document {
super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1));
let needle = format!("{num} ");
let data = &self.data;
let budget = ScanLengths::enter();
let resolve = |r: ObjRef| if budget.take() { self.stream_length(r) } else { None };
let mut found = None;
let mut i = 0;
while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
@@ -812,7 +822,11 @@ impl Document {
if p > 0 && !is_whitespace(data[p - 1]) {
continue;
}
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams)
// An indirect `/Length` is resolved, as at the object's offset, or a stream that ends
// in a misspelt `endstream` is searched on into a later object's. A header that parses
// as far as the `/Length` starts with `num` itself, after white space. A budget shared
// with the scans this leads to bounds how often the file is read again ([`ScanLengths`]).
if let Ok((id, o)) = parse_indirect_shared(data, p, &resolve, &self.endstreams)
&& id.num == num
{
found = Some((id, o)); // keep the last (newest) definition
+52
View File
@@ -11,6 +11,52 @@ thread_local! {
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, (Option<i64>, bool)>> = std::cell::RefCell::new(HashMap::new());
/// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`).
static OVERLAY_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length`
/// values the outermost scan and those inside it have resolved ([`ScanLengths`]).
static SCAN_LENGTHS: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
}
/// The indirect `/Length` values a scan of the file for an object may resolve, shared with the
/// scans it leads to. Each header in the file that starts with the object's number is parsed, and
/// a `/Length` that names an object the cross-reference table cannot place scans the file for that
/// one: a damaged file with many such headers, each naming another missing object, would read the
/// whole file once per header. Past the budget a `/Length` is left unresolved, and the stream is
/// read as before resolution, by its `endstream`.
pub(super) struct ScanLengths;
impl ScanLengths {
/// A real object is defined once per revision, and its `/Length` once per definition.
pub(super) const MAX: u32 = 32;
/// Marks a scan as running; the outermost one starts the budget afresh.
pub(super) fn enter() -> Self {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
c.set((depth.saturating_add(1), if depth == 0 { 0 } else { used }));
});
ScanLengths
}
/// Whether one more `/Length` may be resolved, counting it if so.
pub(super) fn take(&self) -> bool {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
if used >= Self::MAX {
return false;
}
c.set((depth, used.saturating_add(1)));
true
})
}
}
impl Drop for ScanLengths {
fn drop(&mut self) {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
c.set((depth.saturating_sub(1), used));
});
}
}
/// Unsaved edits read so far on this thread: a load during which this changes depended on one.
@@ -42,6 +88,12 @@ impl LoadGuard {
}
}
/// Whether an object is being loaded on this thread: [`LENGTHS`] holds only while one is, since
/// it is cleared when the outermost load ends.
pub(super) fn loading() -> bool {
LOADING.with(|l| !l.borrow().is_empty())
}
/// What loading a file object gave, kept in the document's cache either way.
pub(super) type Loaded = Result<Arc<Object>, CosError>;
+61
View File
@@ -30,6 +30,67 @@ fn what_a_scan_of_the_file_did_not_find_is_remembered() {
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file");
}
/// An object found by a scan of the file, because its cross-reference offset is wrong, has its
/// indirect `/Length` resolved as at the right offset. Unresolved, a stream ending in a misspelt
/// `endstream` (`endstrea`) was searched on into a later object's `endstream`, and took that
/// object's header and data as its own.
#[test]
fn a_scanned_stream_resolves_its_indirect_length() {
let mut out = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [] /Count 0 >>",
"<< /Length 4 0 R >>\nstream\nHELLO\nendstrea",
"5",
"<< /Length 5 >>\nstream\nWORLD\nendstream",
],
"/Root 1 0 R",
);
// Point object 3's entry at the catalog, so only the scan finds it.
let at = |needle: &[u8], out: &[u8]| out.windows(needle.len()).position(|w| w == needle).unwrap();
let catalog = format!("{:010} 00000 n ", at(b"1 0 obj", &out));
let entry = at(format!("{:010} 00000 n ", at(b"3 0 obj", &out)).as_bytes(), &out);
out.splice(entry..entry + catalog.len(), catalog.bytes());
let doc = Document::open(Arc::new(out)).unwrap();
let stream = doc.try_get(3).unwrap();
let Object::Stream(s) = &*stream else { panic!("stream expected: {stream:?}") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// A scan of the file resolves only so many indirect `/Length` values. Each header that starts
/// with the sought number is parsed, and a `/Length` naming an object the table cannot place scans
/// the file for that one: with a hundred such headers, each naming another misplaced object, one
/// lookup read the whole file a hundred and one times.
#[test]
fn a_scan_of_the_file_resolves_a_bounded_number_of_lengths() {
const HEADERS: u32 = 100;
let mut out = b"%PDF-1.7\n".to_vec();
let catalog = out.len();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
let pages = out.len();
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n");
// Object 3 is defined only by headers inside this string, each with its own misplaced /Length.
out.extend_from_slice(b"4 0 obj (\n");
for k in 0..HEADERS {
out.extend_from_slice(format!("3 0 obj << /Length {} 0 R >> stream\nX\nendstream endobj\n", 10 + k).as_bytes());
}
out.extend_from_slice(b") endobj\n");
let size = 10 + HEADERS;
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for num in 1..size {
let at = if num == 2 { pages } else { catalog };
out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
FILE_SCANS.with(|scans| scans.set(0));
let three = doc.try_get(3).unwrap();
assert!(matches!(&*three, Object::Stream(_)), "{three:?}");
let scans = FILE_SCANS.with(std::cell::Cell::get);
assert!(scans <= 1 + super::state::ScanLengths::MAX as usize, "{scans} scans of the file");
}
/// An object can be nothing but a reference to another. A chain of them is followed, and one that
/// goes round ends in what isn't a dictionary or a name, without hanging.
#[test]
+160 -20
View File
@@ -437,7 +437,7 @@ pub(crate) type EndstreamIndex = std::sync::OnceLock<Vec<usize>>;
fn endstream_offsets(data: &[u8]) -> Vec<usize> {
#[cfg(test)]
ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len()));
data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect()
data.windows(9).enumerate().filter(|(i, w)| *w == b"endstream" && endstream_at(data, *i)).map(|(i, _)| i).collect()
}
fn parse_indirect_in(
@@ -491,26 +491,24 @@ fn parse_indirect_in(
Some(Object::Ref(r)) => resolve_length(*r),
_ => None,
};
let valid = |len: i64| -> bool {
if len < 0 {
return false;
}
let end = start.saturating_add(len as usize);
if end > data.len() {
return false;
}
let mut after = Lexer::new(data, end);
after.eat_keyword(b"endstream")
};
// A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt.
// A /Length that fits in the file (not negative, and no wrap on a 32-bit target).
let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len()));
let len = match declared {
Some(l) if valid(l) => l as usize,
// What follows the /Length bytes, white space aside.
let after = |len: usize| {
let mut after = Lexer::new(data, start.saturating_add(len));
after.skip_ws();
data.get(after.pos..).unwrap_or_default()
};
let len = match fits {
// The /Length ends at `endstream`, or where it would be: misspelt (`endstrea`), or
// left out before `endobj` or the end of the file. The stream is the bytes its /Length
// gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2
// §7.3.8.2), as other readers take it. A producer that wrote `endstrea` after every
// stream lost every font and every page's content, and searching on would run into a
// later object's `endstream`.
Some(l) if at_stream_end(after(l)) => l,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none
// left in the file, a /Length that fits is the stream's extent, "how many bytes of the
// PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers
// take it: a producer that wrote `endstrea` after every stream lost every font and
// every page's content.
// left in the file, a /Length that fits is still the stream's extent.
_ => match find_endstream(data, start, endstreams) {
Some(end) => end - start,
None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?,
@@ -529,6 +527,45 @@ fn parse_indirect_in(
Ok((id, obj))
}
/// Whether `rest`, what follows a stream's /Length bytes with white space skipped, is where the
/// stream ends. A whole `endstream` token is: a token runs on through regular characters (ISO
/// 32000-2 §7.2.3), so payload such as `endstreamer` is not one. A misspelt `endstream`
/// (`endstrea`), an `endobj` with the `endstream` left out, or both, end it only where the object
/// ends as well, before the end of the data, another object's `N G obj` header, or `xref`,
/// `trailer` or `startxref`: payload such as `endobj more` or `endobject`, where a wrong /Length
/// stops, is not, and the search for `endstream` runs instead. So does anything else but the end
/// of the data.
fn at_stream_end(rest: &[u8]) -> bool {
let mut lx = Lexer::new(rest, 0);
if lx.eat_keyword(b"endstream") {
return true;
}
let misspelt = lx.eat_keyword(b"endstrea");
let endobj = lx.eat_keyword(b"endobj");
lx.skip_ws();
if lx.pos >= rest.len() {
return true;
}
if !(misspelt || endobj) {
return false;
}
if [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| Lexer::new(rest, lx.pos).eat_keyword(kw)) {
return true;
}
let number = |lx: &mut Lexer| {
lx.skip_ws();
let t = lx.token();
!t.is_empty() && t.iter().all(u8::is_ascii_digit)
};
number(&mut lx) && number(&mut lx) && lx.eat_keyword(b"obj")
}
/// Whether a whole `endstream` token starts at `at` in `data`: one followed by a regular
/// character (`endstreamer`) is payload (ISO 32000-2 §7.2.3).
fn endstream_at(data: &[u8], at: usize) -> bool {
data.get(at..).is_some_and(|d| d.starts_with(b"endstream")) && !data.get(at.saturating_add(9)).copied().is_some_and(is_regular)
}
/// Offset where stream data ends (before the EOL preceding "endstream").
fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option<usize> {
let at = match endstreams {
@@ -538,7 +575,7 @@ fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>)
}
None => {
let hay = data.get(from..)?;
let found = hay.windows(9).position(|w| w == b"endstream");
let found = hay.windows(9).enumerate().position(|(i, w)| w == b"endstream" && endstream_at(hay, i));
#[cfg(test)]
ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9)));
found? + from
@@ -683,6 +720,109 @@ mod tests {
assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length");
}
/// A stream ending in `endstrea` is its /Length even when a later object does have an
/// `endstream`: the search for one would otherwise run on into that object and take its
/// header and data as this stream's.
#[test]
fn a_stream_without_endstream_is_not_read_into_a_later_one() {
let data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO\r\nendstrea\rendobj\r\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n";
let second = data.windows(7).position(|w| w == b"8 0 obj").unwrap();
for (offset, want) in [(0, &b"HELLO"[..]), (second, b"WORLD")] {
let (_, o) = parse_indirect(data, offset, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected at {offset}") };
assert_eq!(&s.raw[..], want, "at {offset}");
}
// Through the loader's shared index of `endstream` offsets as well.
let index = EndstreamIndex::new();
let (_, o) = parse_indirect_in(data, None, 0, &|_| None, Some(&index)).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// The same for a stream whose `endstream` is left out before `endobj`: the later object's
/// `endstream` is not this stream's end.
#[test]
fn a_stream_ending_at_endobj_is_not_read_into_a_later_one() {
let data = b"7 0 obj\n<< /Length 5 >>\nstream\nHELLO\nendobj\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// A wrong /Length that stops before payload bytes beginning like an end marker (`endobject`,
/// `endstreak`, `endstreamer`) is not taken for the stream's end: a token runs on through
/// regular characters (ISO 32000-2 §7.2.3), so the search for `endstream` finds the whole
/// payload, and skips `endstreamer` too, by itself and through the loader's shared index.
#[test]
fn a_length_ending_inside_a_longer_word_is_not_an_end_marker() {
for word in ["endobject", "endstreak", "endstreamer"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {word} more\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}");
let index = EndstreamIndex::new();
let (_, o) = parse_indirect_in(pdf.as_bytes(), None, 0, &|_| None, Some(&index)).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}, indexed");
}
// Whole markers still end it, followed by white space, a delimiter or the end of the data.
for tail in ["endobj\n", "endstrea%", "endobj"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{tail}");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{tail:?}");
}
}
/// A whole `endobj` or `endstrea` token where a wrong /Length stops is payload, not the
/// stream's end, unless the object ends there too: what follows is the end of the data,
/// another object's header, or `xref`, `trailer` or `startxref`. Otherwise the search for
/// `endstream` finds the whole payload.
#[test]
fn an_end_marker_inside_the_payload_does_not_end_the_stream() {
for payload in ["endobj more", "endstrea more", "endstrea endobj more", "endobj 12 more"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {payload}\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {payload}").as_bytes(), "{payload}");
}
// Where the object does end, the /Length is the stream, also with an `endstream` later.
for next in ["8 0 obj\n<< >>", "%c\n12 0 obj", "xref", "trailer", "startxref"] {
for marker in ["endobj", "endstrea\nendobj", "endstrea"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{marker}\n{next}\nstream\nWORLD\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{marker:?} then {next:?}");
}
}
}
/// Without an `endstream`, the /Length is trusted as written when it fits, also when it is
/// wrong, and an indirect one is resolved first; one that doesn't fit, a negative one and one
/// too large for any file are errors rather than wrapped into a short read.
#[test]
fn a_stream_without_endstream_takes_its_length_only_when_it_fits() {
let stream = |length: &str, tail: &str| format!("7 0 obj\n<< /Length {length} >>\nstream\nHELLO{tail}");
let raw = |pdf: &str, lookup: &dyn Fn(ObjRef) -> Option<i64>| match parse_indirect(pdf.as_bytes(), 0, lookup) {
Ok((_, Object::Stream(s))) => Ok(s.raw.to_vec()),
Ok((_, other)) => panic!("{pdf:?} is {other:?}"),
Err(e) => Err(e),
};
let none = |_: ObjRef| None;
// Wrong but fitting: the bytes it gives, not the bytes up to `endstrea`.
assert_eq!(raw(&stream("3", "\nendstrea\nendobj\n"), &none).unwrap(), b"HEL");
// Exactly to the end of the file, and nothing at all.
assert_eq!(raw(&stream("5", ""), &none).unwrap(), b"HELLO");
assert_eq!(raw(&stream("0", "\nendstrea\nendobj\n"), &none).unwrap(), b"");
// Indirect, resolved through the lookup.
let lookup = |r: ObjRef| (r == ObjRef::new(9, 0)).then_some(4);
assert_eq!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &lookup).unwrap(), b"HELL");
assert!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &none).is_err(), "an unresolved /Length");
// One past the end, negative, the largest integer and one beyond it.
for length in ["6", "-1", "-9223372036854775808", "9223372036854775807", "18446744073709551621"] {
assert!(raw(&stream(length, ""), &none).is_err(), "/Length {length}");
}
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
+71
View File
@@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun
// No qpdf check here: qpdf counts every offset from the header in a file with bytes before
// it, so it already reconstructs this input before anything is appended.
}
/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2
/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4),
/// both through indirect objects that nothing else references.
fn pdf2_fixture() -> Vec<u8> {
Pdf::new()
.version("2.0")
.obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>")
.obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>")
.stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET")
.obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>")
.obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>")
.obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>")
.obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>")
.build()
}
/// What `pdf2_fixture` declares, read back through the catalog.
fn assert_dpart_and_namespaces(doc: &Document) {
let catalog = doc.get(doc.root().unwrap());
let catalog = catalog.as_dict().unwrap();
let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot"));
let root = root.as_dict().unwrap();
let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept"));
let node = node.as_dict().unwrap();
assert_eq!(node.name(b"Type"), Some(&b"DPart"[..]));
// Both ends of the part are still the one page, found through /Pages (references are
// renumbered by a full save, so they are compared inside this document).
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid");
assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page");
assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page");
assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some());
let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap());
let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept"));
let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]);
let namespace = namespace.as_dict().unwrap();
assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn");
assert!(namespace.get(b"Schema").is_some(), "/Schema is retained");
}
#[test]
fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() {
let original = Arc::new(pdf2_fixture());
let doc = Document::open(original.clone()).unwrap();
assert_dpart_and_namespaces(&doc);
for object_streams in [true, false] {
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
let full = write_full(&doc, &opts).unwrap();
let back = Document::open(Arc::new(full.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_dpart_and_namespaces(&back);
// A second generation, rewritten from the rewritten file.
let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap();
assert!(second.repair_log().is_empty(), "{:?}", second.repair_log());
assert_dpart_and_namespaces(&second);
}
// An incremental save of an unrelated edit leaves the file's bytes, and so both, alone.
let mut edited = Document::open(original.clone()).unwrap();
edited.add(Object::Int(1));
let updated = write_incremental(&edited, &SaveOptions::default()).unwrap();
assert!(updated.starts_with(&original));
let reopened = Document::open(Arc::new(updated)).unwrap();
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
assert_dpart_and_namespaces(&reopened);
}
+5 -3
View File
@@ -19,10 +19,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
`from_images`. The largest page side is still capped at 14,400 points;
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica
can't draw is refused, naming the line and the character, never set as `?`.
can't draw is set in the bundled Inter face instead (Latin, Greek and Cyrillic letters), with
the same page, margins, wrapping and form feeds; text Inter can't draw either is refused,
naming the line and the character, never set as `?`.
- `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents
(comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first
line says so (`ESCAPED_NOTE`).
(comment summaries, compare reports): text Inter draws is set in it; otherwise the characters
Helvetica can't draw are written as `[U+XXXX]` and the first line says so (`ESCAPED_NOTE`).
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
+45 -24
View File
@@ -5,9 +5,11 @@
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize};
mod extract;
mod text_font;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
use pdfcraft_fonts::{literal, win_ansi, wrap};
use pdfcraft_fonts::wrap_with;
use text_font::TextFont;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CreateError {
@@ -281,7 +283,8 @@ fn jp2_boxes(b: &[u8]) -> Vec<([u8; 4], &[u8])> {
},
n => (8, n),
};
let end = i.saturating_add(len as usize).min(b.len());
// `len as usize` would truncate on 32-bit targets and put the end before the payload.
let end = i.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(b.len());
if len < head as u64 || end <= i {
break;
}
@@ -428,7 +431,13 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
// The decoded size comes from the header alone, so a file of a few bytes can claim any
// size: keep the ceiling, and ask for the memory fallibly (a failed allocation would abort
// the whole app, and no `catch_unwind` stops that).
let size = reader.output_buffer_size().filter(|n| *n <= MAX_DECODED_BYTES).ok_or_else(|| bad("image too large".into()))?;
// Flate expands at most 1032:1 and decoding at most 32:1 more (1-bit palette to RGBA), so a
// file cannot honestly hold more than that: refuse a header the bytes cannot back before
// zero-filling a buffer of its size. The slack keeps tiny real images out of the check.
let size = reader
.output_buffer_size()
.filter(|n| *n <= MAX_DECODED_BYTES && *n <= bytes.len().saturating_mul(PNG_MAX_EXPANSION).saturating_add(PNG_SLACK))
.ok_or_else(|| bad("image too large".into()))?;
let mut buf = Vec::new();
buf.try_reserve_exact(size).map_err(|_| bad("not enough memory for this image".into()))?;
buf.resize(size, 0);
@@ -646,6 +655,11 @@ const MAX_GIF_PIXELS: u64 = 1 << 24;
/// twice over.
const MAX_DECODED_BYTES: usize = 2 << 30;
/// Most a PNG's decoded size can exceed its file size: Flate's 1032:1 times the 32:1 of
/// expanding a 1-bit palette to RGBA, plus a fixed allowance for small files.
const PNG_MAX_EXPANSION: usize = 1032 * 32;
const PNG_SLACK: usize = 1 << 20;
/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until
/// they are embedded, and many small directories can share one compressed strip. Saturates to the
/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`.
@@ -900,14 +914,20 @@ fn turkish_code_page(bytes: &[u8]) -> bool {
}
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't
/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and
/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead.
/// draw (outside Western European) is set in the bundled Inter face instead, whole, when Inter
/// draws all of it (Latin, Greek and Cyrillic letters); anything else is refused with
/// [`CreateError::Invalid`] naming the line and the first character neither face draws. Text
/// PdfCraft generates goes through [`from_text_escaped`] instead.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
// Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`.
if let Some((line, c)) = first_undrawable_line(text) {
return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c)));
// Helvetica in WinAnsiEncoding draws it: set as it always was.
if first_undrawable_line(text).is_none() {
return set_text(title, text, page, font_size, false);
}
set_text(title, text, page, font_size)
// Otherwise the bundled face, or a refusal with where it is, never `?`.
if let Some((line, c)) = text_font::first_missing(text) {
return Err(CreateError::Invalid(text_font::undrawable_message(line, c)));
}
set_text(title, &text.chars().map(text_font::drawn_as).collect::<String>(), page, font_size, true)
}
/// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and
@@ -921,10 +941,15 @@ fn first_undrawable_line(text: &str) -> Option<(usize, char)> {
pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points.";
/// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare
/// report), which may quote any language. Each character Helvetica can't draw is written as
/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says
/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way.
/// report), which may quote any language. Text the bundled Inter face draws all of is set as
/// [`from_text`] sets it, nothing escaped. Otherwise each character Helvetica can't draw is
/// written as `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first
/// line says so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written
/// that way.
pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> {
if first_undrawable_line(text).is_some() && text_font::first_missing(text).is_none() {
return Ok((set_text(title, &text.chars().map(text_font::drawn_as).collect::<String>(), page, font_size, true)?, 0));
}
let mut escaped = 0usize;
let mut out = String::with_capacity(text.len());
for c in text.chars() {
@@ -939,29 +964,25 @@ pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f
out = format!("{ESCAPED_NOTE}\n\n{out}");
}
// The title is a text string in /Info, which holds any character.
Ok((set_text(title, &out, page, font_size)?, escaped))
Ok((set_text(title, &out, page, font_size, false)?, escaped))
}
fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
/// `text` set on pages of `page` size, in Helvetica or, with `inter`, the bundled Inter face.
fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64, inter: bool) -> Result<Document, CreateError> {
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
let (w, h) = page;
let margin = 72.0;
let line_h = size * 1.25;
let width = (w - 2.0 * margin).max(36.0);
let mut doc = Document::new_empty();
let font = if inter { TextFont::inter(&mut doc)? } else { TextFont::helvetica(&mut doc) };
let lines: Vec<String> = text
.replace("\r\n", "\n")
.replace('\t', " ")
.split('\u{c}')
.flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string())))
.flat_map(|p| wrap_with(p, size, width, |s, size| font.width(s, size)).into_iter().chain(std::iter::once("\u{c}".to_string())))
.collect();
let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1);
let mut doc = Document::new_empty();
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let fr = doc.add(font);
let mut tree = Pages::of(&doc)?;
let mut page_lines: Vec<Vec<String>> = vec![Vec::new()];
for l in lines.iter().take(lines.len().saturating_sub(1)) {
@@ -978,12 +999,12 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result
for lines in page_lines {
let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes();
for l in lines {
c.extend(literal(&win_ansi(&l)));
c.extend(font.show(&l)?);
c.extend_from_slice(b" Tj T*\n");
}
c.extend_from_slice(b"ET\n");
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(fr));
fonts.set(b"F1".to_vec(), Object::Ref(font.font()));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
tree.add(&mut doc, w, h, res, Some(c));
+101 -1
View File
@@ -163,7 +163,7 @@ fn text_keeps_leading_spaces_and_tabs() {
fn text_helvetica_cannot_draw_is_refused_or_escaped() {
let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err();
let CreateError::Invalid(msg) = &err else { panic!("{err:?}") };
assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}");
assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Cyrillic letters"), "{msg}");
// Western European text, tabs, CRLF and form feeds included, still converts.
from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap();
let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap();
@@ -183,6 +183,99 @@ fn text_helvetica_cannot_draw_is_refused_or_escaped() {
assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX"));
}
/// The decoded content of each page.
fn page_contents(doc: &Document) -> Vec<String> {
pages(doc)
.iter()
.map(|p| {
let Object::Stream(s) = &*doc.resolve(p.get(b"Contents").unwrap()) else { panic!("no content stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
})
.collect()
}
/// The text each page shows, read back through its font's `ToUnicode`, one line per `Tj`.
fn page_text(doc: &Document, page: &Dict) -> Vec<String> {
let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap();
let font = doc.resolve(fonts.get(b"F1").unwrap()).as_dict().cloned().unwrap();
let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font);
let Object::Stream(s) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("no content stream") };
let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
content
.split("> Tj")
.filter_map(|part| part.rsplit_once('<').map(|(_, hex)| hex))
.map(|hex| {
let bytes: Vec<u8> = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect();
metrics.decode(&bytes)
})
.collect()
}
const PANGRAM: &str = "Жълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон.";
/// Cyrillic text was refused (Helvetica can't draw it). Text with letters Helvetica lacks is set
/// in the bundled Inter face when that draws all of it, its Latin lines too, with Helvetica's
/// page, margins, line height, wrapping, tabs and form feeds; it reads back as typed.
#[test]
fn text_with_cyrillic_is_set_in_the_bundled_face() {
let long: String = (0..120).map(|i| format!("Ред {i}: {PANGRAM}\n")).collect();
let text = format!("{long}\tТаб, „кавички“, № 5 и Ελληνικά\u{c}Нова страница");
let doc = reopen(&from_text("бележки", &text, LETTER, 11.0).unwrap());
let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect();
assert!(fonts.iter().any(|f| f == b"Inter") && !fonts.iter().any(|f| f == b"Helvetica"), "{fonts:?}");
let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count();
assert_eq!(programs, 1, "one copy of the face for every page");
let p = pages(&doc);
let contents = page_contents(&doc);
assert!(p.len() >= 4, "{} pages", p.len());
assert_eq!(media(&p[0]), [0.0, 0.0, 612.0, 792.0]);
// The same page set-up as Helvetica's: 11 pt, 1.25 line height, 1-inch margins.
assert!(contents[0].starts_with("BT /F1 11 Tf 13.750 TL 72 709.000 Td\n<"), "{}", contents[0]);
assert!(contents.iter().all(|c| !c.contains('?') && !c.contains("[U+")));
// Each line fits between the margins in Inter's widths, and long lines wrap.
let lines: Vec<String> = p.iter().flat_map(|page| page_text(&doc, page)).collect();
assert!(lines.len() > 120, "{} lines", lines.len());
assert!(lines.iter().all(|l| pdfcraft_fonts::inter_width(l, 11.0) <= 612.0 - 144.0 + 1e-6), "{lines:?}");
let joined = lines.join(" ");
assert!(joined.contains("Ред 0: Жълтата дюля") && joined.contains("Ред 119:"), "{joined}");
assert!(joined.contains(" Таб, „кавички“, № 5 и Ελληνικά"), "a tab is four spaces: {joined}");
assert_eq!(page_text(&doc, p.last().unwrap()), ["Нова страница"], "a form feed starts a page");
// As many lines on a page as in Helvetica.
let latin: String = (0..120).map(|i| format!("Row {i}\n")).collect();
let cyrillic: String = (0..120).map(|i| format!("Ред {i}\n")).collect();
let per_page =
|text: &str| page_contents(&from_text("t", text, LETTER, 11.0).unwrap()).iter().map(|c| c.matches("Tj T*").count()).collect::<Vec<_>>();
assert_eq!(per_page(&cyrillic), per_page(&latin));
}
/// Text the bundled face can't draw all of keeps the Helvetica way: refused, naming the first
/// character neither face draws, or, for text PdfCraft generates, escaped as before. Text it draws
/// all of is set in it with nothing escaped, and Western European text stays in Helvetica.
#[test]
fn text_the_bundled_face_cannot_draw_is_refused_or_escaped_as_before() {
let err = from_text("t", "Бележка\nПодпис: 签名", LETTER, 11.0).unwrap_err();
let CreateError::Invalid(msg) = &err else { panic!("{err:?}") };
assert!(msg.contains("(line 2)") && msg.contains("\"签\" (U+7B7E)"), "{msg}");
let (doc, escaped) = from_text_escaped("r", "Бележка: 签名", LETTER, 11.0).unwrap();
assert_eq!(escaped, 9, "every character Helvetica can't draw, as before");
let shown = page_contents(&reopen(&doc)).join("\n");
assert!(shown.contains(&format!("({ESCAPED_NOTE}) Tj")) && shown.contains("([U+0411][U+0435]"), "{shown}");
let (doc, escaped) = from_text_escaped("Обобщение на коментарите", &format!("Коментар: {PANGRAM}\nPage 2"), LETTER, 11.0).unwrap();
assert_eq!(escaped, 0);
let doc = reopen(&doc);
let shown = page_contents(&doc).join("\n");
assert!(!shown.contains("[U+") && !shown.contains(ESCAPED_NOTE), "{shown}");
assert_eq!(page_text(&doc, &pages(&doc)[0]), [format!("Коментар: {PANGRAM}"), "Page 2".to_string()]);
// A soft hyphen, which Inter lacks, is drawn as the hyphen Helvetica draws for it.
let doc = reopen(&from_text("t", "при\u{ad}мер", LETTER, 11.0).unwrap());
assert_eq!(page_text(&doc, &pages(&doc)[0]), ["при-мер"]);
// Western European text is still set in Helvetica, exactly as before.
let doc = from_text("t", "Café 5€ ™", LETTER, 11.0).unwrap();
let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect();
assert_eq!(fonts, [b"Helvetica".to_vec()]);
}
fn image_of(doc: &Document, page: usize) -> Dict {
let p = &pages(doc)[page];
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
@@ -316,6 +409,13 @@ fn a_tiny_png_claiming_huge_dimensions_is_refused_before_allocating() {
}
}
#[test]
fn a_small_png_claiming_a_large_frame_is_refused_before_zero_filling() {
// 100 MP RGBA = 400 MB is under the 2 GiB ceiling, but 68 bytes cannot hold it.
let r = from_images(&[("big.png".into(), png_claiming(10_000, 10_000))]);
assert!(matches!(&r, Err(CreateError::Image(_, m)) if m.contains("too large")));
}
/// Likewise a PNG: the png crate's limits don't cover the frame buffer, which its header sizes
/// (60000x60000 RGBA is 14.4 GB). Refused before anything is allocated, not after.
#[test]
+108
View File
@@ -0,0 +1,108 @@
//! The font a text document is set in: standard Helvetica, or, for text Helvetica can't draw, the
//! bundled Inter face (`pdfcraft_fonts::document_latin_fonts`), which every build has and which
//! carries the Latin, Greek and Cyrillic letters WinAnsiEncoding lacks. Text Helvetica draws is
//! set in Helvetica exactly as before.
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use crate::CreateError;
/// The bundled face, Regular.
fn face() -> Option<&'static pdfcraft_fonts::CraftFont> {
pdfcraft_fonts::document_latin_fonts(false).into_iter().next()
}
/// Line breaks, tabs and form feeds lay the text out; they are not drawn.
fn layout(c: char) -> bool {
matches!(c, '\n' | '\r' | '\t' | '\u{c}')
}
/// The character the bundled face draws for `c`: a soft hyphen as a hyphen, as Helvetica draws it
/// (Inter has no glyph for it).
pub(crate) fn drawn_as(c: char) -> char {
if c == '\u{ad}' { '-' } else { c }
}
/// The line (1-based, lines split at `\n`) and character of the first one the bundled face can't
/// draw, or `None` when it draws all of `text`.
pub(crate) fn first_missing(text: &str) -> Option<(usize, char)> {
let face = face();
let draws = |s: &str| !s.chars().any(char::is_control) && face.is_some_and(|f| pdfcraft_fonts::embedding::covers(f.bytes, s));
let mut buf = [0u8; 4];
text.split('\n').enumerate().find_map(|(i, line)| {
// A whole line at once; the character only for a line the face can't draw.
let drawn: String = line.chars().filter(|c| !layout(*c)).map(drawn_as).collect();
if draws(&drawn) {
return None;
}
line.chars().find(|&c| !layout(c) && !draws(drawn_as(c).encode_utf8(&mut buf))).map(|c| (i + 1, c))
})
}
/// The refusal for text with `c` on `line`, which neither Helvetica nor the bundled face can draw.
pub(crate) fn undrawable_message(line: usize, c: char) -> String {
format!(
"neither Helvetica nor the bundled Inter face can draw \"{}\" (U+{:04X}) in the text (line {line}); only Latin, Greek and Cyrillic letters can be written there for now",
c.escape_debug(),
u32::from(c)
)
}
/// The font a text document is set in.
pub(crate) struct TextFont {
font: ObjRef,
/// The bundled face's metrics; `None` for Helvetica.
inter: Option<Metrics>,
}
impl TextFont {
/// Standard Helvetica in WinAnsiEncoding, added to `doc`.
pub(crate) fn helvetica(doc: &mut Document) -> Self {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
Self { font: doc.add(font), inter: None }
}
/// The bundled face, embedded in `doc` once for every page.
pub(crate) fn inter(doc: &mut Document) -> Result<Self, CreateError> {
let face = face().ok_or_else(|| CreateError::Invalid("this build has no bundled Latin, Greek and Cyrillic face".into()))?;
let mut fonts = Dict::new();
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(CreateError::Invalid)?;
let font = fonts.reference(name.as_bytes()).ok_or_else(|| CreateError::Invalid("the bundled face could not be added".into()))?;
let dict = doc.dict(&Object::Ref(font)).ok_or_else(|| CreateError::Invalid("the bundled face could not be read".into()))?;
Ok(Self { font, inter: Some(Metrics::from_dict(doc, &dict)) })
}
/// The font object every page names.
pub(crate) fn font(&self) -> ObjRef {
self.font
}
/// The advance of `s` at `size`; characters the face lacks count as nothing ([`Self::show`]
/// refuses them).
pub(crate) fn width(&self, s: &str, size: f64) -> f64 {
match &self.inter {
None => helvetica_width(s, size),
Some(m) => m.encode(s).map_or(0.0, |codes| m.codes(&codes).iter().map(|(code, _)| m.width(*code)).sum::<f64>() * size),
}
}
/// The string operand that shows `line`. Helvetica writes what WinAnsiEncoding can't hold as
/// `?`, so its callers check first; the bundled face refuses a character it lacks.
pub(crate) fn show(&self, line: &str) -> Result<Vec<u8>, CreateError> {
let Some(m) = &self.inter else { return Ok(literal(&win_ansi(line))) };
let codes = m.encode(line).ok_or_else(|| CreateError::Invalid(format!("the bundled Inter face can't draw \"{}\"", line.escape_debug())))?;
let mut out = Vec::with_capacity(codes.len().saturating_mul(2).saturating_add(2));
out.push(b'<');
for b in codes {
out.extend_from_slice(format!("{b:02X}").as_bytes());
}
out.push(b'>');
Ok(out)
}
}
+74 -17
View File
@@ -4,7 +4,8 @@
//! Each visible annotation's normal appearance (for check boxes and radio buttons, the one its
//! `/AS` selects) is drawn into the page with the matrix of ISO 32000-2 §12.5.5 (Algorithm 8.1:
//! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden
//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened
//! annotations and replies are left alone (a reply is not shown on the page: [`is_reply`]);
//! links are kept (they have no ink and stay clickable). Flattened
//! comments take their pop-ups and replies with them; flattened widgets are removed from the
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
//! as it is, so no comment or field value is lost with nothing in its place.
@@ -19,6 +20,21 @@ const HIDDEN: i64 = 2;
const NO_ROTATE: i64 = 16;
const NO_VIEW: i64 = 32;
/// A reply of type R: a markup annotation with an `/IRT` to the annotation it answers and an
/// `/RT` other than `/Group` (R is the default, so a missing, unknown or malformed `/RT` reads as
/// R, as the comment list reads it). It is shown in its comment's thread, never on the page
/// ("Interactive PDF processors shall not display replies to an annotation individually", ISO
/// 32000-2 Table 172), so flattening neither draws it nor builds it an appearance; it goes with
/// its comment. A `/RT /Group` member is part of its group's look and is flattened as any other
/// annotation. `/IRT` is a markup annotation's entry: a widget, link or pop-up with one is no
/// reply, and is flattened (or kept) as it would be without. Keep in step with hayro-interpret's
/// PdfCraft patch (81).
fn is_reply(doc: &Document, d: &Dict) -> bool {
!matches!(d.name(b"Subtype"), Some(b"Widget" | b"Link" | b"Popup"))
&& d.get(b"IRT").is_some_and(|o| doc.resolve(o).as_dict().is_some())
&& d.get(b"RT").map(|o| doc.resolve(o)).as_deref().and_then(Object::as_name) != Some(b"Group")
}
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
let o = doc.resolve(o?);
o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect()
@@ -319,7 +335,8 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
}
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) {
// A reply is never drawn into the page (`is_reply`), so its text can't bake in a `?`.
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) || is_reply(doc, &d) {
continue;
}
let widget = d.name(b"Subtype") == Some(b"Widget");
@@ -346,13 +363,60 @@ fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retain
continue;
}
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
// A reply is not shown on the page (`is_reply`), so it gets no appearance either.
if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, &d) || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw fails here and is kept below, so the comment is not
// deleted with nothing drawn in its place. (Text the standard fonts can't draw was
// refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
}
/// Adds to `gone` and `gone_inline` the pop-ups and replies of what is in them, at any depth of
/// the thread and in any order of `/Annots`, passing by the entries `stays` keeps. What points at
/// each annotation (a pop-up by `/Parent`, a reply by `/IRT`) is gathered once, then followed
/// from what went. `/Annots` should hold references (ISO 32000-2 Table 31), but a pop-up or
/// reply written inline still goes with what it points at; nothing can point at it in turn.
fn take_threads(
doc: &Document,
list: &[Object],
gone: &mut HashSet<ObjRef>,
gone_inline: &mut HashSet<usize>,
stays: impl Fn(usize, &Object) -> bool,
) {
let mut pointing: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
let mut pointing_inline: Vec<(ObjRef, usize)> = Vec::new();
for (k, entry) in list.iter().enumerate() {
if stays(k, entry) {
continue;
}
let r = entry.as_ref();
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { continue };
let popup_of = d.reference(b"Parent").filter(|_| d.name(b"Subtype") == Some(b"Popup"));
// Only a reply goes with what it answers: a `/RT /Group` member was drawn (and went) or
// stays, hidden or undrawable, as any annotation.
let reply_to = d.reference(b"IRT").filter(|_| is_reply(doc, d));
for p in popup_of.into_iter().chain(reply_to) {
match r {
Some(r) => pointing.entry(p).or_default().push(r),
None => pointing_inline.push((p, k)),
}
}
}
let mut follow: Vec<ObjRef> = gone.iter().copied().collect();
while let Some(p) = follow.pop() {
for &r in pointing.get(&p).into_iter().flatten() {
if gone.insert(r) {
follow.push(r);
}
}
}
gone_inline.extend(pointing_inline.into_iter().filter(|(p, _)| gone.contains(p)).map(|(_, k)| k));
}
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
/// success, so a page refused part way leaves the pages before it as they were.
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
@@ -431,7 +495,8 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
continue;
}
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & (HIDDEN | NO_VIEW) != 0 {
// Not on the page, so not drawn into it; a reply goes with its comment (below).
if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, d) {
continue;
}
let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
@@ -491,19 +556,11 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
continue;
}
// Pop-ups and replies of what was flattened go too, except a reply that stayed because
// it could not be drawn: its content would go with nothing in its place.
for entry in &list {
let Some(r) = entry.as_ref() else { continue };
if undrawn.contains(&r) || retained.refs.contains(&r) {
continue;
}
let obj = doc.get(r);
let Some(d) = obj.as_dict() else { continue };
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| gone.contains(&t));
if points_at(b"Parent") && d.name(b"Subtype") == Some(b"Popup") || points_at(b"IRT") {
gone.insert(r);
}
}
// it could not be drawn (its content would go with nothing in its place) and what is
// retained with its thread.
take_threads(doc, &list, &mut gone, &mut gone_inline, |k, entry| {
entry.as_ref().is_some_and(|r| undrawn.contains(&r)) || retained.contains(k, entry)
});
let kept: Vec<Object> = list
.iter()
.enumerate()
+638
View File
@@ -0,0 +1,638 @@
//! Fix hairlines (Acrobat's Print production ▸ Fix Hairlines; Fix hairlines): strokes thinner
//! than a printer holds - zero-width lines, and lines whose width on the page, after every
//! transformation, is below a threshold - are given a chosen width. Page content and the Form
//! XObjects it draws are rewritten; Type 3 glyphs and tiling patterns on request.
//!
//! A form is rewritten for the scale and line width it is drawn under. One drawn under several
//! (or drawn by a page that is not being fixed) is rewritten as a copy for each use that needs
//! one, and the resources of what draws it point at the copy, so no other use changes.
//!
//! Type 3 glyphs and tiling patterns are each a content stream of their own, reached from the
//! resources of the page and of the forms it draws. They are rewritten in place, so a page
//! outside the run that uses one changes too; [`Fixed::elsewhere`] counts those.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, Name, ObjRef, Object, Stream};
use std::collections::{HashMap, HashSet};
use crate::page_streams::PageStreams;
use crate::{EditError, check, n, page_list};
/// The stream `r` holds, if it is one.
fn stream_of(doc: &Document, r: ObjRef) -> Option<Stream> {
match &*doc.get(r) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
/// What counts as a hairline and what it becomes, in points on the page.
#[derive(Clone, Debug, PartialEq)]
pub struct Hairlines {
/// Lines narrower than this (points, after the page's transformations) are replaced;
/// zero-width lines always are. Acrobat's default is 0.003 in.
pub narrower_than: f64,
/// The width they are given (points on the page). Acrobat's default is 0.01 in.
pub width: f64,
/// Also the strokes inside Type 3 font glyphs.
pub type3: bool,
/// Also the strokes inside tiling patterns.
pub patterns: bool,
}
impl Default for Hairlines {
fn default() -> Self {
Self { narrower_than: 0.003 * 72.0, width: 0.01 * 72.0, type3: false, patterns: false }
}
}
impl Hairlines {
/// The widest either width may be, in points: past this it is no hairline.
pub const MAX_POINTS: f64 = 10.0;
/// Whether both widths are numbers a run accepts: a width above 0, a threshold of 0 or
/// more, neither past [`Self::MAX_POINTS`].
pub fn in_range(&self) -> bool {
(0.0..=Self::MAX_POINTS).contains(&self.narrower_than) && self.width > 0.0 && self.width <= Self::MAX_POINTS
}
}
/// What a run changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Fixed {
/// Strokes given a new width.
pub lines: usize,
/// Pages on which something changed.
pub pages: usize,
/// Type 3 glyphs and tiling patterns changed that a page outside the run, or an
/// annotation's appearance, also uses: what those draw changed too.
pub elsewhere: usize,
}
/// The uniform scale a matrix applies to lengths (the geometric mean of its two axes).
fn scale_of(m: [f64; 6]) -> f64 {
(m[0] * m[3] - m[1] * m[2]).abs().sqrt()
}
fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
// a then b.
[
a[0] * b[0] + a[1] * b[2],
a[0] * b[1] + a[1] * b[3],
a[2] * b[0] + a[3] * b[2],
a[2] * b[1] + a[3] * b[3],
a[4] * b[0] + a[5] * b[2] + b[4],
a[4] * b[1] + a[5] * b[3] + b[5],
]
}
/// The graphics state a stream leaves for the next one on the same page: CTM and line width,
/// with the q/Q stack, and whether a path is still being built.
#[derive(Clone, Debug)]
struct State {
ctm: [f64; 6],
width: f64,
stack: Vec<([f64; 6], f64)>,
in_path: bool,
}
impl State {
/// A stream drawn under `base` with line width `width` in effect.
fn new(base: [f64; 6], width: f64) -> Self {
Self { ctm: base, width, stack: Vec::new(), in_path: false }
}
}
/// All a form's rewrite depends on: the scale it is drawn under and the line width in effect
/// at its `Do` (as bits, so it can be a map key).
type Key = (u64, u64);
fn key_of(ctm: [f64; 6], width: f64) -> Key {
(scale_of(ctm).to_bits(), width.to_bits())
}
/// A Form XObject a stream draws: the resource name it is drawn by, the object, and the CTM
/// and line width at its `Do`.
struct Drawn {
name: Name,
r: ObjRef,
ctm: [f64; 6],
width: f64,
}
/// The names a stream's `Do`s are given instead, by the name they had and the key they are
/// drawn under.
type Renames = HashMap<(Name, Key), Name>;
/// How deep forms drawing forms are followed, and how many form rewrites one run makes; past
/// either, a form is drawn as it is.
const MAX_DEPTH: usize = 32;
const MAX_FORMS: usize = 4096;
/// The width `/name gs` sets, when its ExtGState has an `/LW`.
fn line_width_of(doc: &Document, gstates: Option<&Dict>, name: &[u8]) -> Option<f64> {
let gs = doc.resolve(gstates?.get(name)?);
let lw = gs.as_dict()?.get(b"LW")?;
doc.resolve(lw).as_f64().filter(|w| w.is_finite())
}
/// Rewrite one content stream drawn under `state` (the CTM when it starts, scale only
/// matters), leaving in it the state at the stream's end. A thin stroke gets a `w` of its own
/// before its path begins (graphics-state operators are not allowed inside a path) and the
/// old width back after it is painted, so nothing else drawn changes width.
/// `forms` collects the Form XObjects it draws, with the CTM and width each is drawn under,
/// for the caller to rewrite in turn; a `Do` listed in `renames` draws the name given there.
/// Returns the new bytes when anything changed, and the count.
fn rewrite(
data: &[u8],
state: &mut State,
h: &Hairlines,
resources: Option<&Dict>,
doc: &Document,
forms: &mut Vec<Drawn>,
renames: &Renames,
) -> (Option<Vec<u8>>, usize) {
let parsed = parse(data);
let mut ops: Vec<Op> = parsed.ops;
let mut fixed = 0usize;
let mut changed = false;
// Where in `out` the path being built began (None when it began in an earlier stream).
let mut path_start: Option<usize> = None;
let mut out: Vec<Op> = Vec::with_capacity(ops.len() + 8);
let dict_of = |key: &[u8]| resources.and_then(|r| r.get(key)).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
let xobjects = dict_of(&b"XObject"[..]);
let gstates = dict_of(&b"ExtGState"[..]);
for mut op in ops.drain(..) {
match op.op.as_slice() {
b"q" => {
state.stack.push((state.ctm, state.width));
if state.stack.len() > 512 {
state.stack.remove(0);
}
}
b"Q" => {
if let Some((c, w)) = state.stack.pop() {
state.ctm = c;
state.width = w;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
state.ctm = mul(m, state.ctm);
}
}
b"w" => {
if let Some([w]) = op.nums::<1>() {
state.width = w;
}
}
b"gs" => {
// An ExtGState's /LW sets the width as `w` does (so the width put back after
// a fixed stroke is the right one).
if let Some(Object::Name(name)) = op.operands.first()
&& let Some(w) = line_width_of(doc, gstates.as_ref(), name)
{
state.width = w;
}
}
b"m" | b"re" => {
if !state.in_path {
state.in_path = true;
path_start = Some(out.len());
}
}
b"Do" => {
if let Some(Object::Name(name)) = op.operands.first()
&& let Some(x) = xobjects.as_ref().and_then(|x| x.get(name))
&& let Some(r) = x.as_ref()
&& matches!(&*doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))
{
let name = name.clone();
if !renames.is_empty()
&& let Some(to) = renames.get(&(name.clone(), key_of(state.ctm, state.width)))
&& let Some(first) = op.operands.first_mut()
{
*first = Object::Name(to.clone());
changed = true;
}
forms.push(Drawn { name, r, ctm: state.ctm, width: state.width });
}
}
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => {
let scale = scale_of(state.ctm);
let on_page = state.width * scale;
let thin = state.width <= 0.0 || (scale > 0.0 && on_page < h.narrower_than);
// A path begun in an earlier stream cannot get its `w` before it here; it is
// left as it is rather than given an operator inside the path.
let at = match path_start {
Some(i) => Some(i),
None if !state.in_path => Some(out.len()),
None => None,
};
state.in_path = false;
path_start = None;
if thin
&& scale > 0.0
&& let Some(at) = at
{
let new_w = h.width / scale;
out.insert(at, Op::new("w", vec![Object::Real(new_w)]));
out.push(op);
out.push(Op::new("w", vec![Object::Real(state.width)]));
fixed += 1;
changed = true;
continue;
}
}
b"f" | b"F" | b"f*" | b"n" => {
state.in_path = false;
path_start = None;
}
_ => {}
}
out.push(op);
}
(changed.then(|| serialize_ops(&out)), fixed)
}
/// What a run keeps across pages.
struct Run {
/// Each form already rewritten under a key, and the object drawn for it there.
done: HashMap<(ObjRef, Key), ObjRef>,
/// The forms drawn where this run does not rewrite: they are never changed in place.
shared: HashSet<ObjRef>,
/// The forms already drawn as themselves: `true` when rewritten in place, `false` when
/// left as they were. Any other use that needs something else gets a copy.
used: HashMap<ObjRef, bool>,
/// Each form's stream as it was before the run.
original: HashMap<ObjRef, Stream>,
/// Form rewrites left.
budget: usize,
}
/// The XObjects (forms and images, and those the forms draw), tiling patterns and Type 3
/// glyphs that the pages not in `pages` use, and those any page's annotation appearances use
/// (this run does not rewrite them): rewriting one of those in place would change what they
/// show.
fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet<ObjRef> {
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut todo: Vec<Object> = Vec::new();
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
let Some(res) = res.map(|r| doc.resolve(r)) else { return };
let Some(res) = res.as_dict() else { return };
let dict_at = |key: &[u8]| res.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
for dict in [dict_at(b"XObject"), dict_at(b"Pattern")].into_iter().flatten() {
todo.extend(dict.iter().map(|(_, o)| o.clone()));
}
for (_, font) in dict_at(b"Font").iter().flat_map(Dict::iter) {
let font = doc.resolve(font);
if let Some(procs) = font.as_dict().and_then(|f| f.get(b"CharProcs")).map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
todo.extend(procs.iter().map(|(_, g)| g.clone()));
}
}
};
for (i, page) in page_list(doc).into_iter().enumerate() {
if !pages.contains(&i) {
xobjects_of(page.dict.get(b"Resources"), &mut todo);
}
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for a in annots {
let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue };
let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue };
for (_, v) in ap.iter() {
match &*doc.resolve(v) {
Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())),
_ => todo.push(v.clone()),
}
}
}
}
while let Some(o) = todo.pop() {
let Some(r) = o.as_ref() else { continue };
if !seen.insert(r) {
continue;
}
let res = match &*doc.get(r) {
Object::Stream(s) => s.dict.get(b"Resources").cloned(),
_ => None,
};
xobjects_of(res.as_ref(), &mut todo);
}
seen
}
/// A name for another copy drawn by `name` that `xobjects` does not hold yet.
fn fresh_name(xobjects: &Dict, name: &[u8]) -> Name {
let mut i = 1usize;
loop {
let mut candidate = name.to_vec();
candidate.extend_from_slice(format!("_hl{i}").as_bytes());
if !xobjects.contains(&candidate) {
return candidate;
}
i = i.saturating_add(1);
}
}
/// Rewrite one drawing context - a page's streams, or a form's - that starts in `start`, and
/// the forms it draws. Returns each stream's new bytes (`None` where it did not change), the
/// strokes fixed (in the forms too), and the `/XObject` dictionary for the context's resources
/// when its names must now point at other objects.
fn fix_context(
doc: &mut Document,
run: &mut Run,
h: &Hairlines,
data: &[Vec<u8>],
start: &State,
resources: Option<&Dict>,
depth: usize,
) -> (Vec<Option<Vec<u8>>>, usize, Option<Dict>) {
let none = Renames::new();
let mut drawn: Vec<Drawn> = Vec::new();
let mut state = start.clone();
let mut new: Vec<Option<Vec<u8>>> = Vec::with_capacity(data.len());
let mut fixed = 0usize;
for d in data {
let (bytes, count) = rewrite(d, &mut state, h, resources, doc, &mut drawn, &none);
fixed += count;
new.push(bytes);
}
if drawn.is_empty() {
return (new, fixed, None);
}
let Some(xo) = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else {
return (new, fixed, None);
};
// The object each (name, key) is drawn as, and each name's objects in order of first use.
let mut chosen: HashMap<(Name, Key), ObjRef> = HashMap::new();
let mut targets: Vec<(Name, Vec<ObjRef>)> = Vec::new();
for d in drawn {
let k = key_of(d.ctm, d.width);
if chosen.contains_key(&(d.name.clone(), k)) {
continue;
}
let (to, count) = fix_form(doc, run, h, d.r, d.ctm, d.width, depth + 1);
fixed += count;
chosen.insert((d.name.clone(), k), to);
match targets.iter_mut().find(|(name, _)| *name == d.name) {
Some((_, list)) => {
if !list.contains(&to) {
list.push(to);
}
}
None => targets.push((d.name, vec![to])),
}
}
// The first object keeps the name; each other one gets a name of its own, and the `Do`s
// drawing it are renamed.
let mut repointed = xo;
let mut changed = false;
let mut renames = Renames::new();
for (name, list) in &targets {
for (i, to) in list.iter().enumerate() {
let at = if i == 0 { name.clone() } else { fresh_name(&repointed, name) };
if repointed.get(&at).and_then(Object::as_ref) != Some(*to) {
repointed.set(at.clone(), Object::Ref(*to));
changed = true;
}
if i > 0 {
for ((was, k), t) in &chosen {
if was == name && t == to {
renames.insert((was.clone(), *k), at.clone());
}
}
}
}
}
if !renames.is_empty() {
// The same rewrite again, with the renamed `Do`s.
let mut state = start.clone();
let mut again: Vec<Drawn> = Vec::new();
new = data.iter().map(|d| rewrite(d, &mut state, h, resources, &*doc, &mut again, &renames).0).collect();
}
(new, fixed, changed.then_some(repointed))
}
/// The object `r` is drawn as under `ctm` with line width `width` in effect, rewritten for
/// them, and the strokes that rewrite fixed. It is `r` itself, rewritten in place, unless
/// something else draws `r` differently; then it is a copy.
fn fix_form(doc: &mut Document, run: &mut Run, h: &Hairlines, r: ObjRef, ctm: [f64; 6], width: f64, depth: usize) -> (ObjRef, usize) {
let k = key_of(ctm, width);
if let Some(&to) = run.done.get(&(r, k)) {
return (to, 0);
}
if depth > MAX_DEPTH || run.budget == 0 {
return (r, 0);
}
run.budget -= 1;
// Marked before rewriting, so a form that draws itself ends.
run.done.insert((r, k), r);
let s = match run.original.get(&r) {
Some(s) => s.clone(),
None => {
let Some(s) = stream_of(doc, r) else { return (r, 0) };
run.original.insert(r, s.clone());
s
}
};
let Ok(data) = s.decoded() else { return (r, 0) };
let matrix = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
<[f64; 6]>::try_from(v).ok()
});
let base = match matrix {
Some(m) => mul(m, ctm),
None => ctm,
};
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
let (new, fixed, xo) = fix_context(doc, run, h, std::slice::from_ref(&data), &State::new(base, width), res.as_ref(), depth);
let bytes = new.into_iter().next().flatten();
let to = if bytes.is_none() && xo.is_none() {
if run.used.get(&r) == Some(&true) {
// `r` was rewritten in place for another use; this one draws it as it was.
doc.add(Object::Stream(s))
} else {
run.used.insert(r, false);
r
}
} else {
let mut form = s;
if let Some(xo) = xo {
let mut res = res.unwrap_or_default();
res.set(b"XObject".to_vec(), Object::Dict(xo));
form.dict.set(b"Resources".to_vec(), Object::Dict(res));
}
if let Some(bytes) = bytes {
let mut dict = form.dict;
for key in [&b"Length"[..], b"Filter", b"DecodeParms"] {
dict.remove(key);
}
form = Stream::flate(dict, &bytes);
}
if run.shared.contains(&r) || run.used.contains_key(&r) {
doc.add(Object::Stream(form))
} else {
doc.set(r, Object::Stream(form));
run.used.insert(r, true);
r
}
};
run.done.insert((r, k), to);
(to, fixed)
}
/// Fix the hairlines on `pages` (0-based). Each page's content, and each Form XObject it draws
/// (for the scale and line width it is drawn under), is rewritten where a stroke is thinner
/// than `h.narrower_than` points on the page. A form drawn under more than one, or drawn by a
/// page not in `pages` or by an annotation's appearance, is rewritten as a copy for the uses
/// that need one, and the page's (or drawing form's) resources point at the copy; a resource
/// dictionary other pages share is not changed. Type 3 glyphs and tiling patterns are rewritten
/// in place, once, at a nominal scale (see `fix_in_resources`), and those other uses of them are
/// counted in [`Fixed::elsewhere`].
pub fn fix_hairlines(doc: &mut Document, pages: &[usize], h: &Hairlines) -> Result<Fixed, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if !h.in_range() {
return Err(EditError::Invalid(format!("the hairline widths must be numbers of points, above 0 and at most {}", Hairlines::MAX_POINTS)));
}
let mut result = Fixed::default();
let mut run =
Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages), used: HashMap::new(), original: HashMap::new(), budget: MAX_FORMS };
let mut done_glyphs: HashSet<ObjRef> = HashSet::new();
for &i in pages {
let Some(page) = page_list(doc).get(i).cloned() else { continue };
let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let mut page_fixed = 0usize;
// The page's streams, each rewritten on its own (state carries across them), and the
// forms they draw. A page with a stream that cannot be read is left alone.
if let Some(streams) = PageStreams::read(doc, &page.dict) {
let start = State::new([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 1.0);
let (new, fixed, xo) = fix_context(doc, &mut run, h, &streams.data, &start, resources.as_ref(), 0);
page_fixed += fixed;
streams.write(doc, page.obj, new)?;
if let Some(xo) = xo {
// This page's own resources point at the copies; a resource dictionary other
// pages share is not changed.
let mut res = resources.clone().unwrap_or_default();
res.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
}
}
if h.type3 || h.patterns {
for res in resources_below(doc, resources.as_ref()) {
page_fixed += fix_in_resources(doc, Some(&res), h, &mut done_glyphs, &run.shared, &mut result.elsewhere)?;
}
}
if page_fixed > 0 {
result.lines += page_fixed;
result.pages += 1;
}
}
Ok(result)
}
/// `resources` and the resources of every form they reach (a form that names none uses its
/// parent's), each once.
fn resources_below(doc: &Document, resources: Option<&Dict>) -> Vec<Dict> {
let mut all: Vec<Dict> = resources.into_iter().cloned().collect();
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut at = 0;
while let Some(res) = all.get(at).cloned() {
at += 1;
let Some(xo) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
for (_, x) in xo.iter() {
let Some(r) = x.as_ref() else { continue };
if !seen.insert(r) {
continue;
}
let Some(s) = stream_of(doc, r) else { continue };
if s.dict.name(b"Subtype") == Some(b"Form")
&& let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())
{
all.push(inner);
}
}
}
all
}
/// Strokes inside Type 3 glyph procedures and tiling patterns of `resources`, on request. Their
/// drawing scale is the font matrix or the pattern matrix (the text size and placement are
/// not known here, so the glyph's own scale stands in). They are rewritten in place, once per
/// run: a pattern's space is set by its matrix whatever CTM it is painted under, and a glyph
/// is measured at a nominal size, so there is no per-use scale to make a copy for. The forms
/// they draw are not followed. Each one changed that is in `shared` is added to `elsewhere`.
fn fix_in_resources(
doc: &mut Document,
resources: Option<&Dict>,
h: &Hairlines,
done: &mut HashSet<ObjRef>,
shared: &HashSet<ObjRef>,
elsewhere: &mut usize,
) -> Result<usize, EditError> {
let Some(res) = resources else { return Ok(0) };
let mut fixed = 0usize;
let mut targets: Vec<(ObjRef, [f64; 6])> = Vec::new();
if h.type3
&& let Some(fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
{
for (_, f) in fonts.iter() {
let fd = doc.resolve(f);
let Some(fd) = fd.as_dict() else { continue };
if fd.name(b"Subtype") != Some(b"Type3") {
continue;
}
let fm = fd.get(b"FontMatrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
<[f64; 6]>::try_from(v).ok()
});
// Glyph space → text space at a nominal 10 pt.
let base = fm.map(|m| mul(m, [10.0, 0.0, 0.0, 10.0, 0.0, 0.0])).unwrap_or([0.01, 0.0, 0.0, 0.01, 0.0, 0.0]);
if let Some(procs) = fd.get(b"CharProcs").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
for (_, g) in procs.iter() {
if let Some(r) = g.as_ref() {
targets.push((r, base));
}
}
}
}
}
if h.patterns
&& let Some(pats) = res.get(b"Pattern").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned())
{
for (_, p) in pats.iter() {
let Some(r) = p.as_ref() else { continue };
let Some(s) = stream_of(doc, r) else { continue };
if s.dict.int(b"PatternType") != Some(1) {
continue;
}
let pm = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
<[f64; 6]>::try_from(v).ok()
});
targets.push((r, pm.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])));
}
}
let none = Renames::new();
for (r, base) in targets {
if !done.insert(r) {
continue;
}
let Some(s) = stream_of(doc, r) else { continue };
let Ok(data) = s.decoded() else { continue };
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
let mut inner = Vec::new();
let (new, n_fixed) = rewrite(&data, &mut State::new(base, 1.0), h, res.as_ref(), doc, &mut inner, &none);
if let Some(bytes) = new {
let mut dict = s.dict.clone();
dict.remove(b"Length");
dict.remove(b"Filter");
dict.remove(b"DecodeParms");
doc.set(r, Object::Stream(Stream::flate(dict, &bytes)));
fixed += n_fixed;
*elsewhere += usize::from(n_fixed > 0 && shared.contains(&r));
}
}
let _ = n;
Ok(fixed)
}
+3
View File
@@ -64,6 +64,9 @@ pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod hairlines;
mod page_streams;
pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines};
pub mod added;
pub use added::{
Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content,
+69
View File
@@ -0,0 +1,69 @@
//! A page's content streams rewritten one at a time: each stream keeps its own dictionary
//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced.
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::EditError;
/// The decoded content streams of one page, in drawing order.
pub(crate) struct PageStreams {
/// Each stream's object and dictionary.
entries: Vec<(ObjRef, Dict)>,
/// Each stream's decoded data, in the same order.
pub(crate) data: Vec<Vec<u8>>,
}
impl PageStreams {
/// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or
/// cannot be decoded: rewriting the others would draw the page differently, so the page is
/// left alone. A page without content has no streams.
pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option<Self> {
let refs: Vec<Object> = match page_dict.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
let mut entries = Vec::with_capacity(refs.len());
let mut data = Vec::with_capacity(refs.len());
for e in &refs {
let r = e.as_ref()?;
let (dict, bytes) = match &*doc.get(r) {
Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?),
_ => return None,
};
data.push(bytes);
entries.push((r, dict));
}
Some(Self { entries, data })
}
/// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each
/// replacement is a new object with the old dictionary (minus its encoding), so a stream
/// another page shares is not touched. Returns whether anything changed.
pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec<Option<Vec<u8>>>) -> Result<bool, EditError> {
if new.iter().all(Option::is_none) {
return Ok(false);
}
let mut refs: Vec<Object> = Vec::with_capacity(self.entries.len());
for (i, (r, dict)) in self.entries.into_iter().enumerate() {
match new.get(i).and_then(Option::as_ref) {
Some(bytes) => {
let mut dict = dict;
for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] {
dict.remove(k);
}
refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes)))));
}
None => refs.push(Object::Ref(r)),
}
}
let contents = match <[Object; 1]>::try_from(refs) {
Ok([one]) => one,
Err(many) => Object::Array(many),
};
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(true)
}
}
+599 -12
View File
@@ -549,7 +549,7 @@ fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
fn flattening_draws_cloudy_squares_and_keeps_one_it_cannot_draw() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
let case = |extra: &str, drawn: usize, left: bool| {
@@ -571,10 +571,13 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment");
}
};
case("/BE << /S /C /I 2 >>", 0, true);
// A cloudy square is drawn (ISO 32000-2 Table 169); one whose /RD leaves no square inside
// its /Rect (Table 180: the sums "shall be less than" the width and height) cannot be, and stays.
case("/BE << /S /C /I 2 >>", 1, false);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30]", 0, true);
case("/BE << /S /S >>", 1, false);
case("/BE << /S /C /I 2 >> /F 2", 0, true);
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /F 2", 0, true);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /AP << /N 6 0 R >>", 1, false);
}
/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and
@@ -802,10 +805,12 @@ fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() {
assert_eq!(annots, Some(1), "the widget stays");
}
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
/// is flattened; the comment's pop-up still goes with it.
/// #1794, #5631: a reply this builder cannot draw goes with the comment it replies to, as its
/// pop-up does. A reply of type R is not shown on the page (ISO 32000-2 Table 172), so it has no
/// ink of its own to keep; before #5631 it stayed, the orphan of a comment that was gone. (A
/// `/RT /Group` member that cannot be drawn still stays: it is drawn on the page.)
#[test]
fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
fn flattening_takes_an_undrawable_reply_with_its_comment() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
@@ -813,15 +818,212 @@ fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /RD [60 30 60 30] /IRT 5 0 R >>",
"<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>",
"<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be");
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the reply is not");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "the comment, its reply and its pop-up are gone");
}
/// #5631: flattening draws no reply of type R into the page, whatever its appearance, and builds
/// none for one without (a note reply would get an icon); every reply goes with its comment, a
/// reply to a reply too, even listed before the reply it answers. A `/RT /Group` member is
/// drawn as any annotation.
#[test]
fn flattening_draws_no_reply_and_takes_the_thread_with_its_comment() {
let page = stream("", " ");
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
// The reply to the reply (8) is listed before the reply it answers (6).
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 8 0 R 6 0 R 7 0 R 9 0 R] >>",
&page,
// The comment (blue), its reply (red), its pop-up, a reply to the reply with no
// appearance, and a member of its group (green).
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 10 0 R >> /Popup 7 0 R >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /AP << /N 11 0 R >> >>",
"<< /Type /Annot /Subtype /Popup /Rect [300 500 400 600] /Parent 5 0 R >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply to the reply) /IRT 6 0 R /RT /R >>",
"<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 12 0 R >> >>",
&blue,
&red,
&green,
]);
let n = flatten(&mut doc, &[0], true, false).unwrap();
assert_eq!(n, 2, "the comment and the group member are drawn; the replies are not");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "the whole thread, its pop-up and the group member are gone");
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
drawn.sort_unstable();
assert_eq!(drawn, [10, 12], "the comment's and the member's appearances; not the reply's, and none built for the other");
}
/// #5632 review: a reply's text never blocks flattening (a reply is not drawn into the page, so
/// it can't bake in a `?`), and a `/RT /Group` member that flattening skipped (Hidden here)
/// stays when its primary is flattened: only replies go with what they answer.
#[test]
fn flattening_skips_replies_in_the_question_mark_check_and_keeps_skipped_group_members() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Square /Rect [10 10 60 40] /F 4 /AP << /N 8 0 R >> >>",
// A text-box reply whose text Helvetica can't draw, with no appearance.
"<< /Type /Annot /Subtype /FreeText /Rect [10 50 150 80] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /IRT 5 0 R >>",
// A hidden member of the square's group.
"<< /Type /Annot /Subtype /Circle /Rect [70 10 100 40] /F 6 /IRT 5 0 R /RT /Group /AP << /N 8 0 R >> >>",
&stream("/Type /XObject /Subtype /Form /BBox [0 0 50 30]", "0 0 1 rg 0 0 50 30 re f"),
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "flattened, the square drawn");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone");
assert_eq!(left, [7], "the reply went with the square; the hidden group member stays");
}
/// #5632 review: a thread three replies deep, listed in `/Annots` in reverse (the deepest reply
/// first, the comment last), goes whole with its comment, none of the replies drawn; a reply to a
/// `/RT /Group` member goes with that member, which is drawn.
#[test]
fn flattening_takes_a_thread_three_deep_in_reverse_order_and_a_group_members_reply() {
let page = stream("", " ");
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [8 0 R 7 0 R 6 0 R 5 0 R 9 0 R 10 0 R] >>",
&page,
// The comment (blue), and three replies, each to the one before, all with appearances (red).
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 11 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply 1) /IRT 5 0 R /AP << /N 12 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply 2) /IRT 6 0 R /RT /R /AP << /N 12 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [115 485 135 503] /Contents (Reply 3) /IRT 7 0 R /AP << /N 12 0 R >> >>",
// A member of the comment's group (green), and a reply to the member.
"<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 13 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /Contents (Reply to the member) /IRT 9 0 R /AP << /N 12 0 R >> >>",
&blue,
&red,
&green,
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "the comment and the group member are drawn; no reply is");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "the three replies and the member's reply go with what they answer");
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
drawn.sort_unstable();
assert_eq!(drawn, [11, 13], "the comment's and the member's appearances, not the replies'");
}
/// #5632 review: `/Annots` should hold references (ISO 32000-2 Table 31), but a reply or pop-up
/// written inline in it still goes with the comment it points at, as a referenced one does. One
/// pointing at a comment that stays, stays.
#[test]
fn flattening_takes_inline_replies_and_popups_with_their_comment() {
let page = stream("", " ");
let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R \
<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Inline reply) /IRT 5 0 R >> \
<< /Type /Annot /Subtype /Popup /Rect [200 400 300 500] /Parent 5 0 R >> \
<< /Type /Annot /Subtype /Text /F 28 /Rect [105 395 125 413] /Contents (Reply to a hidden note) /IRT 6 0 R >> 6 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 7 0 R >> >>",
// Hidden: not flattened, so it stays, and so does its inline reply.
"<< /Type /Annot /Subtype /Text /F 30 /Rect [100 400 120 418] /Contents (Hidden note) /AP << /N 7 0 R >> >>",
&blue,
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
let annots = doc.resolve(p.dict.get(b"Annots").unwrap());
let left: Vec<String> = annots
.as_array()
.unwrap()
.iter()
.map(|a| doc.resolve(a).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default())
.collect();
assert_eq!(left, ["Reply to a hidden note", "Hidden note"], "the inline reply and pop-up went with the comment");
}
/// #5632 review: replies whose `/IRT` runs in a loop (two replying to each other, one replying to
/// itself, and a square and a note each answering the other) end the flattening. None of them is
/// drawn: each is a reply. Those that answer nothing flattened stay; the comment beside them is
/// flattened and its reply goes with it.
#[test]
fn flattening_ends_on_replies_whose_irt_loops() {
let page = stream("", " ");
let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>",
&page,
// A comment and its reply.
"<< /Type /Annot /Subtype /Square /F 4 /Rect [100 500 120 518] /AP << /N 12 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /IRT 5 0 R /AP << /N 12 0 R >> >>",
// Two replies to each other.
"<< /Type /Annot /Subtype /Text /F 28 /Rect [200 500 220 518] /IRT 8 0 R /AP << /N 12 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /IRT 7 0 R /AP << /N 12 0 R >> >>",
// A reply to itself.
"<< /Type /Annot /Subtype /Text /F 28 /Rect [300 500 320 518] /IRT 9 0 R /AP << /N 12 0 R >> >>",
// A square and a note each answering the other: both are replies.
"<< /Type /Annot /Subtype /Square /F 4 /Rect [400 500 420 518] /IRT 11 0 R /AP << /N 12 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [405 495 425 513] /IRT 10 0 R /AP << /N 12 0 R >> >>",
&blue,
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
assert_eq!(left, [7, 8, 9, 10, 11], "the comment's reply went with it; the looping replies stay");
}
/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list and the
/// page read it, so replies with an unknown name or a string there are neither drawn nor kept
/// once their comment is flattened. A form field with a stray `/IRT` is no reply: it is flattened
/// as a field.
#[test]
fn flattening_reads_an_unknown_or_malformed_rt_as_a_reply_and_a_widget_with_irt_as_a_field() {
let page = stream("", " ");
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 9 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /RT /Answer /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply) /IRT 5 0 R /RT (Group) /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /F 4 /Rect [200 500 220 518] /IRT 5 0 R /AP << /N 11 0 R >> >>",
&blue,
&red,
&green,
]);
assert_eq!(flatten(&mut doc, &[0], true, true).unwrap(), 2, "the comment and the field are drawn; neither reply is");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "both replies went with the comment, and the field was flattened");
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
drawn.sort_unstable();
assert_eq!(drawn, [9, 11], "the comment's and the field's appearances, not the replies'");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
@@ -1704,6 +1906,30 @@ fn tracked_text_preserves_larger_word_gaps() {
}
}
/// LibreOffice stretches a justified line on both sides of each space glyph, so a gap that reads
/// as a space comes right before the space glyph it widens: one space, not two. A gap after the
/// space glyph, a space glyph with no gap before it, and two space glyphs after a gap read as
/// before.
#[test]
fn a_gap_before_a_space_glyph_reads_as_one_space() {
for show in ["[(Word) -250 ( next) -250 ( last)] TJ", "[(Word ) -250 (next ) -250 (last)] TJ", "[(Word) -250 (next) 0 ( last)] TJ"] {
let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last", "{show}");
}
// Only the first space glyph after the gap is the gap's: two real spaces stay two.
let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250 ( next)] TJ ET");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next");
// The same line split over operators: the gap ends one run, its space glyph starts the next.
let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250] TJ [( next) -250] TJ ( last) Tj ET");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last");
// Space glyphs on both sides of a break between runs, with no gap, are two spaces.
let doc = text_page("BT /F1 10 Tf 72 700 Td (Word ) Tj ( next) Tj ET");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next");
// Down a vertical column the same.
let doc = vertical_page("BT /V1 20 Tf 500 720 Td [<0041> 300 <00200042> 300 <00200043>] TJ ET");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "A B C");
}
#[test]
fn tracked_text_positions_do_not_count_character_spacing_twice() {
let expected = "TRACKED SAMPLE TEXT";
@@ -3014,6 +3240,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
}
/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words
/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph),
/// one font per word, and the line reads back whole and in place: one line, in its own font, so
/// editing it again replaces all of it (review on #2955), also when it starts with a substitute
/// word.
#[test]
fn only_the_words_a_subset_cannot_show_are_substituted() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let mut doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}");
let lines = text::text_lines(&doc, 0).unwrap();
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
assert_eq!(read, [("ab ba cab ba", "F2")]);
// Each word where its font puts it, the substituted `cab` included: no gap or overlap opens
// where the font changes (review on #2955).
let starts: Vec<f64> = lines[0].ink.iter().map(|b| b[0]).collect();
assert_eq!(starts.len(), 4, "{:?}", lines[0].ink);
for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) {
assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them");
}
assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink);
for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] {
text::replace_line(&mut doc, 0, 0, text).unwrap();
doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})");
}
}
/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine`
/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader
/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content
/// doesn't grow from one edit to the next.
#[test]
fn replacing_a_mixed_font_line_again_replaces_its_sequence() {
let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET");
let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count();
let mut sizes = Vec::new();
for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] {
text::replace_line(&mut doc, 0, 0, text).unwrap();
doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let mixed = text.contains('c');
assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}");
assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}");
assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}");
let lines = text::text_lines(&doc, 0).unwrap();
let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect();
assert_eq!(read, [text, "ba"], "after {text:?}");
assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place");
sizes.push(content.split_whitespace().count());
}
assert_eq!(sizes[2], sizes[3], "the same text again, the same content");
// A sequence holding marked content of its own goes whole, and what follows it keeps its
// font and size unless the replacement sets the same again.
for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] {
let mut doc = text_page(&format!(
"BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET"
));
text::replace_line(&mut doc, 0, 0, "ab ab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}");
let lines = text::text_lines(&doc, 0).unwrap();
let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect();
assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}");
}
}
/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the
/// subset can show) was split into an empty source-font run and a substitute run, so it was
/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line
/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is
/// substituted as a whole instead, as a line with no word in the source font always was.
#[test]
fn a_leading_space_does_not_make_a_source_font_run() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, " cab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
// With a word in the source font after it, the line is still written in both.
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, " ab cab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab cab", "F2")]);
// The spaces the subset has no glyph for are moves, each one kept: the leading one before
// the first word, two between words as two.
let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc));
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap();
let doc = reopen(&doc);
assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc));
// Before a substitute word, the leading spaces are the substitute's to show, all of them.
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("( cab) Tj"), "{content}");
}
/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier
/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A
/// later replacement of one of those rows must not take the sequence's other rows with it.
#[test]
fn replacing_one_row_of_a_sequence_keeps_the_other_rows() {
let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET";
let rows = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(rows.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab", "ba"]);
for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] {
let mut doc = text_page(content);
text::replace_line(&mut doc, 0, row, text).unwrap();
let doc = reopen(&doc);
let after: Vec<String> = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect();
assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}");
}
}
/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run
/// has the substitute's font in effect at its first operator. Replaced by text the row's own font
/// shows, it must set that font again, or the new text is drawn in the substitute.
#[test]
fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() {
// A real two-font line, starting with the substitute run, then a second row put inside its
// sequence, as a paragraph rewrite that keeps the delimiters can leave it.
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}");
let mut doc = doc;
let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1);
let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes()))));
set_page_contents(&mut doc, stream, None);
let rows = text::text_lines(&doc, 0).unwrap();
assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("cab ab", "F2"), ("ba", "F2")]);
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
let doc = reopen(&doc);
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab", "F2"), ("ba", "F2")]);
}
/// Review on #2955: a sequence holding one row can also hold that row's positioning and text
/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the
/// delimiters, fonts and text, not the `Tm` that places it.
#[test]
fn replacing_the_only_row_of_a_sequence_keeps_its_position() {
let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET";
let mut doc = text_page(content);
let before = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab"]);
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}");
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.len(), 1, "{content}");
let (was, now) = (before[0].rect, after[0].rect);
assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}");
}
#[test]
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
@@ -5561,6 +5956,20 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() {
}
}
#[test]
fn text_blocks_split_at_headings_and_blank_lines() {
let page = |size: u32, gap: u32, words: &[&str], top: u32| -> String {
words.iter().enumerate().map(|(i, w)| format!("BT /F1 {size} Tf 40 {} Td ({w}) Tj ET\n", top - i as u32 * gap)).collect()
};
let count = |content: String| text::text_blocks(&text_page(&content), 0).unwrap().len();
let heading = page(12, 14, &["First line of text", "second line here", "third line ends"], 340)
+ &page(12, 14, &["HEADING"], 340 - 28 - 24)
+ &page(12, 14, &["Next paragraph one", "next two", "next three"], 340 - 28 - 24 - 24);
assert_eq!(count(heading), 3);
assert_eq!(count(page(12, 14, &["Para one a", "para one b"], 340) + &page(12, 14, &["Para two a", "para two b"], 340 - 42)), 2);
assert_eq!(count(page(24, 29, &["VISIBLE", "HIDDEN"], 340)), 1);
}
#[test]
fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() {
let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
@@ -9019,6 +9428,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on
/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider
/// lines, and glyphs and patterns unless asked, are left alone.
#[test]
fn hairlines_are_widened_in_page_content_and_forms() {
use crate::hairlines::{Hairlines, fix_hairlines};
let mut doc = fixture();
let page = page_list(&doc)[0].clone();
// A form drawn at half scale with a 0.1 pt line (0.05 pt on the page) and a 4 pt line.
let mut fd = Dict::new();
fd.set(b"Type".to_vec(), Object::name("XObject"));
fd.set(b"Subtype".to_vec(), Object::name("Form"));
fd.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].iter().map(|v| Object::Real(*v)).collect()));
let form = doc.add(Object::Stream(Stream::flate(fd, b"0.1 w 0 0 m 50 50 l S 4 w 0 50 m 50 0 l S")));
let content = b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S q 0.5 0 0 0.5 0 0 cm 0.2 w 0 0 m 10 10 l S /Fm1 Do Q 0.5 w 10 30 m 100 30 l S";
let cs = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
let mut xo = Dict::new();
xo.set(b"Fm1".to_vec(), Object::Ref(form));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(cs));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
// Page: the 0 w line and the 0.2 w line at half scale (0.1 pt); the 2 pt and 0.5 pt lines stay.
// Form: the 0.1 w line (0.05 pt on the page); the 4 pt line stays.
assert_eq!(fixed, crate::hairlines::Fixed { lines: 3, pages: 1, elsewhere: 0 });
let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ");
let text = flat(&page_content_bytes(&doc, 0));
assert!(text.contains("0.72 w 10 10 m 100 10 l S"), "zero width → 0.72 pt: {text}");
assert!(text.contains("2 w 10 20 m 100 20 l S") && text.contains("0.5 w 10 30 m 100 30 l S"), "wider lines untouched: {text}");
assert!(text.contains("1.44 w 0 0 m 10 10 l S"), "under a half-scale CTM the width is doubled: {text}");
let Object::Stream(fs) = &*doc.get(form) else { panic!() };
let ft = flat(&fs.decoded().unwrap());
assert!(ft.contains("1.44 w 0 0 m 50 50 l S") && ft.contains("4 w 0 50 m 50 0 l S"), "{ft}");
// Nothing left to fix: a second run changes nothing.
assert_eq!(fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap().lines, 0);
// A stricter threshold catches the 0.5 pt line too, with a custom width.
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap();
assert_eq!(fixed.lines, 1);
assert!(flat(&page_content_bytes(&doc, 0)).contains("1 w 10 30 m 100 30 l S"));
assert!(fix_hairlines(&mut doc, &[0], &Hairlines { width: 0.0, ..Hairlines::default() }).is_err());
}
/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
@@ -9314,7 +9769,7 @@ fn vertical_page(content: &str) -> Document {
]);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar",
b"1 begincodespacerange <0000> <ffff> endcodespacerange 7 beginbfchar <0020> <0020> <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar",
)));
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
font.set(
@@ -10980,8 +11435,140 @@ fn carriage_returns_break_rows_on_the_styled_path() {
fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
let reopened = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, "ab ba ab");
}
/// Spaces drawn as TJ gaps in a subset without a space glyph are wrapped in `/ActualText`, so
/// repeated spaces and leading indents read back through `text_lines`, `editor_text`, and text
/// extraction, and survive a second edit without shifting the line or miscalibrating its scale
/// (#4229).
#[test]
fn spaces_and_indents_in_a_subset_without_a_space_glyph_round_trip_across_edits() {
for paragraph in [true, false] {
for typed in [" ab ba", "ab ba ab", " ab ba ab "] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, typed) } else { text::replace_line(&mut doc, 0, 0, typed) };
assert_eq!(edit.unwrap().substituted, None, "{typed:?}");
let mut doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{typed:?}: {lines:?}");
assert_eq!(lines[0].text, typed, "{typed:?}");
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks[0].editor_text, typed, "{typed:?}");
// Re-edit the reopened document and verify the origin, gap count, and scale stay exact.
let next = " ba ab";
let edit2 = if paragraph { text::replace_block(&mut doc, 0, 0, next) } else { text::replace_line(&mut doc, 0, 0, next) };
assert_eq!(edit2.unwrap().substituted, None, "{typed:?}");
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines[0].text, next, "{typed:?}");
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
}
}
// Styled multi-row path: untouched calibration and row breaks keep 100 Tz and every space.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
let mut doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect();
assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, [" ab ba", "ba ab"]);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
// Two subsets of the same face (/F2 holds 'a' only, /F3 holds 'b' only, neither has ' '):
// spaces adopt their neighbour's subset on the row so no space is stranded in a glyph-less run.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (a) Tj /F1 12 Tf ( ) Tj /F3 12 Tf (b) Tj ET");
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
f.set(b"FirstChar".to_vec(), Object::Int(97));
f.set(b"LastChar".to_vec(), Object::Int(97));
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(500)]));
})
.unwrap();
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |f| {
f.set(b"FirstChar".to_vec(), Object::Int(98));
f.set(b"LastChar".to_vec(), Object::Int(98));
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(520)]));
})
.unwrap();
assert_eq!(text::replace_block(&mut doc, 0, 0, " b a \n a b ").unwrap().substituted, None);
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, [" b a ", " a b "]);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " b a \n a b ");
// A RangeStyle covering only spaces in a subset without a space glyph joins an adjacent
// glyph-bearing segment so its space count survives save/reopen.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let style = text::BlockStyle {
ranges: vec![text::RangeStyle {
range: 2..5,
family: None,
resource: None,
size: None,
color: Some([1.0, 0.0, 0.0]),
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
let doc = reopen(&doc);
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "ab ba");
}
/// A space that joins a neighbour's subset for `/ActualText` keeps the underline of its own
/// style, not the neighbour's, and still reads back as that space.
#[test]
fn a_reassigned_subset_space_keeps_its_own_underline() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let style = text::BlockStyle {
ranges: vec![text::RangeStyle {
range: 2..5,
family: None,
resource: None,
size: None,
color: None,
underline: Some(true),
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().into_iter().next().expect("a line");
assert_eq!(line.text, "ab ba");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let mut widths = Vec::new();
let mut from = None;
for row in content.lines() {
if let Some(rest) = row.strip_suffix(" m") {
from = rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok());
} else if let Some(rest) = row.strip_suffix(" l")
&& let (Some(x0), Some(x1)) = (from.take(), rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok()))
{
widths.push((x1 - x0).abs());
}
}
let text_width = line.rect[2] - line.rect[0];
assert!(widths.iter().any(|w| *w > 1.0), "the spaces lost their underline: {widths:?}\n{content}");
assert!(
widths.iter().all(|w| *w < text_width * 0.6),
"the space underline covered the neighbouring glyphs ({text_width}): {widths:?}\n{content}"
);
}
/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to
+6 -2
View File
@@ -6,8 +6,9 @@
//! dropped, and every positioning operator stays, so the lines after it keep their places
//! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change).
//!
//! The new text uses the line's own font when every character has a code and a glyph in it;
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
//! The new text uses the line's own font when every character has a code and a glyph in it.
//! Otherwise the words the font can show keep it and only the others are set in Helvetica
//! (WinAnsi), run by run, and the result says the font was substituted.
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
use std::collections::HashMap;
@@ -132,6 +133,9 @@ pub struct TextLine {
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
/// (#2529). Meaningless otherwise.
soft_gap: usize,
/// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in
/// (see [`fallback::continues_font`]).
edited: Option<usize>,
}
impl TextLine {
+49 -1
View File
@@ -1,4 +1,4 @@
use super::interp::Ts;
use super::interp::{Shown, Ts};
use super::lines::page_dict;
use super::{
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
@@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option<std::col
Some(shown)
}
/// Whether run `s` may continue `line` font-wise: the same font, or both inside the `/PCEdLine`
/// sequence of a replacement that set the words its font can't show in a substitute (#2378),
/// which reads back as the one line it replaced, so the next edit replaces all of it.
pub(super) fn continues_font(line: &TextLine, s: &Shown) -> bool {
same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited)
}
/// A line that began with a substitute run takes the source font of a later run, so the next
/// replacement starts from the line's own font again.
pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) {
if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") {
line.font = String::from_utf8_lossy(&s.font).into_owned();
line.base_font = s.base_font.clone();
line.face = s.face.clone();
}
}
/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese
/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another
/// resource for the same font (a writer may split one font into several subsets, #3661), or
@@ -720,9 +737,40 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
}
}
if out.iter().any(|op| op.is("Tj")) {
let logical = crate::bidi::to_logical(&if metrics.visual_order { crate::indic::to_logical(text) } else { text.to_owned() });
let mut properties = Dict::new();
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&logical)));
out.insert(0, Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
out.push(Op::new("EMC", Vec::new()));
}
Ok(out)
}
/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in
/// order. A word is shown in one font, so a word with any character the font lacks goes to the
/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are
/// in, since [`source_show`] draws spaces the font lacks with a displacement.
pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> {
let mut runs: Vec<(bool, String)> = Vec::new();
// Spaces before the first word go with it: on their own they would make a run of the line's
// font with nothing to show (review on #2955).
let mut lead = String::new();
for (i, word) in text.split(' ').enumerate() {
let piece = if i == 0 { word.to_string() } else { format!(" {word}") };
if runs.is_empty() && word.is_empty() {
lead.push_str(&piece);
continue;
}
let own = word.is_empty() || metrics.encode(word).is_some();
match runs.last_mut() {
Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece),
_ => runs.push((own, std::mem::take(&mut lead) + &piece)),
}
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
+59 -30
View File
@@ -359,6 +359,7 @@ pub(super) struct Shown {
/// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer),
/// matching the single space a rejoin always used before #2529.
pub(super) soft_gap: usize,
pub(super) edited: Option<usize>,
}
impl Shown {
@@ -492,6 +493,9 @@ pub(super) struct Carry {
pub(super) layers: Vec<Option<Vec<u8>>>,
/// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]).
pub(super) text_left: usize,
/// Whether the last run ended in a space read from a gap, with the next operator a `Tj` or
/// `TJ` that may start with the space glyph that gap widened.
pub(super) gap_space: bool,
}
/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is
@@ -522,7 +526,7 @@ impl Carry {
dash: (Vec::new(), 0.0),
line_cap: 0,
};
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT, gap_space: false }
}
}
@@ -537,6 +541,8 @@ type Invocation = (usize, Vec<u8>, Ts);
struct MarkedContent {
regions: Vec<Option<usize>>,
layers: Vec<Option<Vec<u8>>>,
// The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last.
edited: Vec<Option<usize>>,
actual_text: Vec<Option<(usize, usize, String)>>,
}
@@ -550,6 +556,7 @@ impl MarkedContent {
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
self.layers
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() });
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
@@ -565,34 +572,37 @@ impl MarkedContent {
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
self.regions.pop();
self.layers.pop();
self.edited.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
if tail.iter().any(|s| s.glyph_count > 0) {
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
@@ -654,6 +664,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
// Whether the text ends in a space read from a gap rather than from a glyph. The run just
// before may have ended in one: a justified line can be split over `TJ` and `Tj`.
let mut gap_space = std::mem::take(&mut carry.gap_space);
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
@@ -662,13 +675,17 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
// A space glyph right after a gap that read as a space is that space:
// LibreOffice stretches a justified line on both sides of each space glyph.
let after_gap = std::mem::take(&mut gap_space);
match shown {
Some(t) => {
Some(shown) => {
let t: &str = if after_gap { shown.strip_prefix(' ').unwrap_or(&shown) } else { &shown };
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
text.push_str(t);
}
None => decodable = false,
}
@@ -709,6 +726,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
gap_space = true;
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
@@ -720,6 +738,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
gap_space = true;
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
@@ -761,8 +780,11 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
// Outside an `/ActualText` replacement it must not create a phantom editable
// line or enlarge its neighbours' boxes; inside one (such as a space gap in a
// subset font without a space glyph, #4229) it is part of the span's box and
// operators when the span also paints glyphs.
if !marked.actual_text.iter().any(Option::is_some) && !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
@@ -809,7 +831,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
soft_break: state.soft_break,
hard_break: state.hard_break,
soft_gap: state.soft_gap,
edited: marked.edited.last().copied().flatten(),
});
carry.gap_space = gap_space;
Ok(())
}
@@ -843,6 +867,10 @@ pub(super) fn interpret(
// as 1, the single space a rejoin always used before #2529.
let mut soft_gap = 1usize;
for (i, op) in ops.iter().enumerate() {
// A gap's space carries only into a `Tj` or `TJ` right after it.
if !matches!(op.op.as_slice(), b"Tj" | b"TJ") {
carry.gap_space = false;
}
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
@@ -1006,6 +1034,7 @@ pub(super) fn interpret(
carry.stack = stack;
carry.marked = marked.regions;
carry.layers = marked.layers;
out.retain(|s| s.glyph_count > 0 || s.actual_text);
positioned_tracking(&mut out);
Ok(out)
}
+15 -4
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use super::fallback::{same_font, same_paint};
use super::fallback::{adopt_source_font, continues_font, same_paint};
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
@@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
piece == si
&& lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
&& (s.text.trim().is_empty()
|| lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01
@@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
if l.unreadable_mode.is_none() {
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
}
adopt_source_font(l, &s);
// Spaces may use a separate subset font; they do not change the visible style.
} else {
lines.push(TextLine {
@@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
soft_break: s.soft_break,
hard_break: s.hard_break,
soft_gap: s.soft_gap,
edited: s.edited,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
let bt = pen.bt;
lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
&& (s.text.trim().is_empty()
|| lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01
@@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
if l.unreadable_mode.is_none() {
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
}
adopt_source_font(l, &s);
} else {
lines.push(TextLine {
text: s.text.clone(),
@@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
soft_break: s.soft_break,
hard_break: s.hard_break,
soft_gap: s.soft_gap,
edited: s.edited,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -701,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
let mut carry = Carry {
ts: Ts { ctm: form.ctm, ..ts },
stack: Vec::new(),
marked: Vec::new(),
layers: Vec::new(),
text_left: *text_left,
gap_space: false,
};
let stream = w.next_stream;
w.next_stream = w.next_stream.saturating_add(1);
if let Some(r) = form.obj {
+113 -11
View File
@@ -1,7 +1,7 @@
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
use super::fallback::{
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font,
};
use super::interp::{Carry, Origin, TextState, interpret};
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
@@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
substituted = Some(format!("{} Type3", fallback.family));
} else {
// The words the line's font can show keep it; only the others are set in the
// substitute (#2378): a subset without `V` or `ü` no longer turns the whole line
// into Helvetica. Reordered text (right to left, Indic) keeps one run as before.
let mixed = font
.as_ref()
.filter(|_| !visual_indic && fallback_shown == text)
.map(|m| source_runs(m, &text))
.filter(|runs| runs.iter().any(|(own, _)| *own));
// What the substitute has to show: only the runs the line's font can't.
let for_substitute: String = match &mixed {
Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(),
None => fallback_shown.clone(),
};
let win = pdfcraft_fonts::win_ansi(&fallback_shown);
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
let checked = pdfcraft_fonts::win_ansi(&for_substitute);
let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
}
// The size in text space: the current Tf's size.
@@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let family = source_family(&target.base_font);
let base = family.base_font(target.bold, target.italic);
let substitute_name = format!("PCEd{}", base.replace('-', ""));
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
match mixed {
Some(runs) => {
let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m));
// Marked as one line, so it reads back as one (see `continues_font`).
replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")]));
for (own, run) in runs {
if own {
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
if let Some(m) = font.as_ref() {
replacement.extend(source_show(
m,
&run,
target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size),
space,
target.origin.state.char_spacing,
target.origin.state.word_spacing,
)?);
}
} else {
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))]));
}
}
replacement.push(Op::new("EMC", Vec::new()));
}
None => {
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
}
}
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
substituted = Some(base.to_string());
let mut f = Dict::new();
@@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
}
// Rebuild: the line's first operator becomes the replacement, its others go.
// A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own,
// with its fonts and the moves standing in for spaces the source font lacks: those go
// with the line, or each edit would nest one more (review on #2955). The line then starts in
// its own font, set again where the sequence was.
if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) {
// Only when the sequence holds nothing but this line: a paragraph rewrite keeps the
// delimiters, so other rows can be inside it (review on #2955). Then the sequence stays
// as it is, since it is also what reads its own two-font line back as one, and only
// this line's operators go.
let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o)));
// Either way the replacement starts in the line's own font: inside the sequence the font
// in effect at the line's first operator can be the substitute's, which a replacement
// the line's font shows by itself would otherwise inherit (review on #2955).
let size = font_size_before(&ops, first).unwrap_or(target.size);
replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
if !others_inside {
// What goes is what the replacement stands in for: the marked-content delimiters
// (nested ones too, which come in pairs), the fonts and the text-showing operators
// (the line's runs and the moves between them). A paragraph rewrite can also write a
// row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET`
// inside kept delimiters; those stay, or the replacement loses its place and the
// content its balance (review on #2955).
drops.extend(
(open..=close)
.filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))),
);
let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size));
// The same font set just before the sequence (by the last edit) is set again here.
if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) {
drops.insert(i);
}
// And set back after it: the replacement ends in that font too.
if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) {
drops.insert(i);
}
}
}
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data)?;
let page_ref = p.obj;
@@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
Ok(LineEdit { substituted })
}
/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins.
fn matching_emc(ops: &[Op], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (i, op) in ops.iter().enumerate().skip(open) {
match op.op.as_slice() {
b"BMC" | b"BDC" => depth = depth.saturating_add(1),
b"EMC" => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// The size of the last `Tf` before operator `at`.
fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
@@ -649,12 +746,17 @@ impl LineWriter<'_> {
}
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
let visual = crate::bidi::to_visual(shown);
// Stretched without word spacing: shown up to and including each space, then moved on.
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
for piece in pieces {
self.show_piece(piece, line, tw, cursor, ops)?;
if stretch > 0.0 && piece.ends_with(' ') {
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) {
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?);
} else {
// Stretched without word spacing: shown up to and including each space, then moved on.
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
for piece in pieces {
self.show_piece(piece, line, tw, cursor, ops)?;
if stretch > 0.0 && piece.ends_with(' ') {
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
}
}
}
let tw = tw + stretch;
+13 -1
View File
@@ -331,7 +331,19 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
let scale = gap.abs().max(ngap.abs());
if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None }
});
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5);
// A lone row with none of those: the step to the row after it is the leading of the
// paragraph it opens, and the gap above it is a heading's or a blank line's when it
// is much wider (#3577).
let following = rows.get(row_at.saturating_add(1)).and_then(|next| {
let nfirst = next.first().and_then(|i| lines.get(*i))?;
let ngap = first.frame_origin().1 - nfirst.frame_origin().1;
(same_plane(first, nfirst) && ngap > tol * 0.8 && ngap < tol * 2.05).then_some(ngap)
});
let max_gap = own_leading
.filter(|lead| *lead > tol * 0.4)
.or(stated)
.or(repeated)
.map_or_else(|| following.map_or(tol * 2.05, |lead| (lead * 1.35).min(tol * 2.05)), |lead| lead * 1.5);
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
+117 -21
View File
@@ -354,6 +354,17 @@ struct StyledLines<'a> {
}
type StyledChar = (char, usize, String);
/// End of a contiguous run sharing both its style and font resource.
fn styled_run_end(items: &[StyledChar], start: usize) -> usize {
let Some(first) = items.get(start) else { return start };
let mut end = start + 1;
while items.get(end).is_some_and(|next| next.1 == first.1 && next.2 == first.2) {
end += 1;
}
end
}
type StyledRows = Vec<Vec<StyledChar>>;
type RowRanges = Vec<std::ops::Range<usize>>;
enum ShapedFont {
@@ -370,6 +381,7 @@ type ShapedRunCache = HashMap<(usize, usize), ShapedText>;
struct ShapedRows {
runs: ShapedRunCache,
advances: HashMap<(usize, usize), f64>,
char_advances: HashMap<(usize, usize), Vec<f64>>,
}
fn prepare_shaped_rows(
@@ -382,15 +394,15 @@ fn prepare_shaped_rows(
) -> Result<ShapedRows, EditError> {
let mut runs = HashMap::new();
let mut advances = HashMap::new();
let mut char_advances = HashMap::new();
for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) {
let mut at = 0;
while let Some(item) = row.get(at) {
let mut end = at + 1;
while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) {
end += 1;
}
let end = styled_run_end(row, at);
let segment = row.get(at..end).unwrap_or_default();
advances.insert((row_index, at), segment.iter().map(&advance).sum::<f64>());
let widths: Vec<f64> = segment.iter().map(&advance).collect();
advances.insert((row_index, at), widths.iter().copied().sum());
char_advances.insert((row_index, at), widths);
let text: String = segment.iter().map(|character| character.0).collect();
if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? {
runs.insert((row_index, at), shaped);
@@ -398,7 +410,7 @@ fn prepare_shaped_rows(
at = end;
}
}
Ok(ShapedRows { runs, advances })
Ok(ShapedRows { runs, advances, char_advances })
}
type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec<u8>)>>;
@@ -1297,6 +1309,70 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
.collect()
}
fn segment_underline_runs(
widths: &[f64],
segment_width: f64,
x: f64,
default_style: usize,
row_styles: Option<&[Option<usize>]>,
pos: usize,
) -> Vec<(usize, f64, f64)> {
let width_sum = widths.iter().copied().sum::<f64>();
let width_scale = if width_sum > 1e-6 { segment_width / width_sum } else { 0.0 };
let mut cursor = x;
let mut runs: Vec<(usize, f64, f64)> = Vec::new();
for (offset, width) in widths.iter().enumerate() {
let style_index = row_styles.and_then(|styles| styles.get(pos.saturating_add(offset))).and_then(|style| *style).unwrap_or(default_style);
let next = cursor + *width * width_scale;
match runs.last_mut() {
Some((prev, _, to)) if *prev == style_index => *to = next,
_ => runs.push((style_index, cursor, next)),
}
cursor = next;
}
runs
}
/// Keep spaces in a subset without a space glyph attached to an adjacent glyph-bearing segment on
/// the same row so `source_show` wraps them in one `/ActualText` span (#4229).
///
/// The returned slot is the space's style index from before that reassignment, where the
/// grouping key changed. Underlines stay with that style; the row itself keeps the neighbour's
/// key so the space is not a group of its own.
fn assign_subset_space_fonts(row: &mut [(char, usize, String)], metrics: &std::collections::BTreeMap<String, Metrics>) -> Vec<Option<usize>> {
let mut right_any = vec![None; row.len()];
let mut next_any: Option<(usize, String)> = None;
for (i, item) in row.iter().enumerate().rev() {
if item.0 != ' ' {
next_any = Some((item.1, item.2.clone()));
} else if let Some(slot) = right_any.get_mut(i) {
*slot = next_any.clone();
}
}
let mut prev_any: Option<(usize, String)> = None;
let mut underline_style = vec![None; row.len()];
for (i, item) in row.iter_mut().enumerate() {
if item.0 != ' ' {
prev_any = Some((item.1, item.2.clone()));
continue;
}
if metrics.get(&item.2).is_none_or(|m| m.encode(" ").is_some()) {
continue;
}
if let Some((style, font)) = prev_any.as_ref().or_else(|| right_any.get(i).and_then(Option::as_ref)) {
let original = item.1;
item.1 = *style;
item.2 = font.clone();
if let Some(slot) = underline_style.get_mut(i)
&& item.1 != original
{
*slot = Some(original);
}
}
}
underline_style
}
/// Operator index of the paragraph's first text object, and of the next line's text object
/// when one follows.
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
@@ -1584,10 +1660,7 @@ pub(crate) fn rewrite_styled_block(
let mut width = 0.0;
let mut at = 0;
while let Some(item) = items.get(at) {
let mut end = at + 1;
while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
let end = styled_run_end(items, at);
let segment = items.get(at..end).unwrap_or_default();
let string: String = segment.iter().map(|v| v.0).collect();
if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? {
@@ -1612,7 +1685,8 @@ pub(crate) fn rewrite_styled_block(
}
Ok(width)
};
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let (mut rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let space_styles: Vec<_> = rows.iter_mut().map(|row| assign_subset_space_fonts(row, &metrics)).collect();
let streams = content_streams(doc, &p.dict)?;
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
@@ -1643,7 +1717,7 @@ pub(crate) fn rewrite_styled_block(
validate_contextual_rows(&rows, &kept)?;
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?;
let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped;
let ShapedRows { runs: shaped_runs, advances: segment_advances, char_advances } = shaped;
let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?;
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
@@ -1720,10 +1794,7 @@ pub(crate) fn rewrite_styled_block(
while pos < row.len() {
let Some(item) = row.get(pos) else { break };
let source = lines.get(item.1).unwrap_or(first);
let mut end = pos + 1;
while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) {
end += 1;
}
let end = styled_run_end(row, pos);
let segment = row.get(pos..end).unwrap_or_default();
let string: String = segment.iter().map(|c| c.0).collect();
let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?;
@@ -1779,11 +1850,20 @@ pub(crate) fn rewrite_styled_block(
)?);
block_ops.push(Op::new("ET", vec![]));
}
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, item.1) * source.origin.k;
let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
// A space reassigned onto a neighbour keeps that neighbour's grouping key, and its
// own style for the underline. Stroke each run of one underline style separately.
let widths = char_advances.get(&(row_index, pos)).map(Vec::as_slice).unwrap_or(&[]);
for (style_index, from, to) in
segment_underline_runs(widths, segment_width, x, item.1, space_styles.get(row_index).map(Vec::as_slice), pos)
{
let source = lines.get(style_index).unwrap_or(first);
if underlines.get(&style_index).copied().or(style.underline) != Some(true) || to <= from {
continue;
}
let [r, g, b] = overrides.get(&style_index).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, style_index) * source.origin.k;
let (x0, y0) = back.apply(from - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(to - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))]));
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
@@ -1819,3 +1899,19 @@ pub(crate) fn rewrite_styled_block(
crate::draw_on_top(doc, &p, o.ctm, &on_top)?;
Ok(LineEdit { substituted })
}
#[cfg(test)]
mod tests {
use super::styled_run_end;
#[test]
fn styled_runs_stop_at_style_or_font_changes() {
let items = vec![('a', 0, "F1".into()), (' ', 0, "F1".into()), ('b', 1, "F1".into()), ('c', 1, "F2".into())];
assert_eq!(styled_run_end(&items, 0), 2);
assert_eq!(styled_run_end(&items, 1), 2);
assert_eq!(styled_run_end(&items, 2), 3);
assert_eq!(styled_run_end(&items, 3), 4);
assert_eq!(styled_run_end(&items, 4), 4);
assert_eq!(styled_run_end(&[], 0), 0);
}
}
@@ -0,0 +1,88 @@
//! Fix hairlines follows the line width an ExtGState's `/LW` sets through `gs`: a stroke it
//! leaves thin is found, and the width put back after a widened stroke is that one.
use pdfcraft_cos::{Document, ObjRef, Object};
use pdfcraft_edit::{Hairlines, fix_hairlines};
use std::sync::Arc;
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
/// it is a stream); object 1 is the catalog.
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, (dict, data)) in objects.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
match data {
Some(data) => {
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
}
None => out.extend_from_slice(dict.as_bytes()),
}
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// A stream's data, its tokens joined by single spaces.
fn flat(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
_ => panic!("not a stream"),
}
}
/// Page `i`'s content (a single stream), flattened.
fn page_text(doc: &Document, i: usize) -> String {
let page = pdfcraft_model::pages(doc).swap_remove(i);
flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap())
}
/// A one-page document drawing `content`, with `/GS0` setting `/LW lw` and `/GS1` no width.
fn with_gstate(lw: &str, content: &[u8]) -> Document {
let page = format!(
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /ExtGState << /GS0 << /Type /ExtGState /LW {lw} >> /GS1 << /Type /ExtGState /CA 1 >> >> >> >>"
);
pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
(page.as_str(), None),
("", Some(content)),
])
}
#[test]
fn the_width_put_back_is_the_one_gs_set() {
// 2 pt under a tenth scale is 0.2 pt on the page: both strokes are widened, and each gets
// the 2 pt back after it.
let mut doc = with_gstate("2", b"/GS0 gs 0.1 0 0 0.1 0 0 cm 0 0 m 1 0 l S 0 1 m 1 1 l S");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 2);
let t = page_text(&doc, 0);
assert_eq!(t.matches("l S 2 w").count(), 2, "{t}");
assert!(!t.contains(" 1 w"), "no stroke is put back to the default width: {t}");
}
#[test]
fn a_zero_width_set_by_gs_is_a_hairline() {
let mut doc = with_gstate("0", b"/GS0 gs 0 0 m 1 0 l S");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(page_text(&doc, 0), "/GS0 gs 0.72 w 0 0 m 1 0 l S 0 w");
}
#[test]
fn a_gs_without_a_width_keeps_the_one_in_effect() {
let mut doc = with_gstate("2", b"0.1 w /GS1 gs 0 0 m 1 0 l S");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(page_text(&doc, 0), "0.1 w /GS1 gs 0.72 w 0 0 m 1 0 l S 0.1 w");
}
@@ -0,0 +1,75 @@
//! Fix hairlines measures a form's strokes with the line width in effect where the form is
//! drawn (a form inherits the graphics state of its `Do`), and puts that width back after a
//! stroke it widens, in forms drawn by forms too.
use pdfcraft_cos::{Document, ObjRef, Object};
use pdfcraft_edit::{Hairlines, fix_hairlines};
use std::sync::Arc;
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
/// it is a stream); object 1 is the catalog.
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, (dict, data)) in objects.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
match data {
Some(data) => {
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
}
None => out.extend_from_slice(dict.as_bytes()),
}
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// A stream's data, its tokens joined by single spaces.
fn flat(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
_ => panic!("not a stream"),
}
}
const CATALOG: (&str, Option<&[u8]>) = ("<< /Type /Catalog /Pages 2 0 R >>", None);
const PAGES: (&str, Option<&[u8]>) = ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None);
const PAGE: (&str, Option<&[u8]>) = ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None);
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
#[test]
fn a_form_without_its_own_width_is_measured_with_the_width_at_its_do() {
// 0.1 pt is in effect where the form is drawn; the form sets no width of its own.
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w");
}
#[test]
fn a_widened_stroke_in_a_form_gets_the_inherited_width_back() {
// 0.4 pt at half scale is 0.2 pt on the page; after the stroke the form's later strokes
// must draw with the 0.4 they inherited, not the 1 a fresh graphics state has.
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.5 0 0 0.5 0 0 cm 0.4 w /Fm0 Do")), (FORM, Some(b"0 0 m 1 0 l S"))]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "1.44 w 0 0 m 1 0 l S 0.4 w");
}
#[test]
fn a_form_drawn_by_a_form_inherits_the_width_too() {
let fm0 = "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 6 0 R >> >>";
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (fm0, Some(b"/Fm1 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(flat(&doc, ObjRef { num: 6, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w");
}
+83
View File
@@ -0,0 +1,83 @@
//! Fix hairlines gives a thin stroke a width of its own: set before its path begins (a `w`
//! inside a path is not allowed) and put back after it is painted, so the `w` other strokes
//! share, and strokes under another transformation, keep their width.
use pdfcraft_cos::{Document, Object};
use pdfcraft_edit::{Hairlines, fix_hairlines};
use std::sync::Arc;
/// A one-page document drawing `content`.
fn one_page(content: &[u8]) -> Document {
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
];
let mut s = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
s.extend_from_slice(content);
s.extend_from_slice(b"\nendstream");
objs.push(s);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The page's content, its tokens joined by single spaces.
fn content_text(doc: &Document) -> String {
let page = pdfcraft_model::pages(doc).swap_remove(0);
let list: Vec<Object> = match &*doc.resolve(page.dict.get(b"Contents").unwrap()) {
Object::Array(a) => a.clone(),
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
};
let mut text = String::new();
for e in list {
if let Object::Stream(s) = &*doc.resolve(&e) {
text.push_str(&String::from_utf8_lossy(&s.decoded().unwrap()));
text.push(' ');
}
}
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn a_thin_stroke_does_not_change_the_width_other_strokes_share() {
// The first line is 0.1 pt on the page; the second, under a tenfold scale, is 1 pt.
let mut doc = one_page(b"0.1 w 0 0 m 10 0 l S 10 0 0 10 0 0 cm 0 1 m 1 1 l S");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
let t = content_text(&doc);
assert!(t.starts_with("0.1 w 0.72 w 0 0 m 10 0 l S 0.1 w 10 0 0 10 0 0 cm"), "the shared w is kept: {t}");
assert!(!t.contains("7.2"), "the scaled stroke keeps its 1 pt: {t}");
}
#[test]
fn a_stroke_inside_q_does_not_change_the_outer_width() {
let mut doc = one_page(b"0.1 w q 0 0 m 1 0 l S Q 0 1 m 1 1 l S");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 2);
let t = content_text(&doc);
assert!(t.starts_with("0.1 w q 0.72 w 0 0 m 1 0 l S 0.1 w Q 0.72 w 0 1 m 1 1 l S 0.1 w"), "{t}");
}
#[test]
fn the_new_width_is_set_before_the_path_not_inside_it() {
// No `w` before the stroke: the default 1 pt at half scale is 0.5 pt on the page.
let mut doc = one_page(b"0.5 0 0 0.5 0 0 cm 0 0 m 10 0 l S 5 5 10 10 re f");
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap();
assert_eq!(fixed.lines, 1);
let t = content_text(&doc);
assert!(t.contains("cm 2 w 0 0 m 10 0 l S 1 w 5 5 10 10 re f"), "{t}");
assert!(!t.contains("l 2 w S"), "no w inside the path: {t}");
}
+165
View File
@@ -0,0 +1,165 @@
//! Fix hairlines rewrites a form for the scale it is drawn under: a form a page not being
//! fixed also draws, or one drawn under two scales, is rewritten as a copy for the use that
//! needs it, so the other uses keep drawing it as it was.
use pdfcraft_cos::{Document, ObjRef, Object};
use pdfcraft_edit::{Hairlines, fix_hairlines};
use std::sync::Arc;
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
/// it is a stream); object 1 is the catalog.
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, (dict, data)) in objects.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
match data {
Some(data) => {
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream");
}
None => out.extend_from_slice(dict.as_bytes()),
}
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// A stream's data, its tokens joined by single spaces.
fn flat(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
_ => panic!("not a stream"),
}
}
/// Page `i`'s content (a single stream), flattened.
fn page_text(doc: &Document, i: usize) -> String {
let page = pdfcraft_model::pages(doc).swap_remove(i);
flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap())
}
/// The object page `i`'s resources draw as `name`.
fn xobject(doc: &Document, i: usize, name: &[u8]) -> ObjRef {
let page = pdfcraft_model::pages(doc).swap_remove(i);
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap()
}
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
const ORIGINAL: &str = "0.4 w 0 0 m 1 0 l S";
#[test]
fn a_form_another_page_draws_is_fixed_as_a_copy() {
// Both pages share one resource dictionary; the first draws the form at half scale (its
// 0.4 pt line is 0.2 pt there), the second at full size.
let mut doc = pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 7 0 R >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 7 0 R >>", None),
("", Some(b"0.5 0 0 0.5 0 0 cm /Fm0 Do")),
("", Some(b"/Fm0 Do")),
("<< /XObject << /Fm0 8 0 R >> >>", None),
(FORM, Some(ORIGINAL.as_bytes())),
]);
let form = ObjRef { num: 8, generation: 0 };
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
assert_eq!(flat(&doc, form), ORIGINAL, "the form the second page draws is unchanged");
assert_eq!(xobject(&doc, 1, b"Fm0"), form, "the second page still draws it");
let copy = xobject(&doc, 0, b"Fm0");
assert_ne!(copy, form, "the first page draws a copy");
assert_eq!(flat(&doc, copy), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w");
}
#[test]
fn a_form_drawn_under_two_scales_gets_one_object_for_each() {
let mut doc = pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None),
("", Some(b"q 0.5 0 0 0.5 0 0 cm /Fm0 Do Q /Fm0 Do")),
(FORM, Some(ORIGINAL.as_bytes())),
]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
let text = page_text(&doc, 0);
let words: Vec<&str> = text.split(' ').collect();
let drawn: Vec<&str> = words.windows(2).filter(|w| w[1] == "Do").map(|w| w[0]).collect();
assert_eq!(drawn.len(), 2, "{text}");
assert_eq!(drawn[0], "/Fm0", "{text}");
assert_ne!(drawn[1], "/Fm0", "the full-size use draws another name: {text}");
let half = xobject(&doc, 0, b"Fm0");
let full = xobject(&doc, 0, drawn[1].trim_start_matches('/').as_bytes());
assert_ne!(half, full);
assert_eq!(flat(&doc, half), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w");
assert_eq!(flat(&doc, full), ORIGINAL, "at full size the 0.4 pt line is left as it was");
}
const SHADED: &str = "/Pattern cs /P0 scn 0 0 10 10 re f";
const PATTERN: &str = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 9 9] /XStep 9 /YStep 9 /Resources << >>";
const THIN: &str = "0 w 0 0 m 9 9 l S";
#[test]
fn a_pattern_another_page_uses_is_reported() {
let mut doc = pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None),
("", Some(SHADED.as_bytes())),
("<< /Pattern << /P0 7 0 R >> >>", None),
(PATTERN, Some(THIN.as_bytes())),
]);
let h = Hairlines { patterns: true, ..Hairlines::default() };
let mut both = doc.clone();
assert_eq!(fix_hairlines(&mut both, &[0, 1], &h).unwrap().elsewhere, 0, "every use is in the run");
let fixed = fix_hairlines(&mut doc, &[0], &h).unwrap();
assert_eq!((fixed.lines, fixed.elsewhere), (1, 1));
}
#[test]
fn a_pattern_a_form_uses_is_fixed_too() {
let mut doc = pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None),
("", Some(b"/Fm0 Do")),
// The form lists itself: walking the resources ends all the same.
(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Pattern << /P0 6 0 R >> /XObject << /Fm0 5 0 R >> >>",
Some(SHADED.as_bytes()),
),
(PATTERN, Some(THIN.as_bytes())),
]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { patterns: true, ..Hairlines::default() }).unwrap();
assert_eq!(fixed.lines, 1);
assert_ne!(flat(&doc, ObjRef { num: 6, generation: 0 }), THIN);
}
#[test]
fn widths_out_of_range_are_refused() {
let mut doc = pdf(&[
("<< /Type /Catalog /Pages 2 0 R >>", None),
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", None),
("", Some(THIN.as_bytes())),
]);
for (narrower_than, width) in [(0.2, -1.0), (0.2, f64::NAN), (0.2, f64::INFINITY), (0.2, 10_000.0), (-1.0, 0.7), (f64::NAN, 0.7), (10_000.0, 0.7)]
{
let h = Hairlines { narrower_than, width, ..Hairlines::default() };
assert!(fix_hairlines(&mut doc, &[0], &h).is_err(), "{narrower_than} {width}");
}
let most = Hairlines { narrower_than: Hairlines::MAX_POINTS, width: Hairlines::MAX_POINTS, ..Hairlines::default() };
assert_eq!(fix_hairlines(&mut doc, &[0], &most).unwrap().lines, 1);
}
+92
View File
@@ -0,0 +1,92 @@
//! Fix hairlines rewrites each of a page's content streams on its own: a stream keeps its
//! dictionary (its `/PCMark` among it), streams that did not change stay as they are, the
//! graphics state carries from one stream to the next, and a page with a stream that cannot
//! be read is left alone instead of losing that stream.
use pdfcraft_cos::{Document, ObjRef, Object};
use pdfcraft_edit::{Hairlines, fix_hairlines};
use std::sync::Arc;
/// A one-page document whose `/Contents` lists `streams` (dictionary entries, data).
fn one_page(streams: &[(&str, &[u8])]) -> Document {
let refs: Vec<String> = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect();
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", refs.join(" ")).into_bytes(),
];
for (dict, data) in streams {
let mut s = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"\nendstream");
objs.push(s);
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The page's `/Contents` references.
fn contents(doc: &Document) -> Vec<ObjRef> {
let page = pdfcraft_model::pages(doc).swap_remove(0);
match &*doc.resolve(page.dict.get(b"Contents").unwrap()) {
Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(),
_ => page.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(),
}
}
fn text_of(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
_ => panic!("not a stream"),
}
}
#[test]
fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() {
let mut doc = one_page(&[("", b"2 w 0 0 m 1 1 l S"), ("/PCMark /Redaction", b"0 w 0 0 m 1 1 l S")]);
let before = contents(&doc);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
let after = contents(&doc);
assert_eq!(after.len(), 2, "still two streams");
assert_eq!(after[0], before[0], "the unchanged stream is the same object");
assert_ne!(after[1], before[1]);
let mark = match &*doc.get(after[1]) {
Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec),
_ => None,
};
assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept");
assert!(text_of(&doc, after[1]).contains("0.72 w 0 0 m 1 1 l S"));
}
#[test]
fn the_graphics_state_carries_from_one_stream_to_the_next() {
// 0.3 pt under the first stream's half scale is 0.15 pt on the page.
let mut doc = one_page(&[("", b"0.5 0 0 0.5 0 0 cm"), ("", b"0.3 w 0 0 m 1 1 l S")]);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 1);
let after = contents(&doc);
assert!(text_of(&doc, after[1]).contains("1.44 w 0 0 m 1 1 l S"));
}
#[test]
fn a_page_with_an_unreadable_stream_is_left_alone() {
let mut doc = one_page(&[("", b"0 w 0 0 m 1 1 l S"), ("/Filter /DCTDecode", b"not a picture")]);
let before = contents(&doc);
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
assert_eq!(fixed.lines, 0);
assert_eq!(contents(&doc), before, "no stream is dropped");
}
+1 -1
View File
@@ -412,7 +412,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")),
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Add printer marks", "ruler", "prepress.marks", Planned("M11")),
item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")),
item("Fix hairlines", "minus", "prepress.hairlines", Ready),
],
}],
},
+4
View File
@@ -228,6 +228,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
// Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one;
// a tab's right-click menu also splits right or down with any tab.
c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"),
// Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS,
// where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and
// also run from Combine files, which has no document open.
@@ -403,6 +406,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
+14 -2
View File
@@ -442,16 +442,22 @@ impl Document {
self.editor.as_ref().and_then(|e| e.cos.permissions())
}
/// The renderer opened an encrypted file that the editing parser did not, so its permission
/// bits are unknown. Unknown is not unrestricted: printing and copying are refused.
fn permissions_unknown(&self) -> bool {
self.editor.is_none() && self.info.encrypted
}
/// Printing is allowed (Table 22, bit 3).
pub fn allows_printing(&self) -> bool {
self.permissions().is_none_or(|p| p.print())
!self.permissions_unknown() && self.permissions().is_none_or(|p| p.print())
}
/// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying
/// selected text, snapshots, Save Image As, exporting. Assistive technology may read the
/// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask.
pub fn allows_copying(&self) -> bool {
self.permissions().is_none_or(|p| p.copy())
!self.permissions_unknown() && self.permissions().is_none_or(|p| p.copy())
}
/// [`Self::allows_copying`], as the error to report when it isn't.
@@ -606,6 +612,12 @@ impl Document {
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
}
/// [`Self::hidden_info`] for the categories in `which` alone, without the reading the others
/// need (see [`pdfcraft_redact::sanitize::scan_only`]).
pub fn hidden_info_of(&self, which: &[Hidden]) -> Vec<(Hidden, usize)> {
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan_only(&e.cos, which)).unwrap_or_default()
}
/// Verification: faux redactions (opaque covers over extractable text) and redaction
/// marks that were never applied. Fails closed: any read error is returned, never an
/// empty list.
+16 -5
View File
@@ -3,11 +3,11 @@
use std::sync::Arc;
use crate::{
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit,
InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox,
Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image,
field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted,
xfa, xfa_after_data_change,
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden,
ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget,
PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file,
embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa,
unrestricted, xfa, xfa_after_data_change,
};
mod labels;
@@ -349,6 +349,12 @@ pub enum Edit {
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on
/// the page (after every transformation; zero-width lines always) get `settings.width`.
FixHairlines {
pages: Vec<usize>,
settings: Hairlines,
},
/// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under
/// `name`, made unique; `mime` is the embedded file's `/Subtype` when known.
AddAttachment {
@@ -391,6 +397,11 @@ pub enum Edit {
MarkTagArtifact {
object: pdfcraft_cos::ObjRef,
},
/// A layout table as plain blocks: the table, its row groups and rows become Div tags and its
/// cells P tags (Div when they hold tags), without their Table attributes.
LayoutTableToBlocks {
object: pdfcraft_cos::ObjRef,
},
/// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level
/// script `name`.
SetDocumentScript {
+2
View File
@@ -250,6 +250,7 @@ impl Edit {
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
Edit::SetInitialView(_) => Label::of("Change initial view"),
Edit::FixHairlines { .. } => Label::of("Fix hairlines"),
Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
Edit::AutoTagText => Label::of("Automatically tag text"),
@@ -259,6 +260,7 @@ impl Edit {
Edit::MoveTag { .. } => Label::of("Move object"),
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"),
Edit::LayoutTableToBlocks { .. } => Label::of("Change tag properties"),
Edit::AddOcrText { .. } => Label::of("Recognize text"),
Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"),
Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"),
+17 -11
View File
@@ -134,6 +134,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::MoveInTabOrder { .. }
| Edit::SetCalculationOrder { .. }
| Edit::SetInitialView(_)
| Edit::FixHairlines { .. }
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
| Edit::AutoTagText
@@ -143,6 +144,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::MoveTag { .. }
| Edit::MarkDecorative { .. }
| Edit::MarkTagArtifact { .. }
| Edit::LayoutTableToBlocks { .. }
| Edit::AddOcrText { .. }
| Edit::EnhanceScan { .. }
| Edit::SetDocumentScript { .. }
@@ -598,6 +600,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::EraseInk { page, index, path, radius } => {
pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
}
Edit::FixHairlines { pages, settings } => {
let fixed = pdfcraft_edit::fix_hairlines(doc, pages, settings)?;
if fixed.lines == 0 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no hairlines found on those pages".into())));
}
}
Edit::AddAttachment { name, bytes, description, mime } => {
pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?;
}
@@ -649,17 +657,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js),
None => pdfcraft_forms::set_value(doc, name, value),
};
match set(doc, value) {
Ok(()) => {}
// A date the field's format doesn't read may be typed by another of an XFA date
// field's patterns: it is kept in the first. Only a format refusal is tried
// again: it comes before any of the field's scripts run, so they run once.
Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) {
Some(typed) => set(doc, &typed)?,
None => return Err(refused.into()),
},
Err(refused) => return Err(refused.into()),
}
// A date typed by another of an XFA date field's patterns is kept in the first, before
// the field's format reads it more loosely (`18OCT16` in a `YYYYMMDD|DDMMMYY` field is
// the 18th of October 2016, not a day of 2018).
let laid_out = pdfcraft_xfa::xfa_shape(doc).is_some_and(|s| s.laid_out);
let typed = if laid_out { xfa::typed_date(doc, cx.xfa.as_deref(), name, value) } else { None };
set(doc, typed.as_ref().unwrap_or(value))?;
}
Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
@@ -797,6 +800,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::MarkTagArtifact { object } => {
a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::LayoutTableToBlocks { object } => {
a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AddHeaderFooter { pages, settings, replace } => {
let date = cx.today;
pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?;
+1
View File
@@ -96,6 +96,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. }
| Edit::FixHairlines { .. }
// Recognized text is an invisible text layer stamped on its page, with its font in the
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
// a large file.
+97 -3
View File
@@ -119,6 +119,49 @@ impl JsOutput {
self.errors.push(e);
}
}
/// Takes out the alerts, console lines, requests and errors. The record of the document
/// scripts' errors stays, so they are still reported once.
fn set_aside(&mut self) -> JsOutput {
JsOutput {
alerts: std::mem::take(&mut self.alerts),
console: std::mem::take(&mut self.console),
requests: std::mem::take(&mut self.requests),
errors: std::mem::take(&mut self.errors),
doc_errors: Vec::new(),
}
}
/// Puts back what [`JsOutput::set_aside`] took out and returns what was added since.
fn put_back(&mut self, waiting: JsOutput) -> JsOutput {
let own = self.set_aside();
self.alerts = waiting.alerts;
self.console = waiting.console;
self.requests = waiting.requests;
self.errors = waiting.errors;
own
}
}
impl Session {
/// Runs `f` and returns its value with the script output `f` added to document `id`. The
/// output limits count that output alone, and the output that was waiting stays for
/// [`Session::take_js_output`]. When `f` fails, its output joins the waiting output.
pub fn with_own_js_output<T, E>(&mut self, id: DocId, f: impl FnOnce(&mut Session) -> Result<T, E>) -> Result<(T, JsOutput), E> {
let waiting = self.docs.iter_mut().find(|d| d.id == id).map(|d| d.js_output.set_aside());
let result = f(self);
let mut doc = self.docs.iter_mut().find(|d| d.id == id);
let own = doc.as_mut().zip(waiting).map(|(d, w)| d.js_output.put_back(w)).unwrap_or_default();
match result {
Ok(value) => Ok((value, own)),
Err(e) => {
if let Some(d) = doc {
d.js_output.append(own);
}
Err(e)
}
}
}
}
fn kind(k: FieldKind) -> pdfcraft_js::FieldType {
@@ -342,8 +385,8 @@ impl Session {
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
{
self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?;
let out = self.take_js_output(id);
// The click returns only the output of its own edit; output from opening or from earlier edits stays.
let ((), out) = self.with_own_js_output(id, |s| s.apply(id, Edit::XfaEvent { som, activity: "click".into() }))?;
return Ok(Outcome {
rc: true,
alerts: out.alerts,
@@ -570,6 +613,56 @@ mod review_tests {
assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form");
}
/// A click returns its own alert when the output waiting in the document is full, and the
/// waiting output stays.
#[test]
fn a_click_returns_its_alert_when_the_waiting_output_is_full() {
let mut s = Session::new();
let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template());
let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap();
s.take_js_output(id);
s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output.alert(vec![Message::plain("waiting"); MAX_ALERTS]);
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::<Vec<_>>(), ["Hello 2"]);
let out = s.take_js_output(id);
assert_eq!(out.alerts, vec![Message::plain("waiting"); MAX_ALERTS]);
}
/// With the console full and closed, an operation still gets its own lines. Output of an
/// operation that fails joins the waiting output.
#[test]
fn output_limits_count_only_the_operations_own_output() {
let mut s = Session::new();
let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template());
let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap();
s.take_js_output(id);
fn output(s: &mut Session, id: DocId) -> &mut JsOutput {
&mut s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output
}
output(&mut s, id).say(vec!["waiting".to_string(); MAX_CONSOLE_LINES + 1]);
let waiting = output(&mut s, id).console.clone();
assert!(waiting.last().unwrap().starts_with("more console lines were left out"));
let ((), own) = s
.with_own_js_output::<_, ()>(id, |s| {
output(s, id).say(["mine".to_string()]);
output(s, id).errors.push("own error".into());
Ok(())
})
.unwrap();
assert_eq!(own.console, ["mine"]);
assert_eq!(own.errors, ["own error"]);
assert_eq!(output(&mut s, id).console, waiting);
assert!(output(&mut s, id).errors.is_empty());
let failed = s.with_own_js_output::<(), _>(id, |s| {
output(s, id).errors.push("failed edit".into());
Err("no")
});
assert_eq!(failed.unwrap_err(), "no");
let out = s.take_js_output(id);
assert_eq!(out.console, waiting);
assert_eq!(out.errors, ["failed edit"]);
}
/// A form of many calculated fields, each printing its own cap of console lines, keeps a
/// bounded console: past the budget, lines are left out and one closing line says so.
#[test]
@@ -604,7 +697,8 @@ mod review_tests {
};
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
glyphs.push(glyph(n, "x"));
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs };
// Built by hand, not read from a page the renderer cut short, so the text is complete.
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false };
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
let words = words_with(&text, |x, y| [x, y]);
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
+3 -2
View File
@@ -58,6 +58,7 @@ mod stamp_preview;
#[cfg(test)]
mod test_hooks;
pub mod text_fonts;
pub mod text_links;
mod text_preview;
pub mod text_script;
pub mod text_style_bookmarks;
@@ -95,8 +96,8 @@ pub use pdfcraft_edit::added::{
};
pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
MarkKind, Watermark,
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed,
HeaderFooter, Ink as TextInk, MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
+19 -1
View File
@@ -227,7 +227,25 @@ pub fn read_remote_document(path: &Path) -> Result<Vec<u8>, RemoteDocumentPathEr
if meta.len() > MAX_REMOTE_DOCUMENT_BYTES {
return Err(RemoteDocumentPathError::TooLarge);
}
let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
// The path can change between that check and the open (a file replaced by a FIFO): opened
// without blocking, and checked again on the handle, which is what is read.
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
// O_NONBLOCK: Linux 0o4000, macOS and the BSDs 0x4.
options.custom_flags(if cfg!(target_os = "linux") { 0o4000 } else { 0x4 });
}
let file = options.open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
let meta = file.metadata().map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if !meta.is_file() {
return Err(RemoteDocumentPathError::NotAFile);
}
// the opened handle's own length: a file replaced after the path check must not size the buffer
if meta.len() > MAX_REMOTE_DOCUMENT_BYTES {
return Err(RemoteDocumentPathError::TooLarge);
}
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) {
+1 -1
View File
@@ -382,7 +382,7 @@ mod tests {
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("locked.pdf", None, bytes, None).unwrap();
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() };
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new(), truncated: false };
let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err();
assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}");
}
+7 -6
View File
@@ -10,12 +10,13 @@ use crate::check_refresh_panic;
use crate::edit::{EditCtx, check_permission, run_edit};
use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts};
use crate::{
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, 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, 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,
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,
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,
};
mod editing;
+17 -6
View File
@@ -1,7 +1,7 @@
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster,
set_default_layer_state, source_kind, writable, write_full,
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,
print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full,
};
impl Session {
@@ -25,15 +25,17 @@ impl Session {
self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?)
}
/// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is
/// A new document from plain text (US Letter, 11 pt Helvetica, or the bundled Inter face for
/// text with Latin, Greek or Cyrillic letters Helvetica can't draw). Text neither can draw is
/// refused, naming the line and the character (never set as `?`).
pub fn create_from_text(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?)
}
/// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries,
/// compare reports), which can quote any language: characters Helvetica can't draw are written
/// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused.
/// compare reports), which can quote any language: when the bundled Inter face can't draw it
/// all, characters Helvetica can't draw are written as `[U+XXXX]`, and the report's first line
/// says so, rather than the report being refused.
pub(crate) fn create_report(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?;
self.write_new(&doc)
@@ -114,6 +116,15 @@ impl Session {
.map_err(EditError::Write)
}
/// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many
/// pages, without changing the document (the edit itself refuses when there is nothing to fix).
pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed {
let Some(doc) = self.get(id) else { return HairlinesFixed::default() };
let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() };
let mut cos = editor.cos.clone();
pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default()
}
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
+39 -3
View File
@@ -100,11 +100,11 @@ impl Session {
}
/// Web addresses in the text of every page (Create links from URLs): (page, one rectangle
/// per line in user space, URI). Text that already has a link over it is skipped.
/// per line in user space, URI). Text that already has a link over it is skipped, and so is
/// text on a layer the viewer hides: a link there would sit over nothing the reader can see.
pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> {
let Some(doc) = self.get(id) else { return Vec::new() };
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let mut out = Vec::new();
for page in 0..doc.info.pages.len() {
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
@@ -133,6 +133,40 @@ impl Session {
out
}
/// The addresses written as plain text that the viewer makes clickable
/// ([`crate::text_links::find_text_links`]), leaving out any a link annotation lies over, from
/// the 0-based page `from` on. One call reads at most
/// [`MAX_TEXT_LINK_PAGES_PER_SCAN`](crate::text_links::MAX_TEXT_LINK_PAGES_PER_SCAN) pages
/// and stops after the page that brings it to
/// [`MAX_TEXT_LINKS_PER_SCAN`](crate::text_links::MAX_TEXT_LINKS_PER_SCAN) addresses; then
/// `next_page` says where to go on.
pub fn text_links(&self, id: DocId, from: usize) -> crate::text_links::TextLinkScan {
use crate::text_links::{MAX_TEXT_LINK_PAGES_PER_SCAN, MAX_TEXT_LINKS_PER_SCAN, TextLinkScan};
let Some(doc) = self.get(id) else { return TextLinkScan::default() };
// The viewer's settings, so text on a layer it hides is not a link.
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let mut scan = TextLinkScan::default();
let pages = doc.info.pages.len();
for (page, info) in doc.info.pages.iter().enumerate().skip(from).take(MAX_TEXT_LINK_PAGES_PER_SCAN) {
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
if let Some(text) = res.text {
let annotations: Vec<_> = doc.info.links.iter().filter(|l| l.page == page).collect();
for link in crate::text_links::find_text_links(&text) {
if !annotations.iter().any(|l| link.covered_by(info, l.rect)) {
scan.links.push((page, link));
}
}
}
let next = page.saturating_add(1);
let read = next.saturating_sub(from);
if next < pages && (read >= MAX_TEXT_LINK_PAGES_PER_SCAN || scan.links.len() >= MAX_TEXT_LINKS_PER_SCAN) {
scan.next_page = Some(next);
break;
}
}
scan
}
/// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`.
/// The document's own structure is read for it, so every bookmark can be reached by paging.
pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option<BookmarkPage> {
@@ -277,6 +311,8 @@ impl Session {
/// with its number, author, type, date, text and replies, grouped by page.
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
// The summary copies the comments' text out into a new document.
doc.check_copying()?;
let text = comment_summary(&doc.name, &doc.info.annotations, sort);
self.create_report(&format!("Summary of Comments on {}", doc.name), &text)
}
+86 -11
View File
@@ -1420,6 +1420,35 @@ fn utf16_and_code_page_text_files_convert_to_their_text() {
assert_eq!(page_texts(&s, id), ["Crème brûlée"]);
}
/// Create ▸ PDF from a text file in Cyrillic was refused, and a comment summary quoting Cyrillic
/// wrote each letter as `[U+XXXX]`. Both are set in the bundled Inter face now and read back as
/// typed.
#[test]
fn cyrillic_text_files_and_comment_summaries_read_back_as_typed() {
let mut s = Session::new();
let text = "Договор № 12\nЖълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон.";
let (_, pdf) = s.convert_to_pdf("бележки.txt", &Arc::new(text.as_bytes().to_vec())).unwrap();
let id = s.open_new("бележки.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), [text]);
// A sticky note in Russian, summarized.
let note = "Проверьте сумму в разделе 3";
let hex: String = note.encode_utf16().map(|u| format!("{u:04X}")).collect();
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Reviewer) /Contents <FEFF{hex}> >> endobj
trailer << /Root 1 0 R >>
%%EOF"
);
let id = s.open("note.pdf", None, Arc::new(pdf.into_bytes()), None).expect("opens");
let summary = s.summarize_comments(id, SummarySort::Page).unwrap();
let id = s.open_new("summary.pdf", summary).unwrap();
let shown = page_texts(&s, id).join("\n");
assert!(shown.contains(note) && !shown.contains("[U+"), "{shown}");
}
#[test]
fn a_file_split_around_another_combines_in_order_with_one_bookmark() {
let mut s = Session::new();
@@ -1647,11 +1676,28 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
assert_eq!(s.compare_report(oid, id).map(|_| ()), refused());
assert!(s.compare(oid, oid).is_ok());
assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused());
// The comment summary copies comment text out like the exports do.
assert_eq!(s.summarize_comments(id, SummarySort::Page).map(|_| ()), refused());
// Unencrypted documents allow everything.
let (s, plain) = session_with(1);
assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c)));
}
/// The renderer can open an encrypted file the editing parser rejects. Its permission bits are
/// then unknown, which is not the same as none: copying and printing stay refused.
#[test]
fn an_encrypted_document_without_an_editor_allows_neither_copying_nor_printing() {
let mut s = Session::new();
let id = s.open("r.pdf", None, protected("", "owner", -1 ^ 0b1_0000), None).unwrap();
s.doc_mut(id).unwrap().editor = None;
let d = s.get(id).unwrap();
assert!(d.info.encrypted && d.permissions().is_none());
assert!(!d.allows_copying() && !d.allows_printing());
let (mut s, plain) = session_with(1);
s.doc_mut(plain).unwrap().editor = None;
assert!(s.get(plain).unwrap().allows_copying(), "an unencrypted document is not restricted");
}
/// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead
/// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is
/// still printing, and the owner password lifts the restriction.
@@ -7933,6 +7979,29 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
assert!(!d2.dirty);
}
/// An XFA button's click returns only what the click produced. The alerts and errors of the
/// scripts that ran on open stay in the document's output.
#[test]
fn an_xfa_click_returns_only_its_own_script_output() {
let template = pdfcraft_xfa::fixtures::scripted_template()
.replace("if (qty.rawValue === null) qty.rawValue = 2;", "qty.rawValue = 2; xfa.host.messageBox('opened'); throw new Error('open failed');");
let mut s = Session::new();
let id = s.open("xfa.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::<Vec<_>>(), ["Hello 2"]);
assert!(o.error.is_none(), "{o:?}");
let out = s.take_js_output(id);
assert_eq!(out.alerts.iter().map(|a| a.text.as_str()).collect::<Vec<_>>(), ["opened"]);
assert!(out.errors.iter().any(|e| e.contains("open failed")), "{out:?}");
// A click whose script fails returns that error, and the next click does not repeat it.
let broken =
pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, "throw new Error('click failed');");
let id = s.open("broken.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&broken)), None).unwrap();
s.take_js_output(id);
assert!(s.run_javascript(id, "", Some("hello")).unwrap().error.is_some_and(|e| e.contains("click failed")));
assert!(s.run_javascript(id, "", Some("legacy")).unwrap().error.is_none());
}
/// A repeating table inside an area of a positioned page (as in real Designer forms): every
/// row is laid out, the values Adobe wrote (under the area's parent: areas are
/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here
@@ -10464,6 +10533,8 @@ fn exporting_to_word_keeps_the_page_background() {
assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour");
}
/// A table of contents' entries with dotted leaders export to an office document as paragraphs, in
/// reading order, not as a table.
#[test]
fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
let mut content = String::new();
@@ -10486,7 +10557,8 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
}
/// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and
/// yyyymd are rewritten as yyyymmdd, a date none of them reads is refused, and the data holds the date.
/// yyyymd are rewritten as yyyymmdd, and the data holds the date. Text none of them reads is kept
/// as typed, in the field and the data, as another viewer keeps it.
#[test]
fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() {
let mut s = Session::new();
@@ -10498,18 +10570,21 @@ fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first()
assert_eq!(value(&s), ["20261018"]);
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd");
assert_eq!(value(&s), ["20261108"]);
let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) });
assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it");
assert_eq!(value(&s), ["20261108"]);
// The data holds the date.
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet");
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08"));
let data_of = |s: &Session| {
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet");
data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")).map(str::to_string)
};
assert_eq!(data_of(&s).as_deref(), Some("2026-11-08"));
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }).expect("kept as typed");
assert_eq!(value(&s), ["2618"]);
assert_eq!(data_of(&s).as_deref(), Some("2618"));
}
/// A date the field's own scripts refuse is not tried again in another of an XFA date field's
/// patterns: only a date its format refuses is, which happens before any script runs, so the
/// scripts run once. This validate script counts its runs in `name` and accepts on its second.
/// A date typed in another of an XFA date field's patterns is rewritten before any of the field's
/// scripts run, so they run once, and a date they refuse stays refused. This validate script counts
/// its runs in `name` and accepts on its second.
#[test]
fn a_date_its_validate_script_refuses_is_not_tried_again() {
let mut s = Session::new();
@@ -10519,7 +10594,7 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() {
let script = Some("var n = Number(getField('name').value || 0) + 1; getField('name').value = String(n); event.rc = n >= 2;".to_string());
s.apply(id, Edit::SetFieldScript { name: "leave".into(), event: "validate".into(), script }).unwrap();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
// Its format reads 18OCT26, and so does its DDMMMYY pattern.
// Its DDMMMYY pattern reads 18OCT26, which its format would read more loosely too.
let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("18OCT26".into()) });
assert!(refused.is_err(), "the validate script refused it, once");
assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name"));
+341
View File
@@ -0,0 +1,341 @@
//! Web and email addresses written as plain page text, with no link annotation over them (#4207).
//!
//! The viewer makes them clickable the way link annotations are: the address opens only through
//! [`crate::links::document_url`] and the confirmation, like any link from a document. No clause
//! of ISO 32000-2 covers this (it defines link annotations, not reading addresses out of page
//! text); the detector is the one Create links from URLs uses ([`pdfcraft_annot::links::find_urls`]).
use pdfcraft_render::{PageInfo, PageText};
/// Pages with more glyphs than this aren't searched for addresses: the work is linear, but it is
/// done for each page as it comes into view.
pub const MAX_TEXT_LINK_GLYPHS: usize = 200_000;
/// At most this many addresses are found on one page.
pub const MAX_TEXT_LINKS_PER_PAGE: usize = 512;
/// A scan of a document's pages for addresses ([`crate::Session::text_links`]) reads at most this
/// many pages, and says where the next one starts: a 10,000-page document is read a share at a
/// time, never all at once. The viewer reads only the pages in view.
pub const MAX_TEXT_LINK_PAGES_PER_SCAN: usize = 100;
/// A scan stops after the page on which it has found this many addresses.
pub const MAX_TEXT_LINKS_PER_SCAN: usize = 2_000;
/// The addresses one scan of a document found ([`crate::Session::text_links`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextLinkScan {
/// (0-based page, address), in page order.
pub links: Vec<(usize, TextLink)>,
/// The 0-based page the next scan starts on, when the scan stopped before the last page.
pub next_page: Option<usize>,
}
/// An address written in a page's text.
#[derive(Clone, Debug, PartialEq)]
pub struct TextLink {
/// Where it is written: one rectangle per line, in view space (points from the displayed
/// page's top-left, y down, the space of [`PageText`]).
pub rects: Vec<[f32; 4]>,
/// The address it opens: as written for `http://` and `https://`, `https://` added for
/// `www.`, and `mailto:` for an email address. Not yet checked: the opener must
/// ([`crate::links::document_url`]).
pub uri: String,
}
impl TextLink {
/// Whether the view-space point `(x, y)` is on the address.
pub fn contains(&self, x: f32, y: f32) -> bool {
self.rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3])
}
/// Whether a link annotation with the user-space rectangle `rect` (normalized) lies over part
/// of the address on `page`: the annotation is what the document asks for there.
pub fn covered_by(&self, page: &PageInfo, rect: [f32; 4]) -> bool {
let (a, b) = (page.user_to_view(rect[0], rect[1]), page.user_to_view(rect[2], rect[3]));
let v = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
self.rects.iter().any(|r| r[0] < v[2] && v[0] < r[2] && r[1] < v[3] && v[1] < r[3])
}
}
/// The web addresses (`http://`, `https://`, `www.`) and email addresses written in `text`, in
/// reading order. Punctuation that ends a sentence (`.`, `,`, `;`, `:`, `!`, `?`) and brackets
/// around an address are not part of it. Nothing is found on a page of more than
/// [`MAX_TEXT_LINK_GLYPHS`] glyphs, and at most [`MAX_TEXT_LINKS_PER_PAGE`] addresses are kept. An
/// address that starts or ends inside a glyph carrying several characters is left out.
pub fn find_text_links(text: &PageText) -> Vec<TextLink> {
if text.glyphs.len() > MAX_TEXT_LINK_GLYPHS {
return Vec::new();
}
// Each address with the text it was found in.
let found: std::cell::RefCell<Vec<(Option<String>, String)>> = Default::default();
let hits = text.find_with(|chars| {
let mut urls = pdfcraft_annot::links::find_urls(chars);
urls.truncate(MAX_TEXT_LINKS_PER_PAGE);
let ranges = urls.iter().map(|u| u.0.clone()).collect();
*found.borrow_mut() =
urls.into_iter().map(|(range, uri)| (chars.get(range.clone()).map(|c| c.iter().collect()), web_uri(chars, range, uri))).collect();
ranges
});
let found = found.into_inner();
// `find_with` drops a range it can't place on glyphs; then the two lists no longer pair up,
// and nothing is offered rather than an address on the wrong text.
if hits.len() != found.len() {
return Vec::new();
}
hits.into_iter()
.zip(found)
// A glyph that carries more than one character (a ligature, or a font that maps a glyph to
// `m,`) is taken whole: an address that starts or ends inside one would make the text next
// to it clickable, so it is left out.
.filter(|(glyphs, (written, _))| written.as_deref() == Some(text.text_of(glyphs.clone()).as_str()))
.map(|(glyphs, (_, uri))| TextLink { rects: text.line_rects(glyphs), uri })
.filter(|l| !l.rects.is_empty())
.collect()
}
/// A `www.` address opens over `https://`: `find_urls`, which writes links into the document,
/// keeps `http://` for them, but a reader following an address it was never given should use the
/// secure scheme.
fn web_uri(chars: &[char], range: std::ops::Range<usize>, uri: String) -> String {
let www = "www.".chars().enumerate().all(|(k, w)| chars.get(range.start.saturating_add(k)).is_some_and(|c| c.eq_ignore_ascii_case(&w)));
match uri.strip_prefix("http://") {
Some(rest) if www => format!("https://{rest}"),
_ => uri,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pdfcraft_render::TextGlyph;
/// One line of text, a glyph per character, 6 points apart; `\n` starts the next line.
fn page_text(s: &str) -> PageText {
let mut glyphs = Vec::new();
let (mut line, mut x) = (0u32, 0.0f32);
let mut line_of = Vec::new();
let mut space_before = Vec::new();
let mut gap = false;
for c in s.chars() {
if c == '\n' {
(line, x, gap) = (line + 1, 0.0, false);
continue;
}
if c == ' ' {
(x, gap) = (x + 6.0, true);
continue;
}
let y = 10.0 + line as f32 * 20.0;
let rect = [x, y, x + 6.0, y + 12.0];
glyphs.push(TextGlyph {
text: c.to_string(),
rect,
quad: [[x, y + 12.0], [x + 6.0, y + 12.0], [x + 6.0, y], [x, y]],
direction: [1.0, 0.0],
});
line_of.push(line);
space_before.push(gap);
(x, gap) = (x + 6.0, false);
}
PageText { glyphs, line_of, space_before, truncated: false }
}
fn uris(s: &str) -> Vec<String> {
find_text_links(&page_text(s)).into_iter().map(|l| l.uri).collect()
}
#[test]
fn web_and_email_addresses_in_text_are_found() {
assert_eq!(uris("Visit https://example.com/plain-text-url today"), ["https://example.com/plain-text-url"]);
assert_eq!(uris("or http://example.org/a?b=c#d"), ["http://example.org/a?b=c#d"]);
assert_eq!(uris("Go to www.example.com/no-scheme now"), ["https://www.example.com/no-scheme"]);
assert_eq!(uris("Go to WWW.Example.com"), ["https://WWW.Example.com"]);
assert_eq!(uris("Write to someone@example.com please"), ["mailto:someone@example.com"]);
assert_eq!(
uris("a https://a.example\nb www.b.example\nc c@example.org"),
["https://a.example", "https://www.b.example", "mailto:c@example.org"]
);
}
#[test]
fn sentence_punctuation_and_brackets_are_not_part_of_the_address() {
assert_eq!(uris("See https://example.com."), ["https://example.com"]);
assert_eq!(uris("See https://example.com/x, then"), ["https://example.com/x"]);
assert_eq!(uris("(see https://example.com/x)"), ["https://example.com/x"]);
assert_eq!(uris("See www.example.com!"), ["https://www.example.com"]);
assert_eq!(uris("Mail someone@example.com."), ["mailto:someone@example.com"]);
assert_eq!(uris("Ask (someone@example.com)."), ["mailto:someone@example.com"]);
}
#[test]
fn text_that_only_looks_like_an_address_is_not_found() {
for s in ["example.com", "http://", "https://x", "www.", "user@localhost", "a@b.c", "notwww.example.com", "Version 1.2.3", "@example.com"] {
assert_eq!(uris(s), Vec::<String>::new(), "{s}");
}
}
/// Only `http`, `https` and `mailto` addresses are offered, whatever letter case another
/// scheme is written in: `javascript:`, `file:`, `data:` and any other scheme never become a
/// link.
#[test]
fn only_web_and_email_addresses_are_offered() {
for s in [
"JaVaScRiPt:alert(1)",
"javascript:void(0)",
"file:///etc/passwd",
"FILE://server/share/report.pdf",
"ftp://ftp.example.com/pub/file.txt",
"data:text/html,<b>example.com</b>",
"data:text/html;base64,PGI+ZXhhbXBsZTwvYj4=",
"myapp://open?doc=example.com",
"vbscript:msgbox(1)",
"tel:+15551234567",
] {
assert_eq!(uris(s), Vec::<String>::new(), "{s}");
}
// An address inside one written with another scheme is not taken out of it and opened
// with a scheme the text doesn't say.
for s in [
"ftp://www.example.com/pub",
"file://www.example.com/share",
"JavaScript:www.example.com",
"ftp://user@example.com/pub",
"smb://admin@example.org/share",
] {
assert_eq!(uris(s), Vec::<String>::new(), "{s}");
}
// Every address offered opens with one of the three schemes, and passes the check every
// document link gets.
let offered = uris("a https://a.example b HTTP://B.EXAMPLE c www.c.example d d@example.org");
assert_eq!(offered.len(), 4, "{offered:?}");
for uri in offered {
let scheme = uri.split_once(':').map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default();
assert!(["http", "https", "mailto"].contains(&scheme.as_str()), "{uri}");
assert!(crate::links::document_url(&uri).is_ok(), "{uri}");
}
}
/// The text the hand appears over is the address that opens: the rectangle covers exactly
/// the characters of the address, the link check passes the address on unchanged, and the
/// only addition is the scheme a `www.` or email address is written without.
#[test]
fn the_address_opened_is_the_address_written() {
for (s, written, opened) in [
("Visit https://example.com/a?b=c&d=%20e#f now", "https://example.com/a?b=c&d=%20e#f", "https://example.com/a?b=c&d=%20e#f"),
("Visit HTTPS://EXAMPLE.COM/Path now", "HTTPS://EXAMPLE.COM/Path", "HTTPS://EXAMPLE.COM/Path"),
("Go to www.Example.com/X now", "www.Example.com/X", "https://www.Example.com/X"),
("Mail Some.One+x@Example.org now", "Some.One+x@Example.org", "mailto:Some.One+x@Example.org"),
// Internationalized and punycode host names are kept as written; the confirmation
// shows the host and warns about mixed scripts (`links::display_host`).
("See https://bücher.example/straße now", "https://bücher.example/straße", "https://bücher.example/straße"),
("See https://xn--bcher-kva.example/ now", "https://xn--bcher-kva.example/", "https://xn--bcher-kva.example/"),
] {
let links = find_text_links(&page_text(s));
assert_eq!(links.len(), 1, "{s}");
assert_eq!(links[0].uri, opened, "{s}");
assert!(opened.ends_with(written), "{s}: only a scheme is added");
assert_eq!(crate::links::document_url(&links[0].uri).ok().as_deref(), Some(opened), "{s}: opened unchanged");
// `page_text` gives every character, spaces included, 6 points.
let at = s.find(written).and_then(|b| s.get(..b)).map(|p| p.chars().count()).expect("written in s");
let len = written.chars().count();
assert_eq!(links[0].rects, vec![[at as f32 * 6.0, 10.0, (at + len) as f32 * 6.0, 22.0]], "{s}");
}
// An address hiding characters is offered as written, and the link check refuses it.
let hidden = uris("https://exa\u{200B}mple.com/x");
assert_eq!(hidden, ["https://exa\u{200B}mple.com/x"]);
assert!(crate::links::document_url(&hidden[0]).is_err());
}
/// A full stop, comma or closing bracket after an address belongs to the sentence; brackets
/// inside an address (a Wikipedia title) belong to it when they pair up.
#[test]
fn punctuation_after_an_address_is_left_out_and_paired_brackets_kept() {
for (s, uri) in [
("See https://example.com/x.", "https://example.com/x"),
("See https://example.com/x...", "https://example.com/x"),
("Is it https://example.com/x?", "https://example.com/x"),
("(see https://example.com/x).", "https://example.com/x"),
("[https://example.com/x]", "https://example.com/x"),
("\"https://example.com/x\",", "https://example.com/x"),
("<https://example.com/x>", "https://example.com/x"),
("See https://en.wikipedia.org/wiki/Mercury_(planet).", "https://en.wikipedia.org/wiki/Mercury_(planet)"),
("(see https://en.wikipedia.org/wiki/Mercury_(planet)).", "https://en.wikipedia.org/wiki/Mercury_(planet)"),
("See www.example.com/a_(b)_c, then", "https://www.example.com/a_(b)_c"),
("See https://example.com/a(b and more", "https://example.com/a"),
("See https://example.com/a)b", "https://example.com/a"),
("(someone@example.com).", "mailto:someone@example.com"),
] {
assert_eq!(uris(s), [uri], "{s}");
}
}
/// An address ends where its line ends: the next line is never joined to it, because the page
/// text can't tell an address wrapped onto it from the start of the next sentence.
#[test]
fn an_address_ends_at_the_end_of_its_line() {
let links = find_text_links(&page_text("Read https://example.com/first\npart and more"));
assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::<Vec<_>>(), ["https://example.com/first"]);
assert_eq!(links[0].rects.len(), 1, "one line");
assert!(!links[0].contains(3.0, 35.0), "nothing on the next line is part of it");
}
#[test]
fn an_address_is_found_where_it_is_written() {
let links = find_text_links(&page_text("ab https://example.com cd"));
// "ab " is 3 characters of 6 points; the address is 19 characters on the first line.
assert_eq!(links.len(), 1);
assert_eq!(links[0].rects, vec![[18.0, 10.0, 132.0, 22.0]]);
assert!(links[0].contains(20.0, 15.0) && links[0].contains(131.0, 21.0));
assert!(!links[0].contains(10.0, 15.0), "the text before it");
assert!(!links[0].contains(140.0, 15.0), "the text after it");
assert!(!links[0].contains(20.0, 30.0), "the line below");
}
#[test]
fn a_link_annotation_over_the_address_covers_it() {
let page = PageInfo { width: 300.0, height: 300.0, label: String::new(), crop: [0.0, 0.0, 300.0, 300.0], rotation: 0 };
let link = &find_text_links(&page_text("ab https://example.com cd"))[0];
// View y 10–22 is user y 278–290.
assert!(link.covered_by(&page, [100.0, 280.0, 120.0, 285.0]));
assert!(!link.covered_by(&page, [100.0, 200.0, 120.0, 250.0]), "a link elsewhere on the page");
assert!(!link.covered_by(&page, [140.0, 280.0, 160.0, 285.0]), "a link after it on the line");
}
/// `text` with glyph `i` and the one after it drawn as one glyph carrying both characters.
fn merged(mut text: PageText, i: usize) -> PageText {
let next = text.glyphs.remove(i + 1);
text.line_of.remove(i + 1);
text.space_before.remove(i + 1);
let g = &mut text.glyphs[i];
g.text.push_str(&next.text);
g.rect[2] = next.rect[2];
text
}
#[test]
fn an_address_ending_inside_a_glyph_is_not_offered() {
// "m," is one glyph: the address ends on its first character, and its rectangle would
// take the comma too.
let s = "See https://example.com, then";
// Glyph indexes skip the one space before the address.
let m = s.find(',').expect("a comma") - 2;
assert!(find_text_links(&merged(page_text(s), m)).is_empty());
// A glyph of several characters inside the address is part of it.
let links = find_text_links(&merged(page_text("See https://fi.example.com"), 11));
assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::<Vec<_>>(), ["https://fi.example.com"]);
}
#[test]
fn work_per_page_is_capped() {
let many = "a@example.org ".repeat(MAX_TEXT_LINKS_PER_PAGE + 5);
assert_eq!(find_text_links(&page_text(&many)).len(), MAX_TEXT_LINKS_PER_PAGE);
let mut big = page_text("https://example.com");
let filler = big.glyphs[0].clone();
big.glyphs.resize(MAX_TEXT_LINK_GLYPHS + 1, filler);
big.line_of.resize(MAX_TEXT_LINK_GLYPHS + 1, 0);
big.space_before.resize(MAX_TEXT_LINK_GLYPHS + 1, false);
assert!(find_text_links(&big).is_empty());
assert!(find_text_links(&PageText::default()).is_empty());
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
use super::Zip;
use super::as_html::{esc, language_tag};
use super::layout::{Image, Item, Page, Table, body_size, items};
use super::layout::{Image, Item, Page, Table, body_size, items, reading_order};
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] {
@@ -156,7 +156,10 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha
// page-sized watermark cannot push the invoice footer onto another page.
let mut s = String::from("<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr>");
let body = body_size(std::slice::from_ref(page)).max(1.0);
for b in &page.blocks {
// Rotated pages are positioned shapes, so paragraph flow never orders them. Emit the
// text boxes in reading order; the file's block order is not the reading order.
let blocks = reading_order(page, page.blocks.iter().map(|b| (b.rect, b.size, b)).collect());
for b in blocks {
*shape_id = shape_id.saturating_add(1);
let rotation = layout_number(-b.rotation).rem_euclid(360.0);
let (sin, cos) = rotation.to_radians().sin_cos();
+121 -8
View File
@@ -173,6 +173,9 @@ fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
.iter()
.enumerate()
.map(|(bi, b)| {
if b.rotation.abs() > 0.1 {
return None;
}
let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size);
by_end
.get(k..)
@@ -181,7 +184,10 @@ fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
.take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size))
.take(CONTINUATION_PROBE)
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a)))
.filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
.filter(|(_, a)| {
a.rotation.abs() <= 0.1
&& a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])
})
.min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs()))
.map(|(ai, _)| ai)
})
@@ -581,6 +587,114 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
(out, consumed)
}
/// The dominant text rotation on `page` (in degrees, counterclockwise), weighted by character
/// count, or `0.0` when the page's text is upright.
fn page_rotation(page: &Page) -> f64 {
let mut by: Vec<(i64, f64, usize)> = Vec::new();
for b in &page.blocks {
if !b.rotation.is_finite() {
continue;
}
let k = b.rotation.round() as i64;
if let Some(e) = by.iter_mut().find(|e| e.0 == k) {
e.2 = e.2.saturating_add(b.text.len());
} else {
by.push((k, b.rotation, b.text.len()));
}
}
by.into_iter().max_by_key(|e| e.2).filter(|e| e.1.abs() > 0.1).map_or(0.0, |e| e.1)
}
/// Transform `rect` (`[x0, y0, x1, y1]`, `y` up) into the reading frame of text turned by
/// `rotation` degrees counterclockwise (`[along0, across0, along1, across1]`). For upright text
/// (`0°`), this is `rect` itself; for text turned 90° clockwise (`-90°`), `across` decreases from
/// right to left so lines read in their natural order (#3657).
fn in_reading_frame(rect: [f64; 4], rotation: f64) -> [f64; 4] {
if rotation.abs() <= 0.1 || !rotation.is_finite() {
return rect;
}
let (sin, cos) = rotation.to_radians().sin_cos();
let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[0], rect[3]), (rect[2], rect[3])];
let mut out = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for (x, y) in corners {
let along = x * cos + y * sin;
let across = y * cos - x * sin;
out = [out[0].min(along), out[1].min(across), out[2].max(along), out[3].max(across)];
}
out
}
/// Put a page's items (`(rect, line_height, item)`) into reading order (#3657):
/// - coordinates are projected into the page's reading frame so turned text reads from first line
/// to last line rather than left-to-right across vertical lines;
/// - a column continues across a paragraph gap (up to 3.5 line heights below the previous item)
/// before jumping to the top of the next column on its right, unless an unread item sits above
/// the continuation in its own horizontal span, sits to its left, or continues the previous
/// item on the same line.
pub(crate) fn reading_order<T>(page: &Page, parts: Vec<([f64; 4], f64, T)>) -> Vec<T> {
let rot = page_rotation(page);
let mut parts: Vec<([f64; 4], f64, T)> = parts.into_iter().map(|(r, h, item)| (in_reading_frame(r, rot), h.clamp(1.0, 200.0), item)).collect();
if parts.len() <= 1 {
return parts.into_iter().map(|x| x.2).collect();
}
const MAX_COLUMN_ORDER: usize = 2000;
if parts.len() > MAX_COLUMN_ORDER {
parts.sort_by(|a, b| b.0[3].total_cmp(&a.0[3]).then(a.0[0].total_cmp(&b.0[0])));
return parts.into_iter().map(|x| x.2).collect();
}
let beside_left = |cb: &[f64; 4], lb: &[f64; 4]| {
let v = lb[3].min(cb[3]) - lb[1].max(cb[1]);
lb[2] <= cb[0] + COL_TOL && v > 0.5 * (lb[3] - lb[1]).min(cb[3] - cb[1])
};
let inline_after = |pb: &[f64; 4], ph: f64, rb: &[f64; 4]| {
let v = rb[3].min(pb[3]) - rb[1].max(pb[1]);
rb[0] >= pb[2] - 1.0 && rb[0] <= pb[2] + INLINE_GAP * ph && v > 0.5 * (rb[3] - rb[1]).min(pb[3] - pb[1])
};
let mut remaining: Vec<usize> = (0..parts.len()).collect();
let mut order: Vec<usize> = Vec::with_capacity(parts.len());
while let Some(&top) = remaining.iter().max_by(|&&a, &&b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0]))) {
let next_in_column = order.last().and_then(|&prev| {
let (pb, ph) = (parts[prev].0, parts[prev].1);
if remaining.iter().any(|&r| inline_after(&pb, ph, &parts[r].0)) {
return None;
}
let tol = ph * 0.5;
let c = remaining
.iter()
.copied()
.filter(|&c| {
let cb = parts[c].0;
let overlap = cb[2].min(pb[2]) - cb[0].max(pb[0]);
let minw = (cb[2] - cb[0]).min(pb[2] - pb[0]).max(1.0);
cb[3] <= pb[1] + tol && pb[1] - cb[3] <= ph * 3.5 && overlap > minw * 0.3
})
.max_by(|&a, &b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0])))?;
let cb = parts[c].0;
let blocked = remaining.iter().any(|&r| {
if r == c {
return false;
}
let rb = parts[r].0;
(rb[3] > cb[3] + tol && rb[2].min(cb[2]) > rb[0].max(cb[0])) || beside_left(&cb, &rb)
});
(!blocked).then_some(c)
});
let pick = next_in_column.unwrap_or_else(|| {
let tb = parts[top].0;
remaining
.iter()
.copied()
.filter(|&c| beside_left(&tb, &parts[c].0))
.min_by(|&a, &b| parts[a].0[0].total_cmp(&parts[b].0[0]).then(parts[b].0[3].total_cmp(&parts[a].0[3])))
.unwrap_or(top)
});
remaining.retain(|&r| r != pick);
order.push(pick);
}
let mut slots: Vec<Option<T>> = parts.into_iter().map(|(_, _, item)| Some(item)).collect();
order.into_iter().filter_map(|i| slots.get_mut(i).and_then(Option::take)).collect()
}
pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
let body = body_size(pages);
let mut out = Vec::new();
@@ -589,21 +703,20 @@ pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
out.push(Item::PageBreak);
}
let (tables, consumed) = tables(&p.blocks, &p.rules);
// Blocks and images by their top edge, top to bottom.
// A paragraph sits where its first run does: its other runs follow along its line and the
// lines it wraps onto.
let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body)
let mut parts: Vec<([f64; 4], f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body)
.into_iter()
.filter_map(|runs| {
let first = runs.first()?.block;
let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max);
let lvl = para_level(&runs, body);
Some((first.rect[3], first.rect[0], Item::Para(runs, lvl)))
Some(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, Item::Para(runs, lvl)))
})
.collect();
parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t))));
parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im))));
parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1)));
out.extend(parts.into_iter().map(|x| x.2));
parts.extend(tables.into_iter().map(|t| (t.rect, body, Item::Table(t))));
parts.extend(p.images.iter().map(|im| (im.rect, (im.rect[3] - im.rect[1]).max(body), Item::Img(im))));
out.extend(reading_order(p, parts));
}
out
}

Some files were not shown because too many files have changed in this diff Show More