mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
Merge upstream/main into pr/print-area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
447d9f0267
183 files changed
+11275
-1445
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
+2
@@ -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",
|
||||
|
||||
@@ -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,6 +472,8 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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:?}"));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+138
-29
@@ -340,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}"),
|
||||
@@ -391,6 +399,48 @@ 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>>,
|
||||
@@ -418,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);
|
||||
@@ -514,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)
|
||||
}
|
||||
@@ -523,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();
|
||||
@@ -540,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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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())),
|
||||
|
||||
+73
-16
@@ -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()));
|
||||
|
||||
+480
-3
@@ -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();
|
||||
@@ -3050,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]]] };
|
||||
@@ -3541,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
|
||||
|
||||
@@ -557,6 +557,20 @@ impl Automation {
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
|
||||
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
|
||||
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -835,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)?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,14 +38,8 @@ 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 (or a size such as "8.5x14in", whose index is past the list); US Letter when
|
||||
/// none is given.
|
||||
/// index and size; 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(),
|
||||
@@ -57,7 +51,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 {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
|
||||
"unknown paper {:?}; choose one of {}",
|
||||
name.unwrap_or_default(),
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
@@ -253,19 +247,6 @@ 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::*;
|
||||
@@ -318,19 +299,7 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,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!({
|
||||
@@ -741,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", "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)." },
|
||||
"paper": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
@@ -1333,6 +1333,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 },
|
||||
@@ -1544,7 +1549,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!({
|
||||
@@ -1674,7 +1679,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!({
|
||||
@@ -1691,6 +1696,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" },
|
||||
|
||||
@@ -2311,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.
|
||||
@@ -3561,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");
|
||||
@@ -7565,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]
|
||||
@@ -7585,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> {
|
||||
@@ -10961,32 +11161,6 @@ 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]
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+251
-11
@@ -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";
|
||||
@@ -5561,6 +5787,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<_>>();
|
||||
@@ -9360,7 +9600,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(
|
||||
|
||||
@@ -492,6 +492,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 +525,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +659,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 {
|
||||
@@ -664,13 +670,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,
|
||||
}
|
||||
@@ -711,6 +721,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;
|
||||
@@ -722,6 +733,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`).
|
||||
@@ -815,6 +827,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
hard_break: state.hard_break,
|
||||
soft_gap: state.soft_gap,
|
||||
});
|
||||
carry.gap_space = gap_space;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -848,6 +861,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.
|
||||
|
||||
@@ -701,7 +701,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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -397,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 {
|
||||
|
||||
@@ -260,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"),
|
||||
|
||||
@@ -144,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 { .. }
|
||||
@@ -799,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 })?;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -311,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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -138,7 +138,7 @@ mod tests {
|
||||
space_before.push(gap);
|
||||
(x, gap) = (x + 6.0, false);
|
||||
}
|
||||
PageText { glyphs, line_of, space_before }
|
||||
PageText { glyphs, line_of, space_before, truncated: false }
|
||||
}
|
||||
|
||||
fn uris(s: &str) -> Vec<String> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -42,7 +42,7 @@ use flow::list_item;
|
||||
pub use layout::{Block, Cell, Image, Page, Table};
|
||||
#[cfg(test)]
|
||||
use layout::{LOOKUP_STEPS, MAX_COLS, body_size};
|
||||
pub(crate) use layout::{Run, flowing_paragraphs, tables};
|
||||
pub(crate) use layout::{Run, flowing_paragraphs, reading_order, tables};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -137,20 +137,21 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
.max_by(|(_, a), (_, b)| a.size.total_cmp(&b.size))
|
||||
.map(|(i, _)| i);
|
||||
|
||||
let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new();
|
||||
let mut parts: Vec<([f64; 4], f64, SlideBodyItem)> = Vec::new();
|
||||
// The title is a shape of its own, so it joins no paragraph.
|
||||
let skip: Vec<bool> = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect();
|
||||
for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs)));
|
||||
let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max);
|
||||
parts.push(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, SlideBodyItem::Para(runs)));
|
||||
}
|
||||
for t in tables {
|
||||
items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t)));
|
||||
parts.push((t.rect, 11.0, SlideBodyItem::Table(t)));
|
||||
}
|
||||
for im in &p.images {
|
||||
items.push((im.rect[3], im.rect[0], SlideBodyItem::Img(im)));
|
||||
parts.push((im.rect, (im.rect[3] - im.rect[1]).max(11.0), SlideBodyItem::Img(im)));
|
||||
}
|
||||
items.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1)));
|
||||
let items = crate::reading_order(p, parts);
|
||||
|
||||
let mut current_y = my;
|
||||
let mut shapes = String::new();
|
||||
@@ -170,12 +171,12 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
while let Some((_, _, item)) = items.get(i) {
|
||||
while let Some(item) = items.get(i) {
|
||||
match item {
|
||||
SlideBodyItem::Para(_) => {
|
||||
let mut paras_xml = String::new();
|
||||
let mut est_h_pt = 0.0;
|
||||
while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) {
|
||||
while let Some(SlideBodyItem::Para(runs)) = items.get(i) {
|
||||
paras_xml.push_str("<a:p>");
|
||||
for r in runs {
|
||||
paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
||||
|
||||
@@ -922,6 +922,100 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
|
||||
assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}");
|
||||
}
|
||||
|
||||
/// #3657: two-column text finishes the left column before starting the right column across
|
||||
/// Word, HTML, RTF, Markdown and PowerPoint, and text turned 90° exports in reading order rather
|
||||
/// than last line first.
|
||||
#[test]
|
||||
fn two_column_and_turned_text_export_in_reading_order() {
|
||||
let para = |t: &str, x: f64, y0: f64, y1: f64, size: f64| Block {
|
||||
text: t.into(),
|
||||
rect: [x, y0, x + 130.0, y1],
|
||||
size,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let columns = Page {
|
||||
width: 595.0,
|
||||
height: 842.0,
|
||||
blocks: vec![
|
||||
para("Two columns", 50.0, 790.0, 808.0, 18.0),
|
||||
para("A1 first paragraph A2 first paragraph A3 first paragraph", 50.0, 720.0, 762.0, 12.0),
|
||||
para("B1 second paragraph B2 second paragraph", 50.0, 675.0, 702.0, 12.0),
|
||||
para("B3 second paragraph B4 second paragraph", 320.0, 735.0, 762.0, 12.0),
|
||||
para("C1 third paragraph C2 third paragraph", 320.0, 695.0, 722.0, 12.0),
|
||||
],
|
||||
images: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
background: None,
|
||||
};
|
||||
let expected_cols = ["Two columns", "A1 first paragraph", "B1 second paragraph", "B3 second paragraph", "C1 third paragraph"];
|
||||
for (fmt, out) in [
|
||||
("html", html(std::slice::from_ref(&columns), "Columns")),
|
||||
("docx", part(&docx(std::slice::from_ref(&columns), "Columns"), "word/document.xml")),
|
||||
("rtf", rtf(std::slice::from_ref(&columns))),
|
||||
("md", markdown(std::slice::from_ref(&columns))),
|
||||
("pptx", part(&pptx(std::slice::from_ref(&columns), "Columns"), "ppt/slides/slide1.xml")),
|
||||
] {
|
||||
let positions: Vec<usize> = expected_cols.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect();
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}");
|
||||
}
|
||||
|
||||
// Text turned 90 degrees clockwise (rotation = -90°): lines run top to bottom (y = 540
|
||||
// down to 468) and follow each other right to left (x = 676, 654.4, 632.8, 611.2).
|
||||
let turned_line = |t: &str, x: f64, size: f64| Block {
|
||||
text: t.into(),
|
||||
rect: [x - size, 468.0, x, 540.0],
|
||||
size,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: -90.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let rotated = Page {
|
||||
width: 792.0,
|
||||
height: 612.0,
|
||||
blocks: vec![
|
||||
turned_line("Line one", 676.0, 20.0),
|
||||
turned_line("Line two", 654.4, 18.0),
|
||||
turned_line("Line three", 632.8, 18.0),
|
||||
turned_line("Line four", 611.2, 18.0),
|
||||
],
|
||||
images: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
background: None,
|
||||
};
|
||||
let expected_lines = ["Line one", "Line two", "Line three", "Line four"];
|
||||
for (fmt, out) in [
|
||||
("html", html(std::slice::from_ref(&rotated), "Rotated")),
|
||||
("docx", part(&docx(std::slice::from_ref(&rotated), "Rotated"), "word/document.xml")),
|
||||
("rtf", rtf(std::slice::from_ref(&rotated))),
|
||||
("md", markdown(std::slice::from_ref(&rotated))),
|
||||
("pptx", part(&pptx(std::slice::from_ref(&rotated), "Rotated"), "ppt/slides/slide1.xml")),
|
||||
] {
|
||||
let positions: Vec<usize> = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect();
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}");
|
||||
}
|
||||
|
||||
// The same lines, stored bottom-to-top. Rotated lines do not join one paragraph, and
|
||||
// Word's positioned boxes must follow reading order rather than the file's block order.
|
||||
let mut shuffled = rotated.clone();
|
||||
shuffled.blocks.reverse();
|
||||
for (fmt, out) in [
|
||||
("html", html(std::slice::from_ref(&shuffled), "Rotated")),
|
||||
("docx", part(&docx(std::slice::from_ref(&shuffled), "Rotated"), "word/document.xml")),
|
||||
("rtf", rtf(std::slice::from_ref(&shuffled))),
|
||||
("md", markdown(std::slice::from_ref(&shuffled))),
|
||||
("pptx", part(&pptx(std::slice::from_ref(&shuffled), "Rotated"), "ppt/slides/slide1.xml")),
|
||||
] {
|
||||
let positions: Vec<usize> = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect();
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} shuffled out of order: {positions:?}\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps
|
||||
/// its borders; the same text without rules (or with rules that only underline it) stays paragraphs.
|
||||
#[test]
|
||||
|
||||
+277
-39
@@ -1,4 +1,6 @@
|
||||
//! A minimal zip writer (deflate or store, no zip64) for Office files.
|
||||
//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic
|
||||
//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the
|
||||
//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
@@ -22,12 +24,102 @@ fn crc32(data: &[u8]) -> u32 {
|
||||
c ^ 0xFFFF_FFFF
|
||||
}
|
||||
|
||||
/// Builds a zip archive in memory.
|
||||
/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12).
|
||||
const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64;
|
||||
/// The largest size or offset the classic records hold: past it the field is a sentinel and the
|
||||
/// ZIP64 record carries the real value (APPNOTE 4.4.1).
|
||||
const MAX_CLASSIC_U32: u64 = u32::MAX as u64;
|
||||
|
||||
/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that
|
||||
/// no longer fit their classic fields — in specification order. Empty when everything fits.
|
||||
fn zip64_extra(fields: &[u64]) -> Vec<u8> {
|
||||
let mut e = Vec::with_capacity(4 + 8 * fields.len());
|
||||
if !fields.is_empty() {
|
||||
e.extend(0x0001u16.to_le_bytes());
|
||||
e.extend((8 * fields.len() as u16).to_le_bytes());
|
||||
for &f in fields {
|
||||
e.extend(f.to_le_bytes());
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it
|
||||
/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real
|
||||
/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3).
|
||||
fn classic_u32(value: u64) -> u32 {
|
||||
if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 }
|
||||
}
|
||||
|
||||
/// Builds one member's local file header and central directory entry. The sizes and `offset` are
|
||||
/// parameters, not read from the body, so tests can inject values past the classic fields without
|
||||
/// allocating the data.
|
||||
fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec<u8>, Vec<u8>) {
|
||||
// A value at or above the sentinel cannot be held by a classic field, so it is offloaded.
|
||||
let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32;
|
||||
// A local header that carries ZIP64 sizes carries both, original then compressed, even when only
|
||||
// one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its
|
||||
// sentinel, and only those, so a reader walking the fields by their classic ones stays in step.
|
||||
let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() };
|
||||
let mut central_fields = Vec::new();
|
||||
if uncompressed >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(uncompressed);
|
||||
}
|
||||
if compressed >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(compressed);
|
||||
}
|
||||
if offset >= MAX_CLASSIC_U32 {
|
||||
central_fields.push(offset);
|
||||
}
|
||||
let central_extra = zip64_extra(¢ral_fields);
|
||||
// An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version
|
||||
// in the local header and the central entry agree.
|
||||
let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 };
|
||||
let header = |sig: u32, central: bool| {
|
||||
let mut h = Vec::new();
|
||||
h.extend(sig.to_le_bytes());
|
||||
if central {
|
||||
h.extend(20u16.to_le_bytes()); // made by
|
||||
}
|
||||
h.extend(version_needed.to_le_bytes()); // version needed
|
||||
h.extend(0x0800u16.to_le_bytes()); // UTF-8 names
|
||||
h.extend(method.to_le_bytes());
|
||||
h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date)
|
||||
h.extend(crc.to_le_bytes());
|
||||
// A local ZIP64 extra carries both sizes, so both corresponding classic fields are
|
||||
// sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries
|
||||
// remain selective and use a sentinel only for fields represented in their extra.
|
||||
let (compressed_field, uncompressed_field) =
|
||||
if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) };
|
||||
h.extend(compressed_field.to_le_bytes());
|
||||
h.extend(uncompressed_field.to_le_bytes());
|
||||
h.extend((name.len() as u16).to_le_bytes());
|
||||
let extra = if central { ¢ral_extra } else { &local_extra };
|
||||
h.extend((extra.len() as u16).to_le_bytes()); // extra
|
||||
if central {
|
||||
h.extend(0u16.to_le_bytes()); // comment
|
||||
h.extend(0u16.to_le_bytes()); // disk
|
||||
h.extend(0u16.to_le_bytes()); // internal attributes
|
||||
h.extend(0u32.to_le_bytes()); // external attributes
|
||||
h.extend(classic_u32(offset).to_le_bytes());
|
||||
}
|
||||
h.extend(name.as_bytes());
|
||||
h.extend(extra);
|
||||
h
|
||||
};
|
||||
(header(0x0403_4b50, false), header(0x0201_4b50, true))
|
||||
}
|
||||
|
||||
/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or
|
||||
/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a
|
||||
/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with
|
||||
/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps
|
||||
/// and readers see a central directory that does not describe the archive.
|
||||
#[derive(Default)]
|
||||
pub struct Zip {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Zip {
|
||||
@@ -41,49 +133,44 @@ impl Zip {
|
||||
} else {
|
||||
(0u16, data.to_vec())
|
||||
};
|
||||
let offset = self.out.len() as u32;
|
||||
let header = |sig: u32, central: bool| {
|
||||
let mut h = Vec::new();
|
||||
h.extend(sig.to_le_bytes());
|
||||
if central {
|
||||
h.extend(20u16.to_le_bytes()); // made by
|
||||
}
|
||||
h.extend(20u16.to_le_bytes()); // version needed
|
||||
h.extend(0x0800u16.to_le_bytes()); // UTF-8 names
|
||||
h.extend(method.to_le_bytes());
|
||||
h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date)
|
||||
h.extend(crc.to_le_bytes());
|
||||
h.extend((body.len() as u32).to_le_bytes());
|
||||
h.extend((data.len() as u32).to_le_bytes());
|
||||
h.extend((name.len() as u16).to_le_bytes());
|
||||
h.extend(0u16.to_le_bytes()); // extra
|
||||
if central {
|
||||
h.extend(0u16.to_le_bytes()); // comment
|
||||
h.extend(0u16.to_le_bytes()); // disk
|
||||
h.extend(0u16.to_le_bytes()); // internal attributes
|
||||
h.extend(0u32.to_le_bytes()); // external attributes
|
||||
h.extend(offset.to_le_bytes());
|
||||
}
|
||||
h.extend(name.as_bytes());
|
||||
h
|
||||
};
|
||||
self.out.extend(header(0x0403_4b50, false));
|
||||
let offset = self.out.len() as u64;
|
||||
let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset);
|
||||
self.out.extend(local);
|
||||
self.out.extend(&body);
|
||||
self.central.extend(header(0x0201_4b50, true));
|
||||
self.central.extend(central);
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
let start = self.out.len() as u32;
|
||||
let size = self.central.len() as u32;
|
||||
let start = self.out.len() as u64;
|
||||
let size = self.central.len() as u64;
|
||||
let count = self.count as u64;
|
||||
self.out.append(&mut self.central);
|
||||
let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 };
|
||||
if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 {
|
||||
let eocd64_at = self.out.len() as u64;
|
||||
self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record
|
||||
self.out.extend(44u64.to_le_bytes()); // size of the rest of this record
|
||||
self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS)
|
||||
self.out.extend(45u16.to_le_bytes()); // version needed to extract
|
||||
self.out.extend(0u32.to_le_bytes()); // number of this disk
|
||||
self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory
|
||||
self.out.extend(count.to_le_bytes()); // entries on this disk
|
||||
self.out.extend(count.to_le_bytes()); // entries in total
|
||||
self.out.extend(size.to_le_bytes()); // size of the central directory
|
||||
self.out.extend(start.to_le_bytes()); // offset of the central directory
|
||||
self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator
|
||||
self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record
|
||||
self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record
|
||||
self.out.extend(1u32.to_le_bytes()); // total number of disks
|
||||
}
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0u8; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(size.to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend(0u16.to_le_bytes());
|
||||
self.out.extend([0u8; 4]); // number of this disk, disk with the central directory
|
||||
self.out.extend(classic_count.to_le_bytes()); // entries on this disk
|
||||
self.out.extend(classic_count.to_le_bytes()); // entries in total
|
||||
self.out.extend(classic_u32(size).to_le_bytes());
|
||||
self.out.extend(classic_u32(start).to_le_bytes());
|
||||
self.out.extend(0u16.to_le_bytes()); // comment length
|
||||
self.out
|
||||
}
|
||||
}
|
||||
@@ -107,4 +194,155 @@ mod tests {
|
||||
assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2);
|
||||
assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_entries_than_the_classic_record_holds_writes_zip64() {
|
||||
// A PowerPoint export writes two entries per page plus ten fixed parts, so a
|
||||
// document with 32 763 pages already passes the classic record's 16-bit entry
|
||||
// count. The count must not wrap: it overflows in a debug build, and in a release
|
||||
// one it silently writes a central directory readers cannot match to the archive.
|
||||
let mut z = Zip::default();
|
||||
for i in 0..u16::MAX as usize + 1 {
|
||||
z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true);
|
||||
z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true);
|
||||
}
|
||||
let b = z.finish();
|
||||
let entries = 2 * (u16::MAX as usize + 1);
|
||||
// The classic end-of-central-directory record holds sentinels…
|
||||
let eocd = &b[b.len() - 22..];
|
||||
assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes());
|
||||
assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk");
|
||||
assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total");
|
||||
// …and the ZIP64 locator and record carry the truth.
|
||||
let locator = &b[b.len() - 42..b.len() - 22];
|
||||
assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes());
|
||||
let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize;
|
||||
assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes());
|
||||
assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk");
|
||||
assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total");
|
||||
}
|
||||
|
||||
/// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name.
|
||||
fn local_extra(h: &[u8]) -> &[u8] {
|
||||
let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
let (name_len, extra_len) = (le(26, 2), le(28, 2));
|
||||
&h[30 + name_len..30 + name_len + extra_len]
|
||||
}
|
||||
|
||||
/// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name.
|
||||
fn central_extra(h: &[u8]) -> &[u8] {
|
||||
let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
let (name_len, extra_len) = (le(28, 2), le(30, 2));
|
||||
&h[46 + name_len..46 + name_len + extra_len]
|
||||
}
|
||||
|
||||
/// The classic compressed and original size fields of a local file header (`at` 18) or a
|
||||
/// central directory entry (`at` 20).
|
||||
fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) {
|
||||
let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4]));
|
||||
(field(at), field(at + 4))
|
||||
}
|
||||
|
||||
/// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic
|
||||
/// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to
|
||||
/// its sentinel, and the local entry must carry both sizes). The central entry stays selective:
|
||||
/// only the fields that themselves overflow get a value, in specification order.
|
||||
fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) {
|
||||
let at_limit = |v: u64| v >= u32::MAX as u64;
|
||||
let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})");
|
||||
let extra = local_extra(&local);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])),
|
||||
16,
|
||||
"both local sizes are present for ({compressed}, {uncompressed})"
|
||||
);
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
let expected =
|
||||
(if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 });
|
||||
assert_eq!(
|
||||
classic_sizes(¢ral, 20),
|
||||
expected,
|
||||
"the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})"
|
||||
);
|
||||
let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed));
|
||||
let extra = central_extra(¢ral);
|
||||
assert_eq!(
|
||||
u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])),
|
||||
8 * fields as u16,
|
||||
"one central value per sentinel field for ({compressed}, {uncompressed})"
|
||||
);
|
||||
if at_limit(uncompressed) {
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
}
|
||||
if at_limit(compressed) {
|
||||
let at = if at_limit(uncompressed) { 12 } else { 4 };
|
||||
assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() {
|
||||
// A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set
|
||||
// to their sentinel — so a small classic size next to an overflowing one is wrong even
|
||||
// though the extra holds the truth. Both orders of which size overflows.
|
||||
assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1);
|
||||
assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() {
|
||||
// A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64
|
||||
// just like a larger one; the other local size must then become a sentinel too. Both orders.
|
||||
assert_local_pair_and_central_selectivity(100, u32::MAX as u64);
|
||||
assert_local_pair_and_central_selectivity(u32::MAX as u64, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_zip64_extra_carries_both_sizes() {
|
||||
// When only one size overflows the u32 limit, the local header still carries both sizes —
|
||||
// original then compressed — because a local ZIP64 extra that carries either must carry
|
||||
// both (APPNOTE 4.5.3). Either size may be the one past the limit.
|
||||
for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] {
|
||||
let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
let extra = local_extra(&local);
|
||||
assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id");
|
||||
assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present");
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip64_entries_need_version_4_5() {
|
||||
// An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the
|
||||
// local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits
|
||||
// every classic field stays at 2.0, so small archives are unchanged.
|
||||
let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0);
|
||||
assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed");
|
||||
assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed");
|
||||
let (local, central) = entry_headers("a", 0, 0, 4, 8, 0);
|
||||
assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed");
|
||||
assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_equal_to_the_sentinel_is_offloaded() {
|
||||
// A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it
|
||||
// too must be offloaded: every classic field set to the sentinel needs its 8-byte value in
|
||||
// the central extra, or a reader such as Python's zipfile reports "Compress size not
|
||||
// found". Both orders of which size is the MAX+1 one must work.
|
||||
for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] {
|
||||
let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0);
|
||||
assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel");
|
||||
assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel");
|
||||
assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel");
|
||||
assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel");
|
||||
let extra = central_extra(¢ral);
|
||||
assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field");
|
||||
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first");
|
||||
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
original entry is preserved. Positive limits still apply.
|
||||
- Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a
|
||||
failed call changes nothing.
|
||||
- Native AF recognition stops at the first genuine matching function call. If its arguments
|
||||
are unsupported, the complete action remains a custom script for the caller's `Scripts`
|
||||
interpreter; a later literal call must not replace the original conditional validation.
|
||||
Async for-of loop headers and bodies do not expose AF-looking text inside regex literals.
|
||||
- Moving choice fields preserves their own options rather than copying an ancestor's `/Opt`;
|
||||
inherited check-box and radio-button export mappings remain intact.
|
||||
- `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps
|
||||
a property; `Some(None)` removes a border/fill colour. Unchanged `/DA`, custom font resources,
|
||||
widget-specific `/DA` overrides, indirect `/MK` and `/BS` contents, dash arrays and unknown
|
||||
|
||||
+140
-23
@@ -19,13 +19,21 @@ pub(super) enum Js {
|
||||
type Token = (Range<usize>, Js);
|
||||
|
||||
/// Words after which `/` starts a regular expression instead of dividing.
|
||||
const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
|
||||
const BEFORE_REGEX: [&str; 12] = ["return", "typeof", "case", "do", "else", "in", "new", "delete", "void", "throw", "instanceof", "yield"];
|
||||
|
||||
/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a
|
||||
/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so
|
||||
/// a `/` after it starts the next statement, and can't divide.
|
||||
const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"];
|
||||
|
||||
/// Context of an open delimiter. Only the left side of a for header can introduce `of`.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Delimiter {
|
||||
Group,
|
||||
Condition,
|
||||
For { left: bool },
|
||||
}
|
||||
|
||||
/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029.
|
||||
fn line_ends_at(s: &[u8], j: usize) -> bool {
|
||||
matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..]))
|
||||
@@ -46,10 +54,11 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
// Where a regular expression that never ended stopped being read: every `/` before it
|
||||
// divides, so a line of them is read once, not once for each.
|
||||
let mut divides_until = 0;
|
||||
// For each `(` still open, whether it opened the condition of `if`, `while`, `for` or
|
||||
// `with`; and whether the last token is the `)` that closed one.
|
||||
let mut conditions: Vec<bool> = Vec::new();
|
||||
// Nested groups hide the surrounding for header: an `of` in a destructuring default
|
||||
// or nested expression is an identifier, not the for-of separator.
|
||||
let mut delimiters: Vec<(char, Delimiter)> = Vec::new();
|
||||
let mut after_condition = false;
|
||||
let mut after_for_of = false;
|
||||
'scan: while let Some(&c) = s.get(i) {
|
||||
let start = i;
|
||||
let token = match c {
|
||||
@@ -90,7 +99,12 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
loop {
|
||||
match s.get(j) {
|
||||
None => break 'scan,
|
||||
Some(b'\\') => j += 2,
|
||||
Some(b'\\') => {
|
||||
let Some(step) = js.get(j + 1..).and_then(|r| r.chars().next()).map(char::len_utf8) else {
|
||||
break 'scan;
|
||||
};
|
||||
j += 1 + step;
|
||||
}
|
||||
Some(b'`') => break,
|
||||
Some(_) => j += 1,
|
||||
}
|
||||
@@ -98,7 +112,7 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
i = j + 1;
|
||||
Js::Other
|
||||
}
|
||||
b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => {
|
||||
b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition, after_for_of) => {
|
||||
// A regular expression ends at a `/` outside a class; a line break first means it
|
||||
// was a division after all.
|
||||
let (mut j, mut class) = (i + 1, false);
|
||||
@@ -106,7 +120,14 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
match s.get(j) {
|
||||
None => break None,
|
||||
Some(_) if line_ends_at(s, j) => break None,
|
||||
Some(b'\\') => j += 2,
|
||||
Some(b'\\') => {
|
||||
if s.get(j + 1).is_none() || line_ends_at(s, j + 1) {
|
||||
j += 1;
|
||||
break None;
|
||||
}
|
||||
let step = js.get(j + 1..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||||
j += 1 + step;
|
||||
}
|
||||
Some(b'/') if !class => break Some(j),
|
||||
Some(b) => {
|
||||
class = match b {
|
||||
@@ -133,6 +154,16 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
b'+' | b'-'
|
||||
if s.get(i + 1) == Some(&c)
|
||||
&& !regex_may_follow(js, &out, after_condition, after_for_of)
|
||||
&& out.last().is_some_and(|(range, _)| !(range.end..i).any(|j| line_ends_at(s, j))) =>
|
||||
{
|
||||
// Postfix update ends an expression: a following slash divides. A line
|
||||
// terminator forbids postfix syntax, including one inside a comment.
|
||||
i += 2;
|
||||
Js::Other
|
||||
}
|
||||
_ if c.is_ascii_digit() => {
|
||||
while word(i) || s.get(i) == Some(&b'.') {
|
||||
i += 1;
|
||||
@@ -150,38 +181,88 @@ pub(super) fn js_tokens(js: &str) -> (Vec<Token>, Vec<Range<usize>>) {
|
||||
Js::Punct(char::from(c))
|
||||
}
|
||||
};
|
||||
after_condition = match token {
|
||||
Js::Punct('(') => {
|
||||
conditions.push(opens_condition(js, &out));
|
||||
false
|
||||
}
|
||||
Js::Punct(')') => conditions.pop().unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
out.push((start..i, token));
|
||||
let token = (start..i, token);
|
||||
(after_condition, after_for_of) = advance_context(js, &token, &out, &mut delimiters);
|
||||
out.push(token);
|
||||
}
|
||||
(out, comments)
|
||||
}
|
||||
|
||||
/// Advance only the delimiter/for-header context after a token has been classified.
|
||||
/// Returns whether it closes a statement's condition or is the for-of separator.
|
||||
fn advance_context(js: &str, token: &Token, tokens: &[Token], delimiters: &mut Vec<(char, Delimiter)>) -> (bool, bool) {
|
||||
let mut after_for_of = false;
|
||||
if let Some((_, Delimiter::For { left })) = delimiters.last_mut() {
|
||||
if matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("of") && *left && for_target_ends(js, tokens) {
|
||||
*left = false;
|
||||
after_for_of = true;
|
||||
} else if matches!(token.1, Js::Punct(';' | '='))
|
||||
|| (matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("in") && !matches!(tokens.last(), Some((_, Js::Punct('.')))))
|
||||
{
|
||||
*left = false;
|
||||
}
|
||||
}
|
||||
let mut after_condition = false;
|
||||
match token.1 {
|
||||
Js::Punct('(') => delimiters.push((')', opens_condition(js, tokens))),
|
||||
Js::Punct('[') => delimiters.push((']', Delimiter::Group)),
|
||||
Js::Punct('{') => delimiters.push(('}', Delimiter::Group)),
|
||||
Js::Punct(close @ (')' | ']' | '}')) if delimiters.last().is_some_and(|(end, _)| *end == close) => {
|
||||
after_condition = matches!(delimiters.pop(), Some((_, Delimiter::Condition | Delimiter::For { .. })));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(after_condition, after_for_of)
|
||||
}
|
||||
|
||||
/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
|
||||
/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not
|
||||
/// after a value. `after_condition` says the last token is the `)` that ends such a condition.
|
||||
fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool {
|
||||
fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool, after_for_of: bool) -> bool {
|
||||
match tokens.last() {
|
||||
None => true,
|
||||
Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
|
||||
Some((range, Js::Ident)) => {
|
||||
!matches!(tokens.get(tokens.len().wrapping_sub(2)), Some((_, Js::Punct('.'))))
|
||||
&& js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w) || (w == "of" && after_for_of))
|
||||
}
|
||||
Some((_, Js::Punct(')'))) => after_condition,
|
||||
Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'),
|
||||
Some((_, Js::Str(_) | Js::Other)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not
|
||||
/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` (including
|
||||
/// `for await`) or `with`, and not
|
||||
/// the arguments of a method that has one of those names (`o.if(x)`).
|
||||
fn opens_condition(js: &str, tokens: &[Token]) -> bool {
|
||||
fn opens_condition(js: &str, tokens: &[Token]) -> Delimiter {
|
||||
let mut back = tokens.iter().rev();
|
||||
let Some((range, Js::Ident)) = back.next() else { return false };
|
||||
js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.'))))
|
||||
let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group };
|
||||
let mut keyword = js.get(range.clone());
|
||||
if keyword == Some("await") {
|
||||
let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group };
|
||||
keyword = js.get(range.clone());
|
||||
if keyword != Some("for") {
|
||||
return Delimiter::Group;
|
||||
}
|
||||
}
|
||||
if matches!(back.next(), Some((_, Js::Punct('.')))) {
|
||||
return Delimiter::Group;
|
||||
}
|
||||
match keyword {
|
||||
Some("for") => Delimiter::For { left: true },
|
||||
Some(w) if BEFORE_CONDITION.contains(&w) => Delimiter::Condition,
|
||||
_ => Delimiter::Group,
|
||||
}
|
||||
}
|
||||
|
||||
/// A binding/assignment target before the for-of separator, not a declaration keyword,
|
||||
/// expression-opening operator, or the start of the header itself.
|
||||
fn for_target_ends(js: &str, tokens: &[Token]) -> bool {
|
||||
match tokens.last() {
|
||||
Some((range, Js::Ident)) => !matches!(js.get(range.clone()), Some("var" | "let" | "const")),
|
||||
Some((_, Js::Punct(']' | '}' | ')'))) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the
|
||||
@@ -455,10 +536,12 @@ fn splice(src: &str, edits: impl IntoIterator<Item = (Range<usize>, String)>) ->
|
||||
}
|
||||
|
||||
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
|
||||
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
|
||||
/// Each field name becomes `AFMakeNumber(this.getField("name").value)`; adjacent signs
|
||||
/// are separated so binary plus/minus followed by a unary sign cannot become an update.
|
||||
pub fn notation_js(expr: &str) -> String {
|
||||
let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t {
|
||||
Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))),
|
||||
Notation::Op(sign @ ('+' | '-')) if expr.get(range.end..).is_some_and(|rest| rest.starts_with(sign)) => Some((range, format!("{sign} "))),
|
||||
_ => None,
|
||||
});
|
||||
splice(expr, names)
|
||||
@@ -507,6 +590,10 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option
|
||||
*i += 1;
|
||||
Some(*n)
|
||||
}
|
||||
T::Op('+') => {
|
||||
*i += 1;
|
||||
factor(t, i, d + 1)
|
||||
}
|
||||
T::Op('-') => {
|
||||
*i += 1;
|
||||
factor(t, i, d + 1).map(|v| -v)
|
||||
@@ -558,3 +645,33 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn regex_scan_does_not_cross_line_break_after_backslash_or_split_utf8() {
|
||||
let js = "x = /a\\\nthis.getField(\"price\").value / 2;";
|
||||
assert_eq!(calculation_reads(js), vec!["price".to_owned()]);
|
||||
|
||||
let js_utf8 = "var r = /\\é/; event.value = this.getField(\"tax\").value;";
|
||||
assert_eq!(calculation_reads(js_utf8), vec!["tax".to_owned()]);
|
||||
|
||||
let trailing_backslash = "event.value = this.getField(\"subtotal\").value + /unterminated\\";
|
||||
assert_eq!(calculation_reads(trailing_backslash), vec!["subtotal".to_owned()]);
|
||||
let template_trailing = "event.value = this.getField(\"shipping\").value + `unterminated\\";
|
||||
assert_eq!(calculation_reads(template_trailing), vec!["shipping".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_notation_supports_unary_plus() {
|
||||
let c = Calculate::Notation("+a + (+b)".to_owned());
|
||||
let out = calculate(&c, &|name| match name {
|
||||
"a" => vec!["3".to_owned()],
|
||||
"b" => vec!["4".to_owned()],
|
||||
_ => Vec::new(),
|
||||
});
|
||||
assert_eq!(out.as_deref(), Some("7"));
|
||||
}
|
||||
}
|
||||
+139
-18
@@ -17,7 +17,7 @@ impl Arg {
|
||||
match self {
|
||||
Arg::Num(n) => Some(*n),
|
||||
Arg::Bool(b) => Some(f64::from(u8::from(*b))),
|
||||
Arg::Str(s) => s.trim().parse().ok(),
|
||||
Arg::Str(s) => s.trim().parse::<f64>().ok().filter(|n| n.is_finite()),
|
||||
Arg::List(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -99,17 +99,36 @@ impl Parser<'_> {
|
||||
let mut encoded = [0u8; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
|
||||
}
|
||||
b'u' if self.s.get(self.i) == Some(&b'{') => {
|
||||
self.i += 1;
|
||||
let start = self.i;
|
||||
let mut code = 0u32;
|
||||
while let Some(&digit) = self.s.get(self.i).filter(|&&b| b != b'}') {
|
||||
code = code.checked_mul(16)?.checked_add(char::from(digit).to_digit(16)?)?;
|
||||
if code > 0x10FFFF {
|
||||
return None;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i == start || self.s.get(self.i) != Some(&b'}') {
|
||||
return None;
|
||||
}
|
||||
self.i += 1;
|
||||
// Match the existing handling of unpaired UTF-16 surrogates.
|
||||
let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
|
||||
let mut encoded = [0u8; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
|
||||
}
|
||||
b'u' => {
|
||||
let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?;
|
||||
self.i += 4;
|
||||
let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") {
|
||||
let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?;
|
||||
if (0xDC00..=0xDFFF).contains(&low) {
|
||||
self.i += 6;
|
||||
0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00)
|
||||
} else {
|
||||
u32::from(code)
|
||||
}
|
||||
let code = if (0xD800..=0xDBFF).contains(&code)
|
||||
&& let Some(pair) = self.i.checked_add(6).and_then(|end| self.s.get(self.i..end))
|
||||
&& pair.starts_with(b"\\u")
|
||||
&& let Some(low) = hex4(&pair[2..6]).filter(|low| (0xDC00..=0xDFFF).contains(low))
|
||||
{
|
||||
self.i += 6;
|
||||
0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00)
|
||||
} else {
|
||||
u32::from(code)
|
||||
};
|
||||
@@ -158,7 +177,7 @@ impl Parser<'_> {
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) {
|
||||
self.i += 1;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num)
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::<f64>().ok().filter(|n| n.is_finite()).map(Arg::Num)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,15 +215,30 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first call to `name(` in `js` and its arguments.
|
||||
/// The first genuine call to `name(` in `js` and its arguments.
|
||||
/// An unsupported first call returns `None`, preserving the custom-script fallback; later calls
|
||||
/// must not substitute a different native rule. Comments, strings and unrelated identifiers are skipped.
|
||||
pub(super) fn call(js: &str, name: &str) -> Option<Vec<Arg>> {
|
||||
let at = js.find(&format!("{name}("))?;
|
||||
// Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx).
|
||||
if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
let (tokens, _) = js_tokens(js);
|
||||
for (k, (range, tok)) in tokens.iter().enumerate() {
|
||||
if *tok != super::calc::Js::Ident {
|
||||
continue;
|
||||
}
|
||||
if k > 0 && matches!(tokens.get(k - 1), Some((_, super::calc::Js::Punct('.')))) {
|
||||
continue;
|
||||
}
|
||||
let Some(target_end) = range.start.checked_add(name.len()) else { continue };
|
||||
if js.get(range.start..target_end) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
let Some(end_k) = (k..tokens.len()).take_while(|&m| tokens[m].0.end <= target_end).find(|&m| tokens[m].0.end == target_end) else {
|
||||
continue;
|
||||
};
|
||||
let Some((open, super::calc::Js::Punct('('))) = tokens.get(end_k + 1) else { continue };
|
||||
let mut p = Parser { s: js.as_bytes(), i: open.end, depth: 0 };
|
||||
return p.list(b')');
|
||||
}
|
||||
let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 };
|
||||
p.list(b')')
|
||||
None
|
||||
}
|
||||
|
||||
pub fn parse_format(js: &str) -> Option<Format> {
|
||||
@@ -246,7 +280,8 @@ pub fn parse_validate(js: &str) -> Option<Validate> {
|
||||
let args = call(js, "AFRange_Validate")?;
|
||||
let flag = |i: usize| args.get(i).is_some_and(Arg::truthy);
|
||||
let num = |i: usize| args.get(i).and_then(Arg::num);
|
||||
Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } })
|
||||
// An enabled but unparseable bound is unsupported, not an absent bound.
|
||||
Some(Validate::Range { min: if flag(0) { Some(num(1)?) } else { None }, max: if flag(2) { Some(num(3)?) } else { None } })
|
||||
}
|
||||
|
||||
pub fn parse_calculate(js: &str) -> Option<Calculate> {
|
||||
@@ -361,3 +396,89 @@ pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unsupported_first_validation_call_does_not_select_a_later_rule() {
|
||||
let js = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);";
|
||||
assert_eq!(parse_validate(js), None);
|
||||
assert_eq!(
|
||||
parse_validate("AFRange_Validate(true, 15, false, 0); AFRange_Validate(false, 0, true, 100);"),
|
||||
Some(Validate::Range { min: Some(15.0), max: None })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_call_still_skips_non_calls_before_literal_arguments() {
|
||||
let js = r#"// AFRange_Validate(true, 10 + 5, false, 0)
|
||||
"AFRange_Validate(true, 10 + 5, false, 0)";
|
||||
my_AFRange_Validate(true, 10 + 5, false, 0);
|
||||
other.AFRange_Validate(true, 10 + 5, false, 0);
|
||||
AFRange_Validate(false, 0, true, 100);"#;
|
||||
assert_eq!(parse_validate(js), Some(Validate::Range { min: None, max: Some(100.0) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpaired_high_surrogate_at_end_of_string_does_not_abort_parsing() {
|
||||
let js = "AFDate_FormatEx(\"\\uD800\")";
|
||||
assert_eq!(parse_format(js), Some(Format::Date("\u{FFFD}".to_owned())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_skips_commented_or_prefixed_identifiers_and_rejects_infinite_numbers() {
|
||||
let js = "// AFPercent_Format(0, 0);\nmy_AFPercent_Format(0, 0); $AFPercent_Format(0, 0); AFPercent_Format (2, 1);";
|
||||
assert_eq!(parse_format(js), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
|
||||
assert_eq!(parse_validate("AFRange_Validate(true, 1e500, false, 0);"), None);
|
||||
}
|
||||
#[test]
|
||||
fn review_enabled_nonfinite_bound_is_unsupported() {
|
||||
for script in [r#"AFRange_Validate(true, "1e500", false, 0)"#, r#"AFRange_Validate(false, 0, true, "-1e500")"#] {
|
||||
assert_eq!(parse_validate(script), None);
|
||||
}
|
||||
assert_eq!(parse_validate(r#"AFRange_Validate(false, "1e500", false, "NaN")"#), Some(Validate::Range { min: None, max: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_format_after_postfix_division() {
|
||||
for op in ["++", "--"] {
|
||||
let js = format!("var y = x{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;");
|
||||
assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
let commented = format!("var y = x/* comment */{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;");
|
||||
assert_eq!(parse_format(&commented), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
}
|
||||
assert_eq!(parse_format("var of = 8; of / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
assert_eq!(
|
||||
parse_format("for (var x of /AFPercent_Format(0, 0)/g) {} AFPercent_Format(2, 1);"),
|
||||
Some(Format::Percent { decimals: 2, sep: 1 })
|
||||
);
|
||||
// A newline makes ++ a prefix operator; the following regex must remain opaque.
|
||||
for js in ["x\n++/AFPercent_Format(2, 1)/.lastIndex;", "x/*\n*/++/AFPercent_Format(2, 1)/.lastIndex;"] {
|
||||
assert_eq!(parse_format(js), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_format_after_brace_unicode() {
|
||||
assert_eq!(parse_format(r#"var s = "\u{1F600}"; AFPercent_Format(2, 1);"#), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
assert_eq!(parse_format(r#"AFDate_FormatEx("\u{1F600}")"#), Some(Format::Date("😀".to_owned())));
|
||||
assert_eq!(parse_format(r#"AFDate_FormatEx("\u{00000041}\u0042\uD83D\uDE00")"#), Some(Format::Date("AB😀".to_owned())));
|
||||
for escape in ["\\u{}", "\\u{110000}", "\\u{xyz}", "\\u{41"] {
|
||||
assert_eq!(parse_format(&format!("AFDate_FormatEx(\"{escape}\")")), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_notation_separates_adjacent_operators() {
|
||||
let field = |name| format!("AFMakeNumber(this.getField(\"{name}\").value)");
|
||||
for (expr, operators) in [("a++b", "+ +"), ("a--b", "- -"), ("a+++b", "+ + +")] {
|
||||
let js = calculate_js(&Calculate::Notation(expr.to_owned())).unwrap();
|
||||
let executable = js.split("event.value = ").nth(1).unwrap();
|
||||
assert_eq!(executable, format!("{}{operators}{};", field("a"), field("b")));
|
||||
assert_eq!(super::super::calc::calculate(&Calculate::Notation(expr.to_owned()), &|_| vec!["2".to_owned()]), Some("4".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,3 +444,45 @@ fn validation_and_calculation() {
|
||||
assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into()));
|
||||
assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None);
|
||||
}
|
||||
|
||||
/// Async for-of headers have the same regex-literal contexts as ordinary for-of headers.
|
||||
#[test]
|
||||
fn for_await_regex_literals_do_not_become_format_calls() {
|
||||
for header in ["for await", "for /* gap */ await"] {
|
||||
let js = format!("async function f() {{ {header} (const x of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);");
|
||||
assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
let body = format!("async function f() {{ {header} (const x of xs) /AFPercent_Format(0, 0)/.test(x); }} AFPercent_Format(2, 1);");
|
||||
assert_eq!(parse_format(&body), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
}
|
||||
assert_eq!(parse_format("obj.await(x) / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_of_in_conditions_keeps_division_and_real_format_calls() {
|
||||
for header in [
|
||||
"if (of / 2)",
|
||||
"while (of / 2)",
|
||||
"with (of / 2)",
|
||||
"for (of / 2; ; )",
|
||||
"for (var x = of / 2; ; )",
|
||||
"for (var x = 0; of / 2; )",
|
||||
"for (x of of / 2)",
|
||||
"for (const x of of / 2)",
|
||||
"for (x in of / 2)",
|
||||
"for (var of / 2; ; )",
|
||||
"for (obj.of / 2; ; )",
|
||||
] {
|
||||
let js = format!("{header} AFPercent_Format(2, 1); var z = 1 / 2;");
|
||||
assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructured_for_of_targets_keep_regex_iterables_opaque() {
|
||||
for header in ["for", "for await"] {
|
||||
for target in ["const [x]", "const {x}", "(x)", "const {x = of / 2}", "obj.in", "obj.of", "const of"] {
|
||||
let js = format!("async function f() {{ {header} ({target} of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);");
|
||||
assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header} {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};
|
||||
use super::tree::{detach, list_of};
|
||||
use super::tree::{ancestor_sets, detach, list_of};
|
||||
use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid};
|
||||
|
||||
/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`
|
||||
/// keeps only the widget's own triggers.
|
||||
const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];
|
||||
|
||||
/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
|
||||
/// Whether `field` holds or inherits `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
|
||||
fn has_opt(doc: &Document, field: ObjRef) -> bool {
|
||||
doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))
|
||||
doc.get(field).as_dict().and_then(|d| d.get(b"Opt")).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null))
|
||||
|| ancestor_sets(doc, field, b"Opt")
|
||||
}
|
||||
|
||||
/// Refuse what [`join_radio_group`] can't do, before anything is written.
|
||||
@@ -127,3 +128,26 @@ fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn has_opt_ignores_null_and_detects_inherited_opt() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"Opt".to_vec(), Object::Array(vec![]));
|
||||
let parent_ref = doc.add(Object::Dict(parent));
|
||||
|
||||
let mut f_null = Dict::new();
|
||||
f_null.set(b"Opt".to_vec(), Object::Null);
|
||||
let f_null_ref = doc.add(Object::Dict(f_null));
|
||||
assert!(!has_opt(&doc, f_null_ref));
|
||||
|
||||
let mut f_inherited = Dict::new();
|
||||
f_inherited.set(b"Parent".to_vec(), Object::Ref(parent_ref));
|
||||
let f_inherited_ref = doc.add(Object::Dict(f_inherited));
|
||||
assert!(has_opt(&doc, f_inherited_ref));
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,11 @@ pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `d` has a non-null entry `key` (a `null` entry is absent per ISO 32000-2 §7.3.9).
|
||||
fn dict_has(doc: &Document, d: &Dict, key: &[u8]) -> bool {
|
||||
d.get(key).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null))
|
||||
}
|
||||
|
||||
/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is
|
||||
/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep
|
||||
/// as the field walk goes, so a cyclic chain ends.
|
||||
@@ -120,7 +125,7 @@ fn holds_value(doc: &Document, r: ObjRef) -> bool {
|
||||
for _ in 0..=64 {
|
||||
let obj = doc.get(at);
|
||||
let Some(d) = obj.as_dict() else { return false };
|
||||
if d.contains(b"V") {
|
||||
if dict_has(doc, d, b"V") {
|
||||
return true;
|
||||
}
|
||||
match d.reference(b"Parent") {
|
||||
@@ -160,8 +165,8 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo
|
||||
/// The most parts a dotted field name may have ("a.b.c" has three).
|
||||
const MAX_NAME_PARTS: usize = 32;
|
||||
|
||||
/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1).
|
||||
const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"];
|
||||
/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1, §12.7.5.2).
|
||||
const INHERITED_KEYS: [&[u8]; 8] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen", b"Opt"];
|
||||
|
||||
/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a
|
||||
/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696).
|
||||
@@ -234,7 +239,7 @@ pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&s
|
||||
break;
|
||||
};
|
||||
// A value, reset value or length limit held by a parent would pass to this field.
|
||||
let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k)));
|
||||
let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| dict_has(doc, d, k) && !dict_has(doc, &owned, k)));
|
||||
if held {
|
||||
return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join(".")));
|
||||
}
|
||||
@@ -259,7 +264,7 @@ pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&st
|
||||
for part in parents {
|
||||
let next = match child_named(doc, af, parent, part) {
|
||||
Some(r) => {
|
||||
inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q"));
|
||||
inherits |= doc.get(r).as_dict().is_some_and(|d| dict_has(doc, d, b"Ff") || dict_has(doc, d, b"Q"));
|
||||
r
|
||||
}
|
||||
None => {
|
||||
@@ -308,12 +313,20 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict {
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { break };
|
||||
for k in INHERITED_KEYS {
|
||||
if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) {
|
||||
if let Some(v) = d.get(k).filter(|_| dict_has(doc, &d, k) && !dict_has(doc, &own, k) && !add.contains(k)) {
|
||||
add.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
}
|
||||
// /Opt is inheritable for check boxes and radio buttons (Table 230), not choice
|
||||
// fields (Table 234). Classify by effective /FT and /Ff before materializing it.
|
||||
let effective = |key: &[u8]| if dict_has(doc, &own, key) { own.get(key) } else { add.get(key) };
|
||||
let button = effective(b"FT").is_some_and(|v| doc.resolve(v).as_name() == Some(b"Btn"));
|
||||
let flags = effective(b"Ff").and_then(|v| doc.resolve(v).as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if !button || flags & crate::flags::PUSH_BUTTON != 0 {
|
||||
add.remove(b"Opt");
|
||||
}
|
||||
add
|
||||
}
|
||||
|
||||
@@ -327,7 +340,7 @@ pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
|
||||
return false;
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { return false };
|
||||
if d.contains(key) {
|
||||
if dict_has(doc, &d, key) {
|
||||
return true;
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
@@ -396,3 +409,162 @@ pub(super) fn top_names(doc: &Document) -> Vec<String> {
|
||||
let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// /Opt is inheritable only for check boxes and radio buttons, using effective FT/Ff.
|
||||
#[test]
|
||||
fn inherited_options_are_limited_to_checkboxes_and_radios() {
|
||||
for (ft, flags, expected) in
|
||||
[("Btn", 0, true), ("Btn", crate::flags::RADIO, true), ("Btn", crate::flags::PUSH_BUTTON, false), ("Ch", 0, false), ("Tx", 0, false)]
|
||||
{
|
||||
for own_type in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"FT".to_vec(), Object::name(ft));
|
||||
parent.set(b"Ff".to_vec(), Object::Int(i64::from(flags)));
|
||||
parent.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("export"))]));
|
||||
parent.set(b"DA".to_vec(), PdfString::text("/Helv 10 Tf 0 g"));
|
||||
let p = doc.add(parent);
|
||||
let mut child = Dict::new();
|
||||
child.set(b"Parent".to_vec(), Object::Ref(p));
|
||||
if own_type {
|
||||
child.set(b"FT".to_vec(), Object::name(ft));
|
||||
child.set(b"Ff".to_vec(), Object::Int(i64::from(flags)));
|
||||
}
|
||||
let c = doc.add(child);
|
||||
let inherited = inherited(&doc, c);
|
||||
assert_eq!(inherited.contains(b"Opt"), expected, "{ft}, flags {flags}, own {own_type}");
|
||||
assert!(inherited.contains(b"DA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_page() -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
let page = doc.add(page);
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn reopen(doc: &Document) -> Document {
|
||||
let bytes = pdfcraft_cos::write_incremental(doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
Document::open(std::sync::Arc::new(bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Moving a choice field must not turn its ancestor's unrelated /Opt into local choices.
|
||||
#[test]
|
||||
fn moving_choice_fields_does_not_copy_ancestor_options() {
|
||||
for combo in [false, true] {
|
||||
for own_options in [false, true] {
|
||||
let mut doc = one_page();
|
||||
let options = if own_options { vec!["local".into()] } else { Vec::new() };
|
||||
let kind = if combo { crate::NewField::Combo { options, editable: false } } else { crate::NewField::List { options, multi: false } };
|
||||
crate::add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &kind, Some("old.pick")).unwrap();
|
||||
let f = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap();
|
||||
let parent = doc.get(f.obj).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
if !own_options {
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.remove(b"Opt");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("borrowed"))]))).unwrap();
|
||||
let before = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap().options;
|
||||
crate::set_props(&mut doc, "old.pick", &crate::FieldProps { name: Some("new.pick".into()), ..Default::default() }).unwrap();
|
||||
let after = crate::fields(&doc).into_iter().find(|f| f.name == "new.pick").unwrap();
|
||||
assert_eq!(after.options, before);
|
||||
assert_eq!(doc.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options);
|
||||
let reopened = reopen(&doc);
|
||||
let after = crate::fields(&reopened).into_iter().find(|f| f.name == "new.pick").unwrap();
|
||||
assert_eq!(after.options, before);
|
||||
assert_eq!(reopened.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_buttons_preserves_inherited_export_mapping() {
|
||||
for kind in [crate::NewField::CheckBox, crate::NewField::Radio { group: None, export: "0".into() }] {
|
||||
let mut doc = one_page();
|
||||
crate::add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &kind, Some("old.button")).unwrap();
|
||||
let field = crate::fields(&doc).into_iter().find(|f| f.name == "old.button").unwrap();
|
||||
let state = field.widgets.first().unwrap().on_state.clone().unwrap();
|
||||
let parent = doc.get(field.obj).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
let opt = Object::Array(vec![Object::String(PdfString::text("exported"))]);
|
||||
doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), opt.clone())).unwrap();
|
||||
doc.update_dict(field.obj, |d| {
|
||||
d.remove(b"Opt");
|
||||
})
|
||||
.unwrap();
|
||||
crate::set_props(&mut doc, "old.button", &crate::FieldProps { name: Some("new.button".into()), ..Default::default() }).unwrap();
|
||||
for doc in [&doc, &reopen(&doc)] {
|
||||
let after = crate::fields(doc).into_iter().find(|f| f.name == "new.button").unwrap();
|
||||
assert_eq!(doc.get(after.obj).as_dict().unwrap().get(b"Opt"), Some(&opt));
|
||||
assert_eq!(after.export_of(0), Some("exported"));
|
||||
assert_eq!(after.state_for("exported"), Some(state.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_entries_on_parents_and_unsigned_signatures_are_treated_as_absent() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut sig = Dict::new();
|
||||
sig.set(b"FT".to_vec(), Object::name("Sig"));
|
||||
sig.set(b"T".to_vec(), PdfString::text("sig"));
|
||||
sig.set(b"V".to_vec(), Object::Null);
|
||||
let sig_ref = doc.add(Object::Dict(sig));
|
||||
assert!(!holds_value(&doc, sig_ref));
|
||||
|
||||
let mut grand = Dict::new();
|
||||
grand.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
grand.set(b"MaxLen".to_vec(), Object::Int(10));
|
||||
let grand_ref = doc.add(Object::Dict(grand));
|
||||
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"Parent".to_vec(), Object::Ref(grand_ref));
|
||||
parent.set(b"T".to_vec(), PdfString::text("group"));
|
||||
parent.set(b"V".to_vec(), Object::Null);
|
||||
parent.set(b"MaxLen".to_vec(), Object::Null);
|
||||
let parent_ref = doc.add(Object::Dict(parent));
|
||||
|
||||
let mut child = Dict::new();
|
||||
child.set(b"Parent".to_vec(), Object::Ref(parent_ref));
|
||||
child.set(b"T".to_vec(), PdfString::text("item"));
|
||||
let child_ref = doc.add(Object::Dict(child));
|
||||
|
||||
let inh = inherited(&doc, child_ref);
|
||||
assert_eq!(inh.get(b"MaxLen"), Some(&Object::Int(10)));
|
||||
assert!(!inh.contains(b"V"));
|
||||
assert!(!ancestor_sets(&doc, child_ref, b"V"));
|
||||
assert!(ancestor_sets(&doc, child_ref, b"MaxLen"));
|
||||
|
||||
// Button /Opt is inherited, text /Opt is not; null kids and /Parent cycles are handled.
|
||||
let opt_val = Object::Array(vec![Object::String(PdfString::text("Choice1"))]);
|
||||
doc.update_dict(grand_ref, |d| {
|
||||
d.set(b"Opt".to_vec(), opt_val.clone());
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent_ref));
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Null, Object::Ref(parent_ref)]));
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!inherited(&doc, child_ref).contains(b"Opt"));
|
||||
doc.update_dict(grand_ref, |d| d.set(b"FT".to_vec(), Object::name("Btn"))).unwrap();
|
||||
let inh_cycle = inherited(&doc, child_ref);
|
||||
assert_eq!(inh_cycle.get(b"Opt"), Some(&opt_val));
|
||||
assert!(!holds_value(&doc, child_ref));
|
||||
assert_eq!(child_named(&doc, grand_ref, Some(grand_ref), "group"), Some(parent_ref));
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,8 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
|
||||
/// enough: dates split into day and month groups of two cells. Cells are square or taller
|
||||
/// than wide; an empty table of wider cells is not a comb.
|
||||
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
|
||||
// A repeated box adds nothing to a row, but every repeat would be searched against every cell.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let cells: Vec<[f64; 4]> = boxes
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -398,6 +400,7 @@ fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
|
||||
})
|
||||
.filter(|r| seen.insert(r.map(f64::to_bits)))
|
||||
.collect();
|
||||
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
|
||||
let follows = |a: &[f64; 4], b: &[f64; 4]| {
|
||||
@@ -521,3 +524,17 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod comb_tests {
|
||||
use super::combs;
|
||||
|
||||
#[test]
|
||||
fn repeated_boxes_do_not_change_or_multiply_a_comb() {
|
||||
let row = [[10.0, 10.0, 20.0, 22.0], [20.0, 10.0, 30.0, 22.0], [30.0, 10.0, 40.0, 22.0]];
|
||||
let mut many = row.to_vec();
|
||||
many.extend(std::iter::repeat_n(row[1], 5000));
|
||||
assert_eq!(combs(&row), vec![([10.0, 10.0, 40.0, 22.0], 3)]);
|
||||
assert_eq!(combs(&many), combs(&row));
|
||||
}
|
||||
}
|
||||
+108
-14
@@ -137,23 +137,34 @@ pub fn tab_orders(doc: &Document) -> Vec<TabOrder> {
|
||||
/// annotations keep their places.
|
||||
pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?;
|
||||
if !all.iter().any(|f| f.name == name) {
|
||||
return Err(FormError::NoSuchField(name.into()));
|
||||
}
|
||||
let (target_fi, page) = all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f.name == name)
|
||||
.find_map(|(fi, f)| Some((fi, f.widgets.iter().filter_map(|w| w.page).min()?)))
|
||||
.ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?;
|
||||
// The page's fields in their current tab order (a field counts once, at its first widget).
|
||||
let mut order: Vec<(usize, &str)> =
|
||||
all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect();
|
||||
let mut order: Vec<(usize, usize)> = all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(fi, g)| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, fi)))
|
||||
.collect();
|
||||
order.sort();
|
||||
let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect();
|
||||
let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) };
|
||||
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) };
|
||||
let Some(i) = order.iter().position(|&(_, fi)| fi == target_fi) else { return invalid(format!("{name} is not on a page")) };
|
||||
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < order.len()) };
|
||||
let Some(j) = j else { return Ok(()) };
|
||||
let mut names = names.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||
names.swap(i, j);
|
||||
// The widgets of this page in the new order.
|
||||
order.swap(i, j);
|
||||
// The widgets of this page in the new order, preserving each field's relative widget tab order.
|
||||
let mut widgets: Vec<ObjRef> = Vec::new();
|
||||
for n in &names {
|
||||
if let Some(g) = all.iter().find(|g| &g.name == n) {
|
||||
widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj));
|
||||
let mut queued = std::collections::HashSet::new();
|
||||
for &(_, fi) in &order {
|
||||
if let Some(g) = all.get(fi) {
|
||||
let mut own: Vec<(usize, ObjRef)> = g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| (w.tab, w.obj)).collect();
|
||||
own.sort_by_key(|&(tab, _)| tab);
|
||||
widgets.extend(own.into_iter().map(|(_, obj)| obj).filter(|obj| queued.insert(*obj)));
|
||||
}
|
||||
}
|
||||
let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?;
|
||||
@@ -162,10 +173,11 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul
|
||||
let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default();
|
||||
let mut next = widgets.into_iter();
|
||||
let set: std::collections::HashSet<ObjRef> = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect();
|
||||
let mut seen_slots = std::collections::HashSet::new();
|
||||
let new_list: Vec<Object> = list
|
||||
.iter()
|
||||
.map(|o| match o.as_ref() {
|
||||
Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()),
|
||||
Some(r) if set.contains(&r) && seen_slots.insert(r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()),
|
||||
_ => o.clone(),
|
||||
})
|
||||
.collect();
|
||||
@@ -178,3 +190,85 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfcraft_cos::{Dict, PdfString};
|
||||
|
||||
#[test]
|
||||
fn move_in_tab_order_preserves_multi_widget_relative_tab_order() {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages_ref = doc.add(Object::Null);
|
||||
let page_ref = doc.add(Object::Null);
|
||||
|
||||
let mut w1 = Dict::new();
|
||||
w1.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
w1.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(200), Object::Int(700), Object::Int(220), Object::Int(720)]));
|
||||
let w1_ref = doc.add(Object::Dict(w1));
|
||||
|
||||
let mut w2 = Dict::new();
|
||||
w2.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
w2.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(700), Object::Int(70), Object::Int(720)]));
|
||||
let w2_ref = doc.add(Object::Dict(w2));
|
||||
|
||||
let mut radio = Dict::new();
|
||||
radio.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
radio.set(b"Ff".to_vec(), Object::Int(1 << 15));
|
||||
radio.set(b"T".to_vec(), PdfString::text("choice"));
|
||||
// /Kids order is [w1 (right), w2 (left)], opposite to /R (Row) left-to-right order.
|
||||
radio.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref)]));
|
||||
let radio_ref = doc.add(Object::Dict(radio));
|
||||
doc.update_dict(w1_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap();
|
||||
doc.update_dict(w2_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap();
|
||||
|
||||
let mut text = Dict::new();
|
||||
text.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
text.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
text.set(b"T".to_vec(), PdfString::text("note"));
|
||||
text.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(600), Object::Int(150), Object::Int(620)]));
|
||||
let text_ref = doc.add(Object::Dict(text));
|
||||
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages_ref));
|
||||
page.set(b"Tabs".to_vec(), Object::name("R"));
|
||||
page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref), Object::Ref(text_ref)]));
|
||||
doc.set(page_ref, Object::Dict(page));
|
||||
|
||||
let mut pages = Dict::new();
|
||||
pages.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page_ref)]));
|
||||
pages.set(b"Count".to_vec(), Object::Int(1));
|
||||
doc.set(pages_ref, Object::Dict(pages));
|
||||
|
||||
let mut af = Dict::new();
|
||||
af.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref)]));
|
||||
let af_ref = doc.add(Object::Dict(af));
|
||||
|
||||
let mut catalog = Dict::new();
|
||||
catalog.set(b"Type".to_vec(), Object::name("Catalog"));
|
||||
catalog.set(b"Pages".to_vec(), Object::Ref(pages_ref));
|
||||
catalog.set(b"AcroForm".to_vec(), Object::Ref(af_ref));
|
||||
let root_ref = doc.root().unwrap();
|
||||
doc.set(root_ref, Object::Dict(catalog));
|
||||
|
||||
// Move "note" earlier (before "choice"): "choice"'s own buttons should stay in their
|
||||
// /R tab order (w2 then w1), not revert to /Kids order (w1 then w2).
|
||||
move_in_tab_order(&mut doc, "note", true).unwrap();
|
||||
let annots = doc.get(page_ref).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap();
|
||||
assert_eq!(annots, vec![Object::Ref(text_ref), Object::Ref(w2_ref), Object::Ref(w1_ref)]);
|
||||
|
||||
// Missing page and unplaced field return errors cleanly.
|
||||
assert!(set_tab_order(&mut doc, &[99], TabOrder::Row).is_err());
|
||||
let mut unplaced = Dict::new();
|
||||
unplaced.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
unplaced.set(b"T".to_vec(), PdfString::text("orphan"));
|
||||
let unplaced_ref = doc.add(Object::Dict(unplaced));
|
||||
doc.update_dict(af_ref, |d| {
|
||||
d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref), Object::Ref(unplaced_ref)]));
|
||||
})
|
||||
.unwrap();
|
||||
assert!(move_in_tab_order(&mut doc, "orphan", true).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1701,6 +1701,43 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
|
||||
assert_eq!(field(&fields(&doc), "total").value, ["ran"]);
|
||||
}
|
||||
|
||||
/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter.
|
||||
#[test]
|
||||
fn unsupported_first_validation_call_preserves_custom_script_dispatch() {
|
||||
const JS: &str = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);";
|
||||
struct RejectingInterpreter {
|
||||
calls: usize,
|
||||
}
|
||||
impl Scripts for RejectingInterpreter {
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult {
|
||||
assert_eq!(event, FieldEvent::Validate);
|
||||
assert_eq!(script, JS);
|
||||
assert_eq!(target.name, "amount");
|
||||
self.calls += 1;
|
||||
ScriptResult { rc: false, value: value.into(), value_set: false, changes: Vec::new(), message: Some("custom validation ran".into()) }
|
||||
}
|
||||
}
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("amount")).unwrap();
|
||||
set_value(&mut doc, "amount", &FieldValue::Text("20".into())).unwrap();
|
||||
set_field_script(&mut doc, "amount", "validate", Some(JS)).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
for mut doc in [doc, reopened] {
|
||||
let all = fields(&doc);
|
||||
let actions = &field(&all, "amount").actions;
|
||||
assert_eq!(actions.validate, af::Validate::None);
|
||||
assert_eq!(actions.scripts.validate.as_deref(), Some(JS));
|
||||
assert!(actions.unsupported.contains(&"validate"));
|
||||
let mut scripts = RejectingInterpreter { calls: 0 };
|
||||
assert_eq!(
|
||||
set_value_with(&mut doc, "amount", &FieldValue::Text("10".into()), &mut scripts),
|
||||
Err(FormError::Invalid("custom validation ran".into()))
|
||||
);
|
||||
assert_eq!(scripts.calls, 1);
|
||||
assert_eq!(field(&fields(&doc), "amount").value, ["20"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation
|
||||
/// read it as 0 and a script read a field that no longer existed.
|
||||
#[test]
|
||||
|
||||
@@ -81,29 +81,49 @@ fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Axis-aligned `[min_x, min_y, max_x, max_y]` bounding box of finite `points`.
|
||||
fn bounds(points: &[Point]) -> Option<[f64; 4]> {
|
||||
let &[first, ref rest @ ..] = points else { return None };
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.iter().fold([first[0], first[1], first[0], first[1]], |[x0, y0, x1, y1], &[x, y]| [x0.min(x), y0.min(y), x1.max(x), y1.max(y)]))
|
||||
}
|
||||
|
||||
/// The four corners in reading order for a y-down space (view or raster pixels): top-left,
|
||||
/// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they
|
||||
/// form a convex quadrilateral with a non-negligible area.
|
||||
pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> {
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
let [min_x, min_y, max_x, max_y] = bounds(&points)?;
|
||||
let span = max_x - min_x;
|
||||
let rise = max_y - min_y;
|
||||
let size = span.max(rise);
|
||||
if span <= 0.0 || rise <= 0.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cx = points.iter().map(|p| p[0]).sum::<f64>() / 4.0;
|
||||
let cy = points.iter().map(|p| p[1]).sum::<f64>() / 4.0;
|
||||
let norm = |p: Point| [(p[0] - min_x) / size, (p[1] - min_y) / size];
|
||||
let cx = points.iter().map(|p| norm(*p)[0]).sum::<f64>() / 4.0;
|
||||
let cy = points.iter().map(|p| norm(*p)[1]).sum::<f64>() / 4.0;
|
||||
let mut q = points;
|
||||
// Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left.
|
||||
q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx)));
|
||||
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?;
|
||||
q.sort_by(|a, b| {
|
||||
let (na, nb) = (norm(*a), norm(*b));
|
||||
(na[1] - cy).atan2(na[0] - cx).total_cmp(&(nb[1] - cy).atan2(nb[0] - cx))
|
||||
});
|
||||
// Score each candidate rotation by its normalised left-edge x and top-edge y.
|
||||
let score = |i: usize| {
|
||||
let (tl, tr, bl) = (q[i], q[(i + 1) % 4], q[(i + 3) % 4]);
|
||||
((tl[0] - min_x) + (bl[0] - min_x)) / span + ((tl[1] - min_y) + (tr[1] - min_y)) / rise
|
||||
};
|
||||
let first = (0..4).min_by(|&a, &b| score(a).total_cmp(&score(b)))?;
|
||||
q.rotate_left(first);
|
||||
// Convex, with every turn the same way, and not a sliver.
|
||||
let nq = q.map(norm);
|
||||
let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
let turns: Vec<f64> = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect();
|
||||
let turns: [f64; 4] = std::array::from_fn(|i| cross(nq[i], nq[(i + 1) % 4], nq[(i + 2) % 4]));
|
||||
let area = turns.iter().sum::<f64>() / 2.0;
|
||||
let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min);
|
||||
let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min);
|
||||
let size = span.max(rise);
|
||||
let convex = turns.iter().all(|&t| t > 1e-9 * size * size);
|
||||
(convex && area > 1e-6 * size * size).then_some(q)
|
||||
let convex = turns.iter().all(|&t| t > 1e-9);
|
||||
(convex && area > 1e-6).then_some(q)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -129,6 +149,10 @@ mod tests {
|
||||
let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]];
|
||||
let h = Homography::from_points(r, r).unwrap();
|
||||
assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0]));
|
||||
|
||||
let narrow = [[0.0, 0.0], [1e10, 0.0], [1e10, 1.0], [0.0, 1.0]];
|
||||
let h_narrow = Homography::from_points(narrow, narrow).unwrap();
|
||||
assert!(close(h_narrow.apply([5e9, 0.5]).unwrap(), [5e9, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -144,6 +168,8 @@ mod tests {
|
||||
fn collinear_or_non_finite_points_have_no_transform() {
|
||||
let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none());
|
||||
assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 1.0], [2.75, 2.75], [3.0, 3.0]]).is_none());
|
||||
assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 1.0]]).is_none());
|
||||
assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none());
|
||||
assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
@@ -155,7 +181,7 @@ mod tests {
|
||||
let tr = [200.0, 5.0];
|
||||
let br = [210.0, 300.0];
|
||||
let bl = [0.0, 290.0];
|
||||
for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] {
|
||||
for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tl, bl, br, tr], [tr, bl, tl, br]] {
|
||||
assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}");
|
||||
}
|
||||
}
|
||||
@@ -164,8 +190,33 @@ mod tests {
|
||||
fn concave_degenerate_or_non_finite_quads_are_refused() {
|
||||
// A dart: one corner inside the triangle of the other three.
|
||||
assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None);
|
||||
assert_eq!(order_quad([[5.0, 5.0]; 4]), None);
|
||||
assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreshortened_tilted_and_large_scale_quads_order_correctly() {
|
||||
// A landscape page photographed from below has a foreshortened top edge where
|
||||
// bl[0] + bl[1] (500) < tl[0] + tl[1] (550), yet tl is still top-left.
|
||||
let (tl, tr, br, bl) = ([250.0, 300.0], [550.0, 300.0], [800.0, 500.0], [0.0, 500.0]);
|
||||
assert_eq!(order_quad([br, tl, bl, tr]), Some([tl, tr, br, bl]));
|
||||
|
||||
// A narrow portrait strip tilted counter-clockwise has tr[0] + tr[1] (90) < tl[0] + tl[1] (120),
|
||||
// yet tl is still top-left.
|
||||
let (tl, tr, br, bl) = ([20.0, 100.0], [80.0, 10.0], [140.0, 400.0], [80.0, 490.0]);
|
||||
assert_eq!(order_quad([tr, bl, tl, br]), Some([tl, tr, br, bl]));
|
||||
|
||||
// A foreshortened portrait quad where bl sits at x = 0 and y = 60 while tl sits at [40, 0]
|
||||
// still orders [40, 0] as top-left.
|
||||
let (tl, tr, br, bl) = ([40.0, 0.0], [100.0, 10.0], [100.0, 200.0], [0.0, 60.0]);
|
||||
assert_eq!(order_quad([bl, br, tr, tl]), Some([tl, tr, br, bl]));
|
||||
|
||||
// Large finite coordinates where `size * size` would overflow f64 still order cleanly.
|
||||
let s = 1e160;
|
||||
let (tl, tr, br, bl) = ([0.0, 0.0], [s, 0.0], [s, s], [0.0, s]);
|
||||
assert_eq!(order_quad([bl, tr, br, tl]), Some([tl, tr, br, bl]));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) {
|
||||
/// Day numbers (and years) beyond this are refused rather than computed.
|
||||
pub(super) const MAX_DAYS: f64 = 1e8;
|
||||
|
||||
/// The century split of a two-digit year (`YY`), XFA picture clauses' default: `00` to `29` are
|
||||
/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference,
|
||||
/// p. 1171, which makes 30 the default and lets the century split be set otherwise).
|
||||
const CENTURY_SPLIT: i64 = 30;
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …).
|
||||
@@ -89,7 +94,7 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
let v: i64 = digits.parse().ok()?;
|
||||
match c {
|
||||
'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }),
|
||||
'Y' => y = Some(if run <= 2 { if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v } } else { v }),
|
||||
'M' => m = Some(v as u32),
|
||||
_ => d = Some(v as u32),
|
||||
}
|
||||
|
||||
@@ -1346,6 +1346,16 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1");
|
||||
}
|
||||
|
||||
/// A `YY` year in a date picture splits its century at 30, the XFA picture clause default:
|
||||
/// `00` to `29` are 2000 to 2029 and `30` to `99` are 1930 to 1999.
|
||||
#[test]
|
||||
fn a_two_digit_year_splits_its_century_at_30() {
|
||||
for (text, iso) in [("12/31/29", "2029-12-31"), ("01/01/30", "1930-01-01"), ("12/31/49", "1949-12-31"), ("01/01/50", "1950-01-01")] {
|
||||
assert_eq!(calc(&format!(r#"Num2Date(Date2Num("{text}", "MM/DD/YY"), "YYYY-MM-DD")"#)), iso, "{text}");
|
||||
assert_eq!(calc(&format!(r#"Parse("MM/DD/YY", "{text}")"#)), iso, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_flow_variables_and_functions_work() {
|
||||
assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10");
|
||||
|
||||
+35
-11
@@ -177,7 +177,7 @@ impl Scale {
|
||||
pub fn read(doc: &Document, object: &Object) -> Result<Self> {
|
||||
let o = doc.resolve(object);
|
||||
let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?;
|
||||
if d.name(b"Subtype").is_some_and(|n| n != b"RL") {
|
||||
if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) {
|
||||
return Err(invalid("only rectilinear measurement scales are supported"));
|
||||
}
|
||||
let format = |key: &[u8]| -> Result<(String, f64, u8)> {
|
||||
@@ -188,20 +188,25 @@ impl Scale {
|
||||
}
|
||||
let o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?);
|
||||
let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?;
|
||||
let precision = match f.name(b"F") {
|
||||
let fmt = f.get(b"F").map(|o| doc.resolve(o));
|
||||
let precision = match fmt.as_deref().and_then(Object::as_name) {
|
||||
Some(b"R") => 0,
|
||||
None | Some(b"D") => {
|
||||
let denom = f.int(b"D").unwrap_or(100);
|
||||
let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100);
|
||||
(0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))?
|
||||
}
|
||||
_ => return Err(invalid("fractional measurement formats are not supported yet")),
|
||||
};
|
||||
Ok((text(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8))
|
||||
Ok((
|
||||
text(doc, f, b"U"),
|
||||
f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?,
|
||||
precision as u8,
|
||||
))
|
||||
};
|
||||
let (unit, x, _) = format(b"X")?;
|
||||
let y = if d.contains(b"Y") {
|
||||
let (_, y, _) = format(b"Y")?;
|
||||
y * d.get(b"CYX").and_then(Object::as_f64).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))?
|
||||
y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))?
|
||||
} else {
|
||||
x
|
||||
};
|
||||
@@ -210,7 +215,7 @@ impl Scale {
|
||||
return Err(invalid("different coordinate and distance units are not supported yet"));
|
||||
}
|
||||
let (area_unit, area_factor, _) = format(b"A")?;
|
||||
let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(d, b"R") };
|
||||
let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") };
|
||||
out.validate()?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -279,12 +284,19 @@ pub struct Reading {
|
||||
pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result<Reading> {
|
||||
check_points(points)?;
|
||||
scale.validate()?;
|
||||
let points = if kind == Kind::Area { open_polygon(points) } else { points };
|
||||
let mut length = 0.0;
|
||||
for w in points.windows(2) {
|
||||
if let [a, b] = w {
|
||||
length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Area
|
||||
&& points.len() >= 3
|
||||
&& let (Some(a), Some(b)) = (points.last(), points.first())
|
||||
{
|
||||
length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y);
|
||||
}
|
||||
let area = if points.len() >= 3 {
|
||||
let origin = points.first().copied().unwrap_or_default();
|
||||
points
|
||||
@@ -340,7 +352,9 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> {
|
||||
Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")),
|
||||
_ => {}
|
||||
}
|
||||
if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) {
|
||||
if points.windows(2).any(|p| matches!(p, [a, b] if distance(*a, *b) < 1e-6))
|
||||
|| (kind == Kind::Area && matches!((points.first(), points.last()), (Some(a), Some(b)) if distance(*a, *b) < 1e-6))
|
||||
{
|
||||
return Err(invalid("adjacent measurement vertices must be distinct"));
|
||||
}
|
||||
if kind == Kind::Area {
|
||||
@@ -357,8 +371,8 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn text(d: &Dict, k: &[u8]) -> String {
|
||||
d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default()
|
||||
fn text(doc: &Document, d: &Dict, k: &[u8]) -> String {
|
||||
d.get(k).and_then(|o| doc.resolve(o).as_string().map(PdfString::to_text)).unwrap_or_default()
|
||||
}
|
||||
fn nums(v: impl IntoIterator<Item = f64>) -> Object {
|
||||
Object::Array(v.into_iter().map(Object::Real).collect())
|
||||
@@ -491,7 +505,16 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us
|
||||
let points: Vec<Point> = v.as_chunks::<2>().0.to_vec();
|
||||
validate_geometry(kind, &points)?;
|
||||
let reading = reading(kind, &points, &scale)?;
|
||||
Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading })
|
||||
Ok(Measurement {
|
||||
page,
|
||||
index,
|
||||
id: text(doc, d, b"NM"),
|
||||
author: text(doc, d, b"T"),
|
||||
label: text(doc, d, b"PCMeasureLabel"),
|
||||
points,
|
||||
scale,
|
||||
reading,
|
||||
})
|
||||
}
|
||||
/// Every measurement annotation. One that can't be read is reported in `unsupported`
|
||||
/// rather than failing the whole list.
|
||||
@@ -520,7 +543,8 @@ pub fn list(doc: &Document) -> Listing {
|
||||
}
|
||||
let o = doc.resolve(a);
|
||||
let Some(d) = o.as_dict() else { continue };
|
||||
let kind = match d.name(b"IT") {
|
||||
let it = d.get(b"IT").map(|o| doc.resolve(o));
|
||||
let kind = match it.as_deref().and_then(Object::as_name) {
|
||||
Some(b"LineDimension") => Kind::Distance,
|
||||
Some(b"PolyLineDimension") => Kind::Perimeter,
|
||||
Some(b"PolygonDimension") => Kind::Area,
|
||||
|
||||
@@ -549,3 +549,86 @@ fn perimeters_are_charged_linear_work() {
|
||||
assert!(!l.truncated, "{} listed", l.measurements.len());
|
||||
assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_scale_fields_and_closed_area_edges_are_handled() {
|
||||
let mut d = fixture("", "", &[]);
|
||||
let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]]);
|
||||
m.scale = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() };
|
||||
add(&mut d, &m, &Meta { date: None, id: "area-1".into() }).unwrap();
|
||||
|
||||
// Open and closed vertex lists for the same rectangle produce identical readings (including perimeter length).
|
||||
let open = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]], &m.scale).unwrap();
|
||||
let closed = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]], &m.scale).unwrap();
|
||||
close(open.length, 36.0);
|
||||
close(closed.length, 36.0);
|
||||
|
||||
// A doubled closing vertex leaves a zero-length closing edge after stripping one closing duplicate,
|
||||
// and an area with fewer than 3 distinct vertices or a NaN coordinate is also rejected.
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]]), &Meta::default()).is_err());
|
||||
assert!(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]], &m.scale).is_err());
|
||||
|
||||
// Indirect PDF objects inside /Measure, /NumberFormat, and the annotation dictionary still resolve cleanly.
|
||||
let (r, mut annot_dict) = annot(&d, 0, 0).unwrap();
|
||||
let it_ref = d.add(Object::name("PolygonDimension"));
|
||||
let nm_ref = d.add(PdfString::text("area-indirect"));
|
||||
let author_ref = d.add(PdfString::text("Architect"));
|
||||
let label_ref = d.add(PdfString::text("Floor Plan"));
|
||||
let rl_ref = d.add(Object::name("RL"));
|
||||
let ratio_ref = d.add(PdfString::text("1 pt = 2 m"));
|
||||
let cyx_ref = d.add(Object::Real(1.5));
|
||||
let unit_ref = d.add(PdfString::text("m"));
|
||||
let area_unit_ref = d.add(PdfString::text("m^2"));
|
||||
let factor_x_ref = d.add(Object::Real(2.0));
|
||||
let factor_y_ref = d.add(Object::Real(2.0));
|
||||
let factor_d_ref = d.add(Object::Real(1.0));
|
||||
let factor_a_ref = d.add(Object::Real(1.0));
|
||||
let fmt_d_ref = d.add(Object::name("D"));
|
||||
let denom_ref = d.add(Object::Int(1000));
|
||||
|
||||
let nf = |u: ObjRef, c: ObjRef| {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("NumberFormat"));
|
||||
f.set(b"U".to_vec(), Object::Ref(u));
|
||||
f.set(b"C".to_vec(), Object::Ref(c));
|
||||
f.set(b"F".to_vec(), Object::Ref(fmt_d_ref));
|
||||
f.set(b"D".to_vec(), Object::Ref(denom_ref));
|
||||
Object::Array(vec![Object::Dict(f)])
|
||||
};
|
||||
let mut measure = Dict::new();
|
||||
measure.set(b"Type".to_vec(), Object::name("Measure"));
|
||||
measure.set(b"Subtype".to_vec(), Object::Ref(rl_ref));
|
||||
measure.set(b"R".to_vec(), Object::Ref(ratio_ref));
|
||||
measure.set(b"CYX".to_vec(), Object::Ref(cyx_ref));
|
||||
measure.set(b"X".to_vec(), nf(unit_ref, factor_x_ref));
|
||||
measure.set(b"Y".to_vec(), nf(unit_ref, factor_y_ref));
|
||||
measure.set(b"D".to_vec(), nf(unit_ref, factor_d_ref));
|
||||
measure.set(b"A".to_vec(), nf(area_unit_ref, factor_a_ref));
|
||||
|
||||
annot_dict.set(b"IT".to_vec(), Object::Ref(it_ref));
|
||||
annot_dict.set(b"NM".to_vec(), Object::Ref(nm_ref));
|
||||
annot_dict.set(b"T".to_vec(), Object::Ref(author_ref));
|
||||
annot_dict.set(b"PCMeasureLabel".to_vec(), Object::Ref(label_ref));
|
||||
annot_dict.set(b"Measure".to_vec(), Object::Dict(measure.clone()));
|
||||
d.update_dict(r, |target| *target = annot_dict).unwrap();
|
||||
|
||||
// Non-RL indirect subtypes and numeric subtypes are rejected by Scale::read.
|
||||
let geo_ref = d.add(Object::name("GEO"));
|
||||
let mut bad_geo = measure.clone();
|
||||
bad_geo.set(b"Subtype".to_vec(), Object::Ref(geo_ref));
|
||||
assert!(Scale::read(&d, &Object::Dict(bad_geo)).is_err());
|
||||
let mut bad_num = measure;
|
||||
bad_num.set(b"Subtype".to_vec(), Object::Int(42));
|
||||
assert!(Scale::read(&d, &Object::Dict(bad_num)).is_err());
|
||||
|
||||
let listing = list(&d);
|
||||
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
|
||||
assert_eq!(listing.measurements.len(), 1);
|
||||
let item = &listing.measurements[0];
|
||||
assert_eq!(item.id, "area-indirect");
|
||||
assert_eq!(item.author, "Architect");
|
||||
assert_eq!(item.label, "Floor Plan");
|
||||
assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() });
|
||||
}
|
||||
@@ -24,8 +24,12 @@ const MIN_BOX_SIDE: f64 = 1.0 / 4096.0;
|
||||
|
||||
fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
|
||||
let o = doc.resolve(o?);
|
||||
let v: Vec<f64> = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
||||
// Four entries, or not a rectangle: a longer array (one shared by many pages, say) is not
|
||||
// resolved entry by entry only to be discarded.
|
||||
let [a, b, c, d] = o.as_array()?.as_slice() else { return None };
|
||||
let v = [a, b, c, d].map(|x| doc.resolve(x).as_f64());
|
||||
let [Some(x0), Some(y0), Some(x1), Some(y1)] = v else { return None };
|
||||
[x0, y0, x1, y1].iter().all(|x| x.is_finite()).then(|| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
|
||||
}
|
||||
|
||||
impl Page {
|
||||
|
||||
@@ -127,7 +127,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
its priority. The document only changes in order on one thread, and what a picture costs is paid
|
||||
in that order, so the file written, and the pictures the run's decoding allowance refuses, are
|
||||
the same with any number.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings, and form
|
||||
submission, import and reset actions (`/SubmitForm`, `/ImportData`, `/ResetForm`, off by
|
||||
default). The fields, links, bookmarks and pages that ran a removed action keep their other
|
||||
actions, and the actions chained after it through `/Next` still run in order.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed
|
||||
|
||||
@@ -262,7 +262,7 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
|
||||
a.claim(o, SpaceCategory::Bookmarks);
|
||||
}
|
||||
let names = a.resolve_dict(cat.get(b"Names"));
|
||||
if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) {
|
||||
for d in [names.get(b"Dests"), cat.get(b"Dests")].into_iter().flatten() {
|
||||
a.claim(d, SpaceCategory::NamedDestinations);
|
||||
}
|
||||
if let Some(e) = names.get(b"EmbeddedFiles") {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Discard Objects: form submission, import and reset actions (`/SubmitForm`, `/ImportData`,
|
||||
//! `/ResetForm`). The fields, buttons, links, bookmarks and pages that ran them stay; a removed
|
||||
//! action's `/Next` actions take its place, so the rest of a chain still runs in order.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::OptimizeError;
|
||||
|
||||
const FORM_ACTIONS: [&[u8]; 3] = [b"SubmitForm", b"ImportData", b"ResetForm"];
|
||||
|
||||
/// Keys whose value is one action (or, for `/OpenAction`, an action or a destination).
|
||||
const ACTION_KEYS: [&[u8]; 2] = [b"A", b"OpenAction"];
|
||||
|
||||
/// How deep direct objects and action chains are followed.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// How many action values one object may make the walk look at.
|
||||
const MAX_STEPS: usize = 100_000;
|
||||
|
||||
/// How many action values the walk may look at in the whole document.
|
||||
const MAX_TOTAL_STEPS: usize = 1_000_000;
|
||||
|
||||
/// Remove every form submission, import and reset action. Returns how many the document held in
|
||||
/// action entries, each counted once: an indirect action however many entries refer to it, and a
|
||||
/// direct one in the object that holds it. Fails with [`OptimizeError::ActionLimit`] when an
|
||||
/// object holds a form action more than [`MAX_DEPTH`] direct objects deep, chains form actions
|
||||
/// more than [`MAX_DEPTH`] deep, or makes the walk look at more than [`MAX_STEPS`] action values,
|
||||
/// or when the walk has looked at more than [`MAX_TOTAL_STEPS`] action values in the document.
|
||||
pub(crate) fn discard(doc: &mut Document) -> Result<usize, OptimizeError> {
|
||||
let numbers = doc.object_numbers();
|
||||
let mut count = Count::default();
|
||||
for &num in &numbers {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
if let o @ (Object::Dict(_) | Object::Array(_)) = &*doc.get(r) {
|
||||
count.object(doc, o, 0);
|
||||
}
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for num in numbers {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
let mut obj = match &*doc.get(r) {
|
||||
o @ (Object::Dict(_) | Object::Array(_)) => o.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let limit = MAX_STEPS.min(MAX_TOTAL_STEPS - total);
|
||||
let mut walk =
|
||||
Walk { this: r, changed: false, steps: 0, limit, exhausted: false, chain: HashSet::new(), looped: false, writes: HashMap::new() };
|
||||
walk.clean(doc, &mut obj, 0);
|
||||
total += walk.steps.min(limit);
|
||||
if walk.exhausted {
|
||||
return Err(OptimizeError::ActionLimit(r));
|
||||
}
|
||||
if walk.changed {
|
||||
doc.set(r, obj);
|
||||
}
|
||||
for (shared, cleaned) in walk.writes {
|
||||
doc.set(shared, cleaned);
|
||||
}
|
||||
}
|
||||
Ok(count.direct + count.indirect.len())
|
||||
}
|
||||
|
||||
/// Whether `d` is a form action; its `/S` may be stored as an indirect name.
|
||||
fn is_form_action(doc: &Document, d: &Dict) -> bool {
|
||||
d.get(b"S").is_some_and(|s| doc.resolve(s).as_name().is_some_and(|s| FORM_ACTIONS.contains(&s)))
|
||||
}
|
||||
|
||||
/// The form actions in action entries, counted before they are removed. Each object's pass looks
|
||||
/// only at its own direct objects, so a direct action is counted once, in the object that holds
|
||||
/// it; references are counted by identity.
|
||||
#[derive(Default)]
|
||||
struct Count {
|
||||
direct: usize,
|
||||
indirect: HashSet<ObjRef>,
|
||||
/// Indirect `/AA` dictionaries and `/Next` arrays already counted.
|
||||
shared: HashSet<ObjRef>,
|
||||
}
|
||||
|
||||
impl Count {
|
||||
fn object(&mut self, doc: &Document, o: &Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
for key in [b"A".as_slice(), b"OpenAction", b"Next"] {
|
||||
if let Some(v) = d.get(key) {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
match d.get(b"AA") {
|
||||
Some(Object::Ref(r)) if self.shared.insert(*r) => {
|
||||
if let Object::Dict(t) = &*doc.get(*r) {
|
||||
for (_, v) in t.iter() {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Object::Dict(t)) => {
|
||||
for (_, v) in t.iter() {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (_, v) in d.iter() {
|
||||
self.object(doc, v, depth + 1);
|
||||
}
|
||||
}
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.object(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One action entry's value: an action, or (in `/Next`) a list of them.
|
||||
fn entry(&mut self, doc: &Document, v: &Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Object::Dict(a) if is_form_action(doc, a) => self.direct += 1,
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.entry(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
Object::Ref(r) => match &*doc.get(*r) {
|
||||
Object::Dict(a) if is_form_action(doc, a) => {
|
||||
self.indirect.insert(*r);
|
||||
}
|
||||
// An indirect `/Next` array: its direct actions are counted from the first entry
|
||||
// that refers to it, and their own entries by the array's own pass.
|
||||
Object::Array(items) if self.shared.insert(*r) => {
|
||||
for item in items {
|
||||
self.entry(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
/// The object being walked.
|
||||
this: ObjRef,
|
||||
/// Whether the object changed.
|
||||
changed: bool,
|
||||
steps: usize,
|
||||
/// How many action values this object's walk may look at: [`MAX_STEPS`], or what is left of
|
||||
/// [`MAX_TOTAL_STEPS`] when that is less.
|
||||
limit: usize,
|
||||
/// Whether a limit stopped the walk before it looked at everything.
|
||||
exhausted: bool,
|
||||
/// Indirect form actions and indirect arrays on the path of the chain being followed.
|
||||
chain: HashSet<ObjRef>,
|
||||
/// Whether the entry being cleaned holds an indirect array that lists itself.
|
||||
looped: bool,
|
||||
/// Cleaned replacements for indirect actions, `/Next` arrays and `/AA` dictionaries that
|
||||
/// action entries of this object refer to, written over them after the walk.
|
||||
writes: HashMap<ObjRef, Object>,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
/// Take form actions out of the action entries of `o` and of the direct objects inside it.
|
||||
fn clean(&mut self, doc: &Document, o: &mut Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
if holds_form_action(doc, o) {
|
||||
self.exhausted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match o {
|
||||
Object::Dict(d) => self.clean_dict(doc, d, depth),
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.clean(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_dict(&mut self, doc: &Document, d: &mut Dict, depth: usize) {
|
||||
for key in ACTION_KEYS {
|
||||
if let Some(v) = d.get(key)
|
||||
&& let shared = v.as_ref()
|
||||
&& let Some(rest) = self.take(doc, v)
|
||||
{
|
||||
let a = chain(doc, rest);
|
||||
self.put(doc, d, key, shared, a, depth);
|
||||
}
|
||||
}
|
||||
if let Some(v) = d.get(b"Next")
|
||||
&& let shared = v.as_ref()
|
||||
&& let Some(mut rest) = self.take(doc, v)
|
||||
{
|
||||
let next = match rest.len() {
|
||||
0 => None,
|
||||
1 => Some(rest.remove(0)),
|
||||
// An action that other entries may run as their `/A` stays a dictionary.
|
||||
_ if shared.is_some_and(|r| doc.get(r).as_dict().is_some()) => chain(doc, rest),
|
||||
_ => Some(Object::Array(rest)),
|
||||
};
|
||||
self.put(doc, d, b"Next", shared, next, depth);
|
||||
}
|
||||
// Additional actions: each trigger holds one action.
|
||||
if let Some(aa) = d.get(b"AA")
|
||||
&& let shared = aa.as_ref()
|
||||
&& let Object::Dict(t) = &*doc.resolve(aa)
|
||||
{
|
||||
let mut triggers = t.clone();
|
||||
let mut changed = false;
|
||||
let keys: Vec<Vec<u8>> = triggers.iter().map(|(k, _)| k.to_vec()).collect();
|
||||
for key in keys {
|
||||
if let Some(v) = triggers.get(&key)
|
||||
&& let Some(rest) = self.take(doc, v)
|
||||
{
|
||||
changed = true;
|
||||
match chain(doc, rest) {
|
||||
Some(a) => triggers.set(key, a),
|
||||
None => {
|
||||
triggers.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let aa = (!triggers.is_empty()).then_some(Object::Dict(triggers));
|
||||
self.put(doc, d, b"AA", shared, aa, depth);
|
||||
}
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = d.iter().map(|(k, _)| k.to_vec()).collect();
|
||||
for key in keys {
|
||||
if let Some(v) = d.get_mut(&key) {
|
||||
self.clean(doc, v, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set entry `key` of `d` to its cleaned value, or remove it when nothing is left. When the
|
||||
/// entry referred to indirect object `shared` and the cleaned value is a direct object, the
|
||||
/// value (with the form actions inside it taken out too) replaces `shared` and the entry keeps
|
||||
/// its reference, so the objects that share it share one cleaned copy. An entry that refers to
|
||||
/// an object already replaced, or being cleaned, keeps its reference.
|
||||
fn put(&mut self, doc: &Document, d: &mut Dict, key: &[u8], shared: Option<ObjRef>, value: Option<Object>, depth: usize) {
|
||||
match (shared, value) {
|
||||
(_, None) => {
|
||||
d.remove(key);
|
||||
}
|
||||
(Some(r), Some(_)) if self.writes.contains_key(&r) => {}
|
||||
(Some(r), Some(mut v)) if r != self.this && !matches!(v, Object::Ref(_)) => {
|
||||
self.writes.insert(r, Object::Null);
|
||||
self.clean(doc, &mut v, depth + 1);
|
||||
self.writes.insert(r, v);
|
||||
}
|
||||
(_, Some(v)) => d.set(key.to_vec(), v),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Walk::without`] for one action entry; marks the object changed when it changes. An entry
|
||||
/// that changes while it holds an array that lists itself stops the walk when what is left
|
||||
/// still reaches a form action through that array.
|
||||
fn take(&mut self, doc: &Document, v: &Object) -> Option<Vec<Object>> {
|
||||
self.chain.clear();
|
||||
self.looped = false;
|
||||
let rest = self.without(doc, v, 0)?;
|
||||
if self.looped && self.reaches_form_action(doc, &rest) {
|
||||
self.exhausted = true;
|
||||
return None;
|
||||
}
|
||||
self.changed = true;
|
||||
Some(rest)
|
||||
}
|
||||
|
||||
/// The actions an action value stands for once the form actions at the head of its chain are
|
||||
/// taken out: a form action gives way to the actions of its `/Next`. `None` when nothing
|
||||
/// changes (the value is kept as it is, references included).
|
||||
fn without(&mut self, doc: &Document, v: &Object, depth: usize) -> Option<Vec<Object>> {
|
||||
self.steps += 1;
|
||||
if depth > MAX_DEPTH || self.steps > self.limit {
|
||||
self.exhausted = true;
|
||||
return None;
|
||||
}
|
||||
let resolved = doc.resolve(v);
|
||||
match &*resolved {
|
||||
Object::Dict(a) if is_form_action(doc, a) => {
|
||||
let r = v.as_ref();
|
||||
// A chain that comes back to an action it is still inside ends there.
|
||||
if let Some(r) = r
|
||||
&& !self.chain.insert(r)
|
||||
{
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let rest = match a.get(b"Next") {
|
||||
None => Vec::new(),
|
||||
Some(next) => match self.without(doc, next, depth + 1) {
|
||||
Some(rest) => rest,
|
||||
None => match &*doc.resolve(next) {
|
||||
Object::Array(items) => items.clone(),
|
||||
_ => vec![next.clone()],
|
||||
},
|
||||
},
|
||||
};
|
||||
if let Some(r) = r {
|
||||
self.chain.remove(&r);
|
||||
}
|
||||
Some(rest)
|
||||
}
|
||||
Object::Array(items) => {
|
||||
let r = v.as_ref();
|
||||
// An indirect array that lists itself, directly or through other arrays, is left
|
||||
// as it is where it comes back.
|
||||
if let Some(r) = r
|
||||
&& !self.chain.insert(r)
|
||||
{
|
||||
self.looped = true;
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut changed = false;
|
||||
for item in items {
|
||||
match self.without(doc, item, depth + 1) {
|
||||
Some(rest) => {
|
||||
changed = true;
|
||||
out.extend(rest);
|
||||
}
|
||||
None => out.push(item.clone()),
|
||||
}
|
||||
}
|
||||
if let Some(r) = r {
|
||||
self.chain.remove(&r);
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `items`, or an indirect array they list, lists a form action. Each value looked at
|
||||
/// counts as a step.
|
||||
fn reaches_form_action(&mut self, doc: &Document, items: &[Object]) -> bool {
|
||||
let mut seen = HashSet::new();
|
||||
let mut pending = Vec::new();
|
||||
if self.lists_form_action(doc, items, &mut seen, &mut pending) {
|
||||
return true;
|
||||
}
|
||||
while let Some(r) = pending.pop() {
|
||||
if let Object::Array(items) = &*doc.get(r)
|
||||
&& self.lists_form_action(doc, items, &mut seen, &mut pending)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `items`, or a direct array inside them, lists a form action; indirect arrays not
|
||||
/// yet seen go to `pending`.
|
||||
fn lists_form_action(&mut self, doc: &Document, items: &[Object], seen: &mut HashSet<ObjRef>, pending: &mut Vec<ObjRef>) -> bool {
|
||||
let mut stack: Vec<&Object> = items.iter().collect();
|
||||
while let Some(item) = stack.pop() {
|
||||
self.steps += 1;
|
||||
if self.steps > self.limit {
|
||||
self.exhausted = true;
|
||||
return false;
|
||||
}
|
||||
match item {
|
||||
Object::Dict(a) if is_form_action(doc, a) => return true,
|
||||
Object::Array(inner) => stack.extend(inner.iter()),
|
||||
Object::Ref(r) => match &*doc.get(*r) {
|
||||
Object::Dict(a) if is_form_action(doc, a) => return true,
|
||||
Object::Array(_) if seen.insert(*r) => pending.push(*r),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the walk would take a form action out of `o` or the direct objects inside it: a form
|
||||
/// action, or an action entry (or a `/Next` array or `/AA` dictionary, direct or indirect) that
|
||||
/// refers to one. Looks through `o` without recursion.
|
||||
fn holds_form_action(doc: &Document, o: &Object) -> bool {
|
||||
let mut stack = vec![o];
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
if is_form_action(doc, d) {
|
||||
return true;
|
||||
}
|
||||
for key in [b"A".as_slice(), b"OpenAction", b"Next"] {
|
||||
let refers = match d.get(key) {
|
||||
Some(Object::Array(items)) => items.iter().any(|item| refers_to_form_action(doc, item)),
|
||||
Some(v) => refers_to_form_action(doc, v),
|
||||
None => false,
|
||||
};
|
||||
if refers {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(aa) = d.get(b"AA")
|
||||
&& let Object::Dict(t) = &*doc.resolve(aa)
|
||||
&& t.iter().any(|(_, v)| is_or_refers_to_form_action(doc, v))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v));
|
||||
}
|
||||
Object::Array(items) => stack.extend(items.iter()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `v` is a direct form action or a reference to one.
|
||||
fn is_or_refers_to_form_action(doc: &Document, v: &Object) -> bool {
|
||||
match v {
|
||||
Object::Dict(a) => is_form_action(doc, a),
|
||||
Object::Ref(r) => matches!(&*doc.get(*r), Object::Dict(a) if is_form_action(doc, a)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` refers to a form action, or to an indirect array that lists one.
|
||||
fn refers_to_form_action(doc: &Document, v: &Object) -> bool {
|
||||
let Object::Ref(r) = v else { return false };
|
||||
match &*doc.get(*r) {
|
||||
Object::Dict(a) => is_form_action(doc, a),
|
||||
Object::Array(items) => items.iter().any(|item| is_or_refers_to_form_action(doc, item)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One action value for a list of actions run in order: the first action, with the others
|
||||
/// after its own `/Next` actions. `None` when no action remains.
|
||||
fn chain(doc: &Document, actions: Vec<Object>) -> Option<Object> {
|
||||
let mut actions = actions.into_iter();
|
||||
loop {
|
||||
let first = actions.next()?;
|
||||
let rest: Vec<Object> = actions.as_slice().to_vec();
|
||||
if rest.is_empty() {
|
||||
return Some(first);
|
||||
}
|
||||
// A list needs a dictionary at its head to carry the rest; anything else is dropped.
|
||||
let Some(mut head) = doc.resolve(&first).as_dict().cloned() else { continue };
|
||||
let mut next = match head.get(b"Next") {
|
||||
None => Vec::new(),
|
||||
Some(n) => match &*doc.resolve(n) {
|
||||
Object::Array(items) => items.clone(),
|
||||
_ => vec![n.clone()],
|
||||
},
|
||||
};
|
||||
next.extend(rest);
|
||||
head.set(b"Next".to_vec(), Object::Array(next));
|
||||
return Some(Object::Dict(head));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
//! between them, each drawing an image upright (no rotation, skew or flip);
|
||||
//! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array
|
||||
//! that changes anything, in a codec PdfCraft decodes;
|
||||
//! - they share their left and right edges and are stacked edge to edge with no gap or overlap
|
||||
//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them.
|
||||
//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed
|
||||
//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least
|
||||
//! [`MIN_FRAGMENTS`] of them.
|
||||
//!
|
||||
//! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost
|
||||
//! until the image settings downsample it; it is stored losslessly. A page whose content
|
||||
@@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe
|
||||
(w > 0 && h > 0).then_some((r, w, h, cs, n))
|
||||
}
|
||||
|
||||
/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge.
|
||||
/// Sorts it from the top down.
|
||||
fn stacked(run: &mut [Fragment]) -> bool {
|
||||
let Some(first) = run.first() else { return false };
|
||||
let [x0, _, x1, _] = first.rect;
|
||||
let width = x1 - x0;
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let tol = 1e-3 * width.max(1.0);
|
||||
if run.iter().any(|f| {
|
||||
(f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
run.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]),
|
||||
_ => true,
|
||||
})
|
||||
/// Whether `a` and `b` share their top and bottom edges within row tolerance.
|
||||
fn same_y(a: &Fragment, b: &Fragment) -> bool {
|
||||
let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]);
|
||||
(a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on while each is drawn right under (or right over) the one
|
||||
/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side,
|
||||
/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right
|
||||
/// within each row. Returns that exact row partition: tolerance-based membership is not
|
||||
/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor.
|
||||
fn stacked(run: &mut [Fragment]) -> Option<Vec<std::ops::Range<usize>>> {
|
||||
if run.is_empty()
|
||||
|| run.iter().any(|f| {
|
||||
let [x0, y0, x1, y1] = f.rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
!(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
let mut row_bounds: Vec<[f64; 4]> = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < run.len() {
|
||||
let mut end = start + 1;
|
||||
while end < run.len() && same_y(&run[start], &run[end]) {
|
||||
end += 1;
|
||||
}
|
||||
let row = &mut run[start..end];
|
||||
row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
if !row.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]),
|
||||
_ => true,
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let (Some(first), Some(last)) = (row.first(), row.last()) else { return None };
|
||||
row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]);
|
||||
rows.push(start..end);
|
||||
start = end;
|
||||
}
|
||||
let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None };
|
||||
let (width, height) = (x1 - x0, y1 - y0);
|
||||
if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol_x = 1e-3 * width.max(1.0);
|
||||
if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) {
|
||||
return None;
|
||||
}
|
||||
row_bounds
|
||||
.windows(2)
|
||||
.all(|w| match w {
|
||||
[a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]),
|
||||
_ => true,
|
||||
})
|
||||
.then_some(rows)
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row
|
||||
/// or strip by strip in one direction, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
let mut run: Vec<Fragment> = Vec::new();
|
||||
let mut row_x = [0.0f64, 0.0];
|
||||
let mut cur_x = [0.0f64, 0.0];
|
||||
let mut cur_y = [0.0f64, 0.0];
|
||||
let mut first_row = true;
|
||||
let mut right: Option<bool> = None;
|
||||
let mut down: Option<bool> = None;
|
||||
let mut complete = 0usize;
|
||||
let mut j = i;
|
||||
while let Some((next, m, name)) = group_at(ops, j) {
|
||||
while run.len() < limit {
|
||||
let Some((next, m, name)) = group_at(ops, j) else { break };
|
||||
let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break };
|
||||
let [a, _, _, d, e, f] = m.0;
|
||||
let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height };
|
||||
if let Some(prev) = run.last() {
|
||||
let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (prev.rect[1] - f2.rect[3]).abs() <= tol;
|
||||
let above = (f2.rect[1] - prev.rect[3]).abs() <= tol;
|
||||
let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0);
|
||||
match (down, below, above) {
|
||||
_ if !same_x => break,
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y;
|
||||
if same_row {
|
||||
let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]);
|
||||
let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x;
|
||||
let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x;
|
||||
match (right, step_right, step_left) {
|
||||
(None | Some(true), true, _) => {
|
||||
right = Some(true);
|
||||
cur_x[1] = f2.rect[2];
|
||||
}
|
||||
(None | Some(false), _, true) => {
|
||||
right = Some(false);
|
||||
cur_x[0] = f2.rect[0];
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
if first_row {
|
||||
row_x = cur_x;
|
||||
complete = run.len() + 1;
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span {
|
||||
break;
|
||||
}
|
||||
if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span {
|
||||
complete = run.len() + 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span {
|
||||
break;
|
||||
}
|
||||
let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v;
|
||||
let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v;
|
||||
match (down, below, above) {
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
}
|
||||
let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span;
|
||||
let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span;
|
||||
if starts_left && starts_right {
|
||||
right = None;
|
||||
complete = run.len() + 1;
|
||||
} else if starts_left {
|
||||
right = Some(true);
|
||||
} else if starts_right {
|
||||
right = Some(false);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
first_row = false;
|
||||
cur_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
}
|
||||
} else {
|
||||
row_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_x = row_x;
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
complete = 1;
|
||||
}
|
||||
run.push(f2);
|
||||
j = next;
|
||||
}
|
||||
run.truncate(complete);
|
||||
run
|
||||
}
|
||||
|
||||
@@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
|
||||
}
|
||||
|
||||
/// The run's pixels as one image, top row first: `(pixels, width, height)`.
|
||||
fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]);
|
||||
let width = run.iter().map(|f| f.width).max()?;
|
||||
// Pixel rows per unit of height: the finest fragment's.
|
||||
let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let height = ((top - bottom) * density).round();
|
||||
fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range<usize>], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (first, last) = (run.first()?, run.last()?);
|
||||
let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]);
|
||||
let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::<Option<_>>()?;
|
||||
// Pixels per unit along each axis: the finest fragment's.
|
||||
let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max);
|
||||
let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() };
|
||||
let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() };
|
||||
if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
// In range and whole, so the cast is exact.
|
||||
let height = height as u32;
|
||||
// In range and whole, so the casts are exact.
|
||||
let (width, height) = (width as u32, height as u32);
|
||||
if u64::from(width) * u64::from(height) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let row = (width as usize).checked_mul(n)?;
|
||||
let stride = (width as usize).checked_mul(n)?;
|
||||
// What this builds isn't what its strips cost to read: four small ones can ask for all of it.
|
||||
let bytes = row.checked_mul(height as usize)?;
|
||||
let bytes = stride.checked_mul(height as usize)?;
|
||||
if !budget.pay_pixels(u64::try_from(bytes).ok()?) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![255u8; bytes];
|
||||
for f in run {
|
||||
let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height);
|
||||
let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height);
|
||||
let mut r0 = 0u32;
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) };
|
||||
if r1 <= r0 {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? };
|
||||
out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?);
|
||||
let th = r1 - r0;
|
||||
let mut c0 = 0u32;
|
||||
for (col_idx, f) in row.iter().enumerate() {
|
||||
let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) };
|
||||
if c1 <= c0 {
|
||||
return None;
|
||||
}
|
||||
let tw = c1 - c0;
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? };
|
||||
let tile_row = (tw as usize).checked_mul(n)?;
|
||||
let col_off = (c0 as usize).checked_mul(n)?;
|
||||
for r in 0..th as usize {
|
||||
let dst = (r0 as usize + r) * stride + col_off;
|
||||
let src = r * tile_row;
|
||||
out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?);
|
||||
}
|
||||
c0 = c1;
|
||||
}
|
||||
r0 = r1;
|
||||
}
|
||||
Some((out, width, height))
|
||||
}
|
||||
@@ -288,13 +411,15 @@ fn merge_page(
|
||||
j = next;
|
||||
}
|
||||
scanned = scanned.saturating_add(run.len()).saturating_add(1);
|
||||
// Not one picture as a whole: the rows drawn one under the other from the start may be.
|
||||
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
|
||||
run = drawn_in_sequence(&ops, i, &xobjects, doc);
|
||||
// Not one picture as a whole: a prefix drawn row by row or strip by strip may be.
|
||||
let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None };
|
||||
if run.len() >= MIN_FRAGMENTS && rows.is_none() {
|
||||
run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc);
|
||||
j = run_end(&ops, i, run.len());
|
||||
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
|
||||
rows = stacked(&mut run);
|
||||
}
|
||||
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
|
||||
let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else {
|
||||
// Not a run: keep this op and look again from the next one.
|
||||
if let Some(op) = ops.get(i) {
|
||||
out.push(op.clone());
|
||||
@@ -305,7 +430,7 @@ fn merge_page(
|
||||
if !progress(Stage::Fragments) {
|
||||
return Err(OptimizeError::Cancelled);
|
||||
}
|
||||
let Some((px, w, h)) = merge(doc, &run, n, images) else {
|
||||
let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else {
|
||||
out.extend(ops.get(i..j).unwrap_or_default().iter().cloned());
|
||||
i = j;
|
||||
continue;
|
||||
@@ -322,7 +447,7 @@ fn merge_page(
|
||||
xobjects.set(name.clone(), Object::Ref(image));
|
||||
add_xobject(doc, page, &name, image)?;
|
||||
let (Some(first), Some(last)) = (run.first(), run.last()) else { break };
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]];
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]];
|
||||
let num = Object::Real;
|
||||
out.push(Op::new("q", Vec::new()));
|
||||
out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)]));
|
||||
@@ -339,6 +464,7 @@ fn merge_page(
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Merge consecutive tiled image fragments across `pages`.
|
||||
pub(crate) fn run(
|
||||
doc: &mut Document,
|
||||
pages: &[ObjRef],
|
||||
@@ -361,17 +487,45 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers.
|
||||
#[test]
|
||||
fn a_merged_height_beyond_u32_is_refused_before_allocating() {
|
||||
// Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be
|
||||
// 2^16 × 2^48 pixels, whose product wraps to 0 in u64.
|
||||
let image = ObjRef { num: 1, generation: 0 };
|
||||
let edge = 1.0 - (-48f64).exp2();
|
||||
let run = [
|
||||
let vertical = [
|
||||
Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 },
|
||||
Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
let horizontal = [
|
||||
Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 },
|
||||
Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Even an inconsistent partition must not silently omit a row or tile from its report.
|
||||
#[test]
|
||||
fn assembly_refuses_collapsed_output_rows_and_tiles() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(10));
|
||||
dict.set(b"Height".to_vec(), Object::Int(10));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100])));
|
||||
let mut run: Vec<_> = (0..4)
|
||||
.map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 })
|
||||
.collect();
|
||||
// The first supposed row consumes the entire height; the second cannot be discarded.
|
||||
assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
// Likewise, a tile whose right edge does not advance cannot simply vanish.
|
||||
if let Some(tile) = run.get_mut(1) {
|
||||
tile.rect = [0.0, 0.0, 10.0, 10.0];
|
||||
}
|
||||
assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Merging reads the images the pass after it reads, from the same allowance: with nothing
|
||||
@@ -474,4 +628,149 @@ mod tests {
|
||||
let reads = GROUP_READS.with(|c| c.get());
|
||||
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
|
||||
}
|
||||
|
||||
/// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid
|
||||
/// or separated runs are handled per run.
|
||||
#[test]
|
||||
fn side_by_side_and_grid_fragments_merge_into_one_image() {
|
||||
let tile = |doc: &mut Document, w: u32, h: u32, px: Vec<u8>| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
doc.add(Object::Stream(Stream::flate(d, &px)))
|
||||
};
|
||||
let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| {
|
||||
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes())));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
doc.add(Object::Dict(page))
|
||||
};
|
||||
|
||||
// Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3
|
||||
// pixels so the merged image keeps the finest horizontal resolution (8×3).
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for c in 0..4u8 {
|
||||
let w = if c % 2 == 0 { 1 } else { 2 };
|
||||
let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]);
|
||||
xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap();
|
||||
let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3)));
|
||||
assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3));
|
||||
let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||||
let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") };
|
||||
let parsed = parse(&cs.decoded().unwrap());
|
||||
let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap();
|
||||
assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]);
|
||||
|
||||
// A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for r in 0..2u8 {
|
||||
for c in 0..3u8 {
|
||||
let v = r * 100 + c * 20;
|
||||
let im = tile(&mut doc, 2, 2, vec![v; 4]);
|
||||
xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
}
|
||||
let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)]
|
||||
.into_iter()
|
||||
.map(|(r, c)| {
|
||||
let x = 5.0 + f64::from(c) * 10.0;
|
||||
let y = 50.0 + f64::from(1 - r) * 10.0;
|
||||
format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n")
|
||||
})
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (6, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4)));
|
||||
let mut expected = Vec::new();
|
||||
expected.extend([0, 0, 20, 20, 40, 40].repeat(2));
|
||||
expected.extend([100, 100, 120, 120, 140, 140].repeat(2));
|
||||
assert_eq!(s.decoded().unwrap(), expected);
|
||||
|
||||
// Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images,
|
||||
// whether drawn left to right or right to left.
|
||||
for right_to_left in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![80; 4]);
|
||||
xobjects.set(b"S".to_vec(), Object::Ref(im));
|
||||
let xs: Vec<f64> = if right_to_left {
|
||||
vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0]
|
||||
} else {
|
||||
vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0]
|
||||
};
|
||||
let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}");
|
||||
}
|
||||
|
||||
// A 2×2 grid followed immediately by a lone fifth tile below only the first column merges
|
||||
// the 4 grid tiles and leaves the fifth tile alone.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n"))
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
|
||||
// Overlapping side-by-side strips or rows of different widths are left alone.
|
||||
for bad_ops in [
|
||||
"q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n",
|
||||
"q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n",
|
||||
] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let page = build_page(&mut doc, xobjects, bad_ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
|
||||
//! Flate once resampled or packed, when that is smaller.
|
||||
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
|
||||
//! print settings.
|
||||
//! print settings, form submission, import and reset actions.
|
||||
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
|
||||
//! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or
|
||||
//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead.
|
||||
@@ -29,6 +29,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod audit;
|
||||
mod form_actions;
|
||||
pub mod fragments;
|
||||
mod images;
|
||||
mod links;
|
||||
@@ -49,6 +50,8 @@ pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
|
||||
pub enum OptimizeError {
|
||||
#[error("the document has no page tree")]
|
||||
NoPages,
|
||||
#[error("object {0} nests or chains its form actions too deeply, or the document holds too many actions, to discard them")]
|
||||
ActionLimit(ObjRef),
|
||||
#[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")]
|
||||
IncompatibleEncryption { required: &'static str, chosen: &'static str },
|
||||
#[error(transparent)]
|
||||
@@ -197,6 +200,8 @@ pub struct Settings {
|
||||
pub discard_alternate_images: bool,
|
||||
pub discard_tags: bool,
|
||||
pub discard_print_settings: bool,
|
||||
/// Remove form submission, import and reset actions; the fields and links that ran them stay.
|
||||
pub discard_form_actions: bool,
|
||||
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
|
||||
/// ASCIIHex layer from streams that have one.
|
||||
pub flate_unencoded: bool,
|
||||
@@ -233,6 +238,7 @@ impl Default for Settings {
|
||||
discard_alternate_images: true,
|
||||
discard_tags: false,
|
||||
discard_print_settings: false,
|
||||
discard_form_actions: false,
|
||||
flate_unencoded: true,
|
||||
object_streams: true,
|
||||
lzw_to_flate: true,
|
||||
@@ -297,6 +303,8 @@ pub struct Report {
|
||||
pub alternate_images: usize,
|
||||
pub tags_removed: bool,
|
||||
pub print_settings: usize,
|
||||
/// Form submission, import and reset actions removed.
|
||||
pub form_actions: usize,
|
||||
pub streams_compressed: usize,
|
||||
/// Streams whose LZW compression was replaced with Flate (Clean Up).
|
||||
pub lzw_streams: usize,
|
||||
@@ -426,6 +434,9 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if settings.discard_form_actions {
|
||||
report.form_actions = form_actions::discard(doc)?;
|
||||
}
|
||||
if settings.remove_invalid_links {
|
||||
let (l, b) = links::remove_invalid(doc, &pages)?;
|
||||
report.invalid_links = l;
|
||||
|
||||
@@ -5,16 +5,19 @@ use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary.
|
||||
fn named_dests(doc: &Document) -> HashSet<Vec<u8>> {
|
||||
/// Named destinations whose target page exists: the `/Names /Dests` tree and the old `/Dests`
|
||||
/// dictionary.
|
||||
fn named_dests(doc: &Document, pages: &HashSet<ObjRef>, count: usize) -> HashSet<Vec<u8>> {
|
||||
let mut out = HashSet::new();
|
||||
let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out };
|
||||
if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) {
|
||||
out.extend(d.iter().map(|(k, _)| k.clone()));
|
||||
out.extend(d.iter().filter(|(_, v)| valid_explicit(doc, v, pages, count)).map(|(k, _)| k.clone()));
|
||||
}
|
||||
if let Some(tree) = names_tree(doc, &cat) {
|
||||
for (k, _) in tree_entries(doc, &tree) {
|
||||
out.insert(k);
|
||||
for (k, v) in tree_entries(doc, &tree) {
|
||||
if valid_explicit(doc, &v, pages, count) {
|
||||
out.insert(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -74,22 +77,33 @@ fn dest_name(o: &Object) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn valid(o: &Object, pages: &HashSet<ObjRef>, count: usize, names: &HashSet<Vec<u8>>) -> bool {
|
||||
match o {
|
||||
Object::Array(a) => match a.first() {
|
||||
Some(Object::Ref(r)) => pages.contains(r),
|
||||
Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count,
|
||||
_ => false,
|
||||
},
|
||||
other => dest_name(other).is_some_and(|n| names.contains(&n)),
|
||||
/// The `[page /Fit …]` array of an explicit destination (an array or a `<< /D […] >>` dictionary).
|
||||
fn explicit_array(doc: &Document, o: &Object) -> Option<Vec<Object>> {
|
||||
match doc.resolve(o).as_ref() {
|
||||
Object::Array(a) => Some(a.clone()),
|
||||
Object::Dict(d) => d.get(b"D").and_then(|x| doc.resolve(x).as_array().cloned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an explicit destination targets one of the document's pages.
|
||||
fn valid_explicit(doc: &Document, o: &Object, pages: &HashSet<ObjRef>, count: usize) -> bool {
|
||||
explicit_array(doc, o).is_some_and(|a| match a.first() {
|
||||
Some(Object::Ref(r)) => pages.contains(r),
|
||||
Some(Object::Int(i)) => usize::try_from(*i).is_ok_and(|i| i < count),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid(doc: &Document, o: &Object, pages: &HashSet<ObjRef>, count: usize, names: &HashSet<Vec<u8>>) -> bool {
|
||||
valid_explicit(doc, o, pages, count) || dest_name(o).is_some_and(|n| names.contains(&n))
|
||||
}
|
||||
|
||||
/// Remove links whose destination is missing, and clear the destination of such bookmarks
|
||||
/// (they stay, so their children do too). Returns (links, bookmarks).
|
||||
pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), pdfcraft_cos::CosError> {
|
||||
let set: HashSet<ObjRef> = pages.iter().copied().collect();
|
||||
let names = named_dests(doc);
|
||||
let names = named_dests(doc, &set, pages.len());
|
||||
let mut links = 0;
|
||||
for p in pages {
|
||||
let Some(pd) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
@@ -101,7 +115,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
if d.name(b"Subtype") != Some(b"Link") {
|
||||
return true;
|
||||
}
|
||||
destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names))
|
||||
destination(doc, &d).is_none_or(|dest| valid(doc, &dest, &set, pages.len(), &names))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -130,7 +144,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
stack.push(f);
|
||||
}
|
||||
if let Some(dest) = destination(doc, &d)
|
||||
&& !valid(&dest, &set, pages.len(), &names)
|
||||
&& !valid(doc, &dest, &set, pages.len(), &names)
|
||||
{
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"Dest");
|
||||
@@ -142,39 +156,40 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
Ok((links, bookmarks))
|
||||
}
|
||||
|
||||
/// Every named destination some object refers to (links, bookmarks, actions, open action).
|
||||
/// Every named destination some reachable object refers to (links, bookmarks, actions, open action).
|
||||
fn referenced_names(doc: &Document) -> HashSet<Vec<u8>> {
|
||||
let mut out = HashSet::new();
|
||||
let mut visit = |o: &Object| {
|
||||
let mut stack = vec![o.clone()];
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
// Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name
|
||||
// string, and an unresolved ref has no name, so the destination it uses would
|
||||
// look unreferenced and be removed. A GoToR or GoToE /D names a destination in
|
||||
// another document, so it does not count as a use of this one's.
|
||||
if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) {
|
||||
out.insert(n);
|
||||
}
|
||||
if d.name(b"S") == Some(b"GoTo")
|
||||
&& let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref()))
|
||||
{
|
||||
out.insert(n);
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v.clone()));
|
||||
}
|
||||
Object::Array(a) => stack.extend(a),
|
||||
Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
for num in doc.object_numbers() {
|
||||
visit(&doc.get(ObjRef::new(num, doc.generation(num))));
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack = vec![Object::Dict(doc.trailer().clone())];
|
||||
if let Some(r) = doc.root() {
|
||||
visit(&doc.get(r));
|
||||
stack.push(Object::Ref(r));
|
||||
}
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Ref(r) => {
|
||||
if seen.insert(r) {
|
||||
stack.push(doc.get(r).as_ref().clone());
|
||||
}
|
||||
}
|
||||
Object::Dict(d) => {
|
||||
// Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name
|
||||
// string, and an unresolved ref has no name, so the destination it uses would
|
||||
// look unreferenced and be removed. A GoToR or GoToE /D names a destination in
|
||||
// another document, so it does not count as a use of this one's.
|
||||
if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) {
|
||||
out.insert(n);
|
||||
}
|
||||
if d.name(b"S") == Some(b"GoTo")
|
||||
&& let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref()))
|
||||
{
|
||||
out.insert(n);
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v.clone()));
|
||||
}
|
||||
Object::Array(a) => stack.extend(a),
|
||||
Object::Stream(s) => stack.push(Object::Dict(s.dict)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2450,6 +2450,347 @@ fn discards_and_clean_up() {
|
||||
assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay");
|
||||
}
|
||||
|
||||
fn action(kind: &str) -> Dict {
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name(kind));
|
||||
a
|
||||
}
|
||||
|
||||
fn with(mut d: Dict, key: &str, value: impl Into<Object>) -> Dict {
|
||||
d.set(key.as_bytes().to_vec(), value);
|
||||
d
|
||||
}
|
||||
|
||||
/// The kinds of the actions `o` runs, in order: each action, then its `/Next` actions.
|
||||
fn kinds(doc: &Document, o: Option<&Object>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Some(o) = o else { return out };
|
||||
match &*doc.resolve(o) {
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
out.extend(kinds(doc, Some(item)));
|
||||
}
|
||||
}
|
||||
Object::Dict(a) => {
|
||||
out.push(String::from_utf8_lossy(a.name(b"S").unwrap_or_default()).into_owned());
|
||||
out.extend(kinds(doc, a.get(b"Next")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dict(doc: &Document, r: ObjRef) -> Dict {
|
||||
doc.get(r).as_dict().cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
let triggers = with(Dict::new(), "U", action("ResetForm"));
|
||||
let triggers = with(triggers, "E", with(action("JavaScript"), "JS", Object::String(pdfcraft_cos::PdfString::literal("app.alert(1)"))));
|
||||
let button = with(with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Btn")), "A", action("SubmitForm"));
|
||||
let button = doc.add(Object::Dict(with(button, "AA", triggers)));
|
||||
let import = doc.add(Object::Dict(action("ImportData")));
|
||||
let field = with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Tx"));
|
||||
let field = doc.add(Object::Dict(with(field, "AA", with(Dict::new(), "K", Object::Ref(import)))));
|
||||
let link = with(with(Dict::new(), "Subtype", Object::name("Link")), "A", with(action("GoTo"), "Next", action("SubmitForm")));
|
||||
let link = doc.add(Object::Dict(with(link, "D", Object::Array(vec![Object::Ref(p), Object::name("Fit")]))));
|
||||
let reset = doc.add(Object::Dict(action("ResetForm")));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(button), Object::Ref(field), Object::Ref(link)]))).unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(reset))).unwrap();
|
||||
|
||||
let mut kept = doc.clone();
|
||||
assert_eq!(optimize(&mut kept, &Settings::default()).unwrap().form_actions, 0, "the option is off by default");
|
||||
assert_eq!(kinds(&kept, dict(&kept, button).get(b"A")), ["SubmitForm"]);
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 5, "{r:?}");
|
||||
let annots = dict(&doc, p).get(b"Annots").and_then(|a| a.as_array().cloned()).unwrap();
|
||||
assert_eq!(annots.len(), 3, "the fields and the link stay");
|
||||
let b = dict(&doc, button);
|
||||
assert_eq!(b.name(b"FT"), Some(&b"Btn"[..]));
|
||||
assert!(!b.contains(b"A"), "{b:?}");
|
||||
let aa = b.get(b"AA").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]), "other triggers stay");
|
||||
assert!(!dict(&doc, field).contains(b"AA"), "an emptied /AA goes");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discarded_form_action_hands_its_place_to_the_actions_after_it() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
// A reset whose /S is an indirect name.
|
||||
let name = doc.add(Object::name("ResetForm"));
|
||||
let reset = doc.add(Object::Dict(with(with(Dict::new(), "S", Object::Ref(name)), "Next", action("URI"))));
|
||||
let submit = with(
|
||||
action("SubmitForm"),
|
||||
"Next",
|
||||
Object::Array(vec![Object::Dict(action("JavaScript")), Object::Ref(reset), Object::Dict(action("Named"))]),
|
||||
);
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit)));
|
||||
// A reset whose chain comes back to itself.
|
||||
let looped = doc.add(Object::Dict(action("ResetForm")));
|
||||
doc.update_dict(looped, |a| a.set(b"Next".to_vec(), Object::Ref(looped))).unwrap();
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(looped))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 3, "{r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "URI", "Named"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_action_listed_twice_in_a_chain_gives_way_both_times() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let reset = doc.add(Object::Dict(with(action("ResetForm"), "Next", action("JavaScript"))));
|
||||
let submit = with(action("SubmitForm"), "Next", Object::Array(vec![Object::Ref(reset), Object::Ref(reset)]));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
// An indirect submit that holds a direct reset in its /Next.
|
||||
let root = doc.root().unwrap();
|
||||
let opening = doc.add(Object::Dict(with(action("SubmitForm"), "Next", action("ResetForm"))));
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(opening))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 4, "each action counts once: {r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "JavaScript"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_actions_chained_past_the_limit_stop_the_optimization() {
|
||||
let mut doc = Document::new_empty();
|
||||
page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
let mut chain = action("ResetForm");
|
||||
for _ in 0..80 {
|
||||
chain = with(action("SubmitForm"), "Next", chain);
|
||||
}
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), chain)).unwrap();
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() });
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == root), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_next_array_that_lists_itself_is_left_as_it_is() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let looped = doc.add(Object::Null);
|
||||
doc.set(looped, Object::Array(vec![Object::Ref(looped)]));
|
||||
let (first, second) = (doc.add(Object::Null), doc.add(Object::Null));
|
||||
doc.set(first, Object::Array(vec![Object::Ref(second)]));
|
||||
doc.set(second, Object::Array(vec![Object::Ref(first)]));
|
||||
let link = |doc: &mut Document, next: ObjRef| {
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)))
|
||||
};
|
||||
let (own, pair) = (link(&mut doc, looped), link(&mut doc, first));
|
||||
let submit = with(action("SubmitForm"), "Next", Object::Ref(looped));
|
||||
let button = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", submit)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(own), Object::Ref(pair), Object::Ref(button)]))).unwrap();
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
|
||||
let mut cleaned = doc.clone();
|
||||
let r = optimize(&mut cleaned, &settings).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
for (l, next) in [(own, looped), (pair, first)] {
|
||||
let a = dict(&cleaned, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}");
|
||||
}
|
||||
assert_eq!(dict(&cleaned, button).get(b"A"), Some(&Object::Ref(looped)), "the submit's /Next takes its place");
|
||||
|
||||
// An array that lists itself and a submit, directly or in an array inside it: a cleaned copy
|
||||
// would still reach the submit.
|
||||
for submit in [Object::Dict(action("SubmitForm")), Object::Array(vec![Object::Dict(action("SubmitForm"))])] {
|
||||
let mut mixed = doc.clone();
|
||||
mixed.set(first, Object::Array(vec![Object::Ref(second), submit]));
|
||||
let r = optimize(&mut mixed, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `referrers` actions whose `/Next` is one shared array of 2,000 actions, a submit among them.
|
||||
fn shared_next_array(referrers: usize) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
page(&mut doc, &[], "");
|
||||
let script = doc.add(Object::Dict(action("JavaScript")));
|
||||
let mut items = vec![Object::Ref(script); 1_999];
|
||||
items.push(Object::Dict(action("SubmitForm")));
|
||||
let shared = doc.add(Object::Array(items));
|
||||
for _ in 0..referrers {
|
||||
doc.add(Object::Dict(with(action("GoTo"), "Next", Object::Ref(shared))));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_next_array_cleaned_for_too_many_actions_stops_the_optimization() {
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
let mut doc = shared_next_array(400);
|
||||
assert_eq!(optimize(&mut doc, &settings).unwrap().form_actions, 1);
|
||||
// Each referrer is under the limit for one object; together they pass the document's.
|
||||
let mut doc = shared_next_array(600);
|
||||
let r = optimize(&mut doc, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}");
|
||||
}
|
||||
|
||||
/// 80 direct dictionaries nested in each other under `/Deep`, the innermost one being `inner`.
|
||||
fn nested(inner: Dict) -> Object {
|
||||
let mut o = Object::Dict(inner);
|
||||
for _ in 0..80 {
|
||||
o = Object::Dict(with(Dict::new(), "Deep", o));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let plain = doc.add(nested(with(action("GoTo"), "Next", action("URI"))));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", action("SubmitForm"))));
|
||||
doc.update_dict(p, |d| {
|
||||
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]));
|
||||
d.set(b"PieceInfo".to_vec(), Object::Ref(plain));
|
||||
})
|
||||
.unwrap();
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
let mut ok = doc.clone();
|
||||
let r = optimize(&mut ok, &settings).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "deep nesting with no form action in it is left as it is: {r:?}");
|
||||
|
||||
let reset = doc.add(Object::Dict(action("ResetForm")));
|
||||
for (n, inner) in [
|
||||
action("SubmitForm"),
|
||||
with(Dict::new(), "A", Object::Ref(reset)),
|
||||
with(Dict::new(), "Next", Object::Ref(doc.add(Object::Array(vec![Object::Ref(reset)])))),
|
||||
with(Dict::new(), "AA", Object::Ref(doc.add(Object::Dict(with(Dict::new(), "U", action("ImportData")))))),
|
||||
with(Dict::new(), "AA", with(Dict::new(), "U", Object::Ref(reset))),
|
||||
with(Dict::new(), "Next", Object::Array(vec![Object::Ref(reset)])),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut deep = doc.clone();
|
||||
let held = deep.add(nested(inner));
|
||||
deep.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(held))).unwrap();
|
||||
let r = optimize(&mut deep, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == held), "case {n}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let triggers = with(with(Dict::new(), "U", action("SubmitForm")), "E", action("JavaScript"));
|
||||
let shared = doc.add(Object::Dict(triggers));
|
||||
let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "AA", Object::Ref(shared))));
|
||||
let (first, second) = (widget(&mut doc), widget(&mut doc));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
for w in [first, second] {
|
||||
assert_eq!(dict(&doc, w).get(b"AA"), Some(&Object::Ref(shared)), "the widgets still share one dictionary: {w:?}");
|
||||
}
|
||||
let aa = dict(&doc, shared);
|
||||
assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// A shared submit whose /Next is a script that resets the form after it runs.
|
||||
let script = with(action("JavaScript"), "Next", action("ResetForm"));
|
||||
let submit = doc.add(Object::Dict(with(action("SubmitForm"), "Next", script)));
|
||||
let next = doc.add(Object::Array(vec![Object::Dict(action("ImportData")), Object::Dict(action("URI")), Object::Dict(action("Named"))]));
|
||||
let link = |doc: &mut Document| {
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)))
|
||||
};
|
||||
let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit))));
|
||||
let (w1, w2, l1, l2) = (widget(&mut doc), widget(&mut doc), link(&mut doc), link(&mut doc));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([w1, w2, l1, l2].map(Object::Ref).to_vec()))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 3, "{r:?}");
|
||||
for w in [w1, w2] {
|
||||
assert_eq!(dict(&doc, w).get(b"A"), Some(&Object::Ref(submit)), "{w:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, w).get(b"A")), ["JavaScript"], "{w:?}");
|
||||
}
|
||||
for l in [l1, l2] {
|
||||
let a = dict(&doc, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, l).get(b"A")), ["GoTo", "URI", "Named"], "{l:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_action_in_an_array_inside_an_indirect_next_array_is_counted() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let next = doc.add(Object::Array(vec![Object::Array(vec![Object::Dict(action("ResetForm"))]), Object::Dict(action("URI"))]));
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo", "URI"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_action_run_as_a_next_action_and_as_an_a_entry_stays_a_dictionary() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// A reset before two actions, run by one link after a GoTo and by another as its /A.
|
||||
let reset = doc.add(Object::Dict(with(
|
||||
action("ResetForm"),
|
||||
"Next",
|
||||
Object::Array(vec![Object::Dict(action("JavaScript")), Object::Dict(action("URI"))]),
|
||||
)));
|
||||
let link = |doc: &mut Document, a: Object| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)));
|
||||
let after = link(&mut doc, Object::Dict(with(action("GoTo"), "Next", Object::Ref(reset))));
|
||||
let runs = link(&mut doc, Object::Ref(reset));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([after, runs].map(Object::Ref).to_vec()))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(dict(&doc, reset).name(b"S"), Some(&b"JavaScript"[..]), "the reset's place is taken by a dictionary");
|
||||
assert_eq!(dict(&doc, runs).get(b"A"), Some(&Object::Ref(reset)));
|
||||
assert_eq!(kinds(&doc, dict(&doc, runs).get(b"A")), ["JavaScript", "URI"]);
|
||||
let a = dict(&doc, after).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(reset)));
|
||||
assert_eq!(kinds(&doc, dict(&doc, after).get(b"A")), ["GoTo", "JavaScript", "URI"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_submit_whose_script_runs_it_again_gives_way_to_the_script() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// The widget comes first, so the walk reaches the submit through it.
|
||||
let widget = doc.add(Object::Null);
|
||||
let submit = doc.add(Object::Null);
|
||||
doc.set(widget, Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit))));
|
||||
doc.set(submit, Object::Dict(with(action("SubmitForm"), "Next", with(action("JavaScript"), "Next", Object::Ref(submit)))));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)]))).unwrap();
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(dict(&doc, widget).get(b"A"), Some(&Object::Ref(submit)));
|
||||
let script = dict(&doc, submit);
|
||||
assert_eq!((script.name(b"S"), script.get(b"Next")), (Some(&b"JavaScript"[..]), Some(&Object::Ref(submit))), "the script runs itself again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_shares_the_file_out_by_kind() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -3096,6 +3437,52 @@ fn drawn_images(doc: &Document) -> Vec<Stream> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tolerated row must not be regrouped after its highest tile moves during X sorting.
|
||||
#[test]
|
||||
fn fragment_row_tolerance_preserves_every_tiles_pixels() {
|
||||
for aligned in [true, false] {
|
||||
for merge_fragments in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut refs = Vec::new();
|
||||
for gray in [20u8, 80, 140, 220] {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(10));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100]))));
|
||||
}
|
||||
let content = if aligned {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q"
|
||||
} else {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q"
|
||||
};
|
||||
page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content);
|
||||
let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||||
let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap();
|
||||
// Save and reopen, then inspect only images still drawn by the optimized page.
|
||||
let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let drawn = drawn_images(&doc);
|
||||
if report.fragments_merged == 0 {
|
||||
assert_eq!(report.merged_images, 0);
|
||||
assert_eq!(drawn.len(), 4);
|
||||
for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) {
|
||||
assert_eq!(image.decoded().unwrap(), [gray; 100]);
|
||||
}
|
||||
} else {
|
||||
assert!(merge_fragments);
|
||||
assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1));
|
||||
let image = &drawn[0];
|
||||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10)));
|
||||
let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect();
|
||||
assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() {
|
||||
// 120 strips, every other one JPEG and half as wide (as one PDF printer writes them).
|
||||
@@ -4196,6 +4583,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "BT ET");
|
||||
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||||
let dict_dest = |target: ObjRef| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let link = |doc: &mut Document, dest: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Link"));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||||
d.set(b"Dest".to_vec(), dest);
|
||||
Object::Ref(doc.add(Object::Dict(d)))
|
||||
};
|
||||
let missing = ObjRef::new(999, 0);
|
||||
// Links: (1) explicit dictionary destination to `p` (valid), (2) explicit dictionary
|
||||
// destination to `missing` (invalid), (3) named destination "dict_ok" whose value is
|
||||
// `<< /D [p /Fit] >>` (valid), (4) named destination "broken_tree" whose target page is
|
||||
// `missing` (invalid).
|
||||
let annots = vec![
|
||||
link(&mut doc, dict_dest(p)),
|
||||
link(&mut doc, dict_dest(missing)),
|
||||
link(&mut doc, Object::Array(vec![Object::Int(i64::from(u32::MAX) + 1), Object::name("Fit")])),
|
||||
link(&mut doc, s("dict_ok")),
|
||||
link(&mut doc, s("broken_tree")),
|
||||
];
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap();
|
||||
|
||||
// Bookmark with an indirect GoTo action pointing to "broken_old" in catalog /Dests (whose
|
||||
// page is `missing`), and a second bookmark pointing to "old_ok" in catalog /Dests.
|
||||
let outlines = doc.add(Object::Null);
|
||||
let mut goto_broken = Dict::new();
|
||||
goto_broken.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
goto_broken.set(b"D".to_vec(), Object::name("broken_old"));
|
||||
let goto_broken = doc.add(Object::Dict(goto_broken));
|
||||
let mut bm_broken = Dict::new();
|
||||
bm_broken.set(b"Title".to_vec(), s("Broken"));
|
||||
bm_broken.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
bm_broken.set(b"A".to_vec(), Object::Ref(goto_broken));
|
||||
let bm_broken = doc.add(Object::Dict(bm_broken));
|
||||
let mut bm_ok = Dict::new();
|
||||
bm_ok.set(b"Title".to_vec(), s("Ok"));
|
||||
bm_ok.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
bm_ok.set(b"Dest".to_vec(), Object::name("old_ok"));
|
||||
let bm_ok = doc.add(Object::Dict(bm_ok));
|
||||
doc.update_dict(bm_broken, |d| d.set(b"Next".to_vec(), Object::Ref(bm_ok))).unwrap();
|
||||
let mut o = Dict::new();
|
||||
o.set(b"First".to_vec(), Object::Ref(bm_broken));
|
||||
o.set(b"Last".to_vec(), Object::Ref(bm_ok));
|
||||
doc.set(outlines, Object::Dict(o));
|
||||
|
||||
// /Names /Dests has "broken_tree" (pointing to `missing`) and "dict_ok" (`<< /D [p /Fit] >>`).
|
||||
let broken_dest = doc.add(dict_dest(missing));
|
||||
let ok_dest = doc.add(dict_dest(p));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Names".to_vec(), Object::Array(vec![s("broken_tree"), Object::Ref(broken_dest), s("dict_ok"), Object::Ref(ok_dest)]));
|
||||
let tree = doc.add(Object::Dict(tree));
|
||||
let mut names = Dict::new();
|
||||
names.set(b"Dests".to_vec(), Object::Ref(tree));
|
||||
|
||||
// Catalog /Dests has "broken_old" (pointing to `missing`) and "old_ok" (`<< /D [p /Fit] >>`).
|
||||
let mut old_dests = Dict::new();
|
||||
old_dests.set(b"broken_old".to_vec(), Object::Array(vec![Object::Ref(missing), Object::name("Fit")]));
|
||||
old_dests.set(b"old_ok".to_vec(), dict_dest(p));
|
||||
let old_dests = doc.add(Object::Dict(old_dests));
|
||||
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"Names".to_vec(), Object::Dict(names));
|
||||
c.set(b"Dests".to_vec(), Object::Ref(old_dests));
|
||||
c.set(b"Outlines".to_vec(), Object::Ref(outlines));
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Space audit claims both /Names /Dests and catalog /Dests under NamedDestinations.
|
||||
let only_tree = {
|
||||
let mut d = doc.clone();
|
||||
d.update_dict(root, |c| {
|
||||
c.remove(b"Dests");
|
||||
})
|
||||
.unwrap();
|
||||
audit_space(&d, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes
|
||||
};
|
||||
let both_tables = audit_space(&doc, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes;
|
||||
assert!(both_tables > only_tree, "catalog /Dests is counted alongside /Names /Dests: {both_tables} vs {only_tree}");
|
||||
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (3, 1, 2), "{report:?}");
|
||||
let left_links = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len();
|
||||
assert_eq!(left_links, 2, "the two valid dictionary/named links remain");
|
||||
assert!(doc.get(bm_broken).as_dict().unwrap().get(b"A").is_none(), "broken bookmark action is cleared");
|
||||
assert!(doc.get(bm_ok).as_dict().unwrap().get(b"Dest").is_some(), "valid bookmark destination stays");
|
||||
assert_eq!(named_dest_keys(&doc), vec![b"dict_ok".to_vec()]);
|
||||
let left_old = doc.get(old_dests).as_dict().cloned().unwrap();
|
||||
assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}");
|
||||
}
|
||||
|
||||
/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle.
|
||||
fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef {
|
||||
let row = (w as usize).div_ceil(8);
|
||||
|
||||
@@ -105,6 +105,10 @@ pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result<Diff, SizeMism
|
||||
|
||||
/// Pixels of `a` with no pixel within `tolerance` anywhere in `b`'s surrounding 3x3.
|
||||
fn unmatched(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> usize {
|
||||
// No pixels, nothing to match: a zero-width image of enormous height is not walked row by row.
|
||||
if a.pixels.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut count = 0usize;
|
||||
for y in 0..a.height {
|
||||
for x in 0..a.width {
|
||||
@@ -210,6 +214,14 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A zero-width image of enormous height has no pixels to walk.
|
||||
#[test]
|
||||
fn an_image_without_pixels_compares_at_once() {
|
||||
let a = Gray { width: 0, height: usize::MAX, pixels: &[] };
|
||||
let d = compare(a, a, 0).unwrap();
|
||||
assert_eq!((d.differing, d.total), (0, 0));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn gray(w: usize, h: usize, fill: u8) -> Vec<u8> {
|
||||
|
||||
@@ -54,9 +54,13 @@ pub fn import_comments(dst: &mut Document, src: &Document) -> Result<usize, Orga
|
||||
let to = walk(dst)?;
|
||||
let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect();
|
||||
let mut incoming: Vec<Vec<Incoming>> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect();
|
||||
let kept = threads_kept(&incoming);
|
||||
let kept = threads_kept(src, &incoming);
|
||||
for list in &mut incoming {
|
||||
list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p))));
|
||||
list.retain(|c| match irt_parent(src, &c.dict) {
|
||||
None => true,
|
||||
Some(Some(p)) => kept.contains(&p),
|
||||
Some(None) => false,
|
||||
});
|
||||
}
|
||||
let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect());
|
||||
// Every comment's object is chosen before any is copied, so a comment that another one reaches
|
||||
@@ -155,13 +159,34 @@ fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict
|
||||
(target, dict)
|
||||
}
|
||||
|
||||
/// Resolve `dict`'s `/IRT` entry: `None` when `/IRT` is absent or resolves to `null`,
|
||||
/// `Some(Some(r))` when it references annotation object `r` (directly or through indirect
|
||||
/// reference objects), or `Some(None)` for any other non-reference value.
|
||||
fn irt_parent(src: &Document, dict: &Dict) -> Option<Option<ObjRef>> {
|
||||
let v = dict.get(b"IRT")?;
|
||||
if matches!(v, Object::Null) {
|
||||
return None;
|
||||
}
|
||||
let Some(mut r) = v.as_ref() else {
|
||||
return Some(None);
|
||||
};
|
||||
for _ in 0..8 {
|
||||
match *src.get(r) {
|
||||
Object::Null => return None,
|
||||
Object::Ref(next) => r = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(Some(r))
|
||||
}
|
||||
|
||||
/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one
|
||||
/// only with the comment it answers, up to the first comment of the thread. A thread whose replies
|
||||
/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once.
|
||||
fn threads_kept(incoming: &[Vec<Incoming>]) -> HashSet<ObjRef> {
|
||||
fn threads_kept(src: &Document, incoming: &[Vec<Incoming>]) -> HashSet<ObjRef> {
|
||||
// For each comment: no parent, a parent comment, or a parent that is no comment of the source.
|
||||
let parents: HashMap<ObjRef, Option<Option<ObjRef>>> =
|
||||
incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect();
|
||||
incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, irt_parent(src, &c.dict)))).collect();
|
||||
let mut known: HashMap<ObjRef, bool> = HashMap::new();
|
||||
for &start in parents.keys() {
|
||||
let (mut path, mut on_path) = (Vec::new(), HashSet::new());
|
||||
@@ -202,8 +227,11 @@ fn page_comments(src: &Document, page: ObjRef) -> Vec<Incoming> {
|
||||
list.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let dict = src.resolve(&entry).as_dict().cloned()?;
|
||||
let mut dict = src.resolve(&entry).as_dict().cloned()?;
|
||||
let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?;
|
||||
if let Some(Some(r)) = irt_parent(src, &dict) {
|
||||
dict.set(b"IRT".to_vec(), Object::Ref(r));
|
||||
}
|
||||
COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict })
|
||||
})
|
||||
.collect()
|
||||
@@ -217,7 +245,8 @@ fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> {
|
||||
.filter_map(|entry| {
|
||||
let r = entry.as_ref()?;
|
||||
let d = src.get(r).as_dict().cloned()?;
|
||||
(d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))?
|
||||
let is_popup = d.get(b"Subtype").map(|s| src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice());
|
||||
is_popup.then(|| d.reference(b"Parent").map(|p| (r, p)))?
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -265,7 +294,7 @@ impl PageAnnots {
|
||||
if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) {
|
||||
names.entry(name).or_default().push(r);
|
||||
}
|
||||
if d.name(b"Subtype") == Some(b"Popup".as_slice())
|
||||
if d.get(b"Subtype").map(|s| dst.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice())
|
||||
&& let Some(parent) = d.reference(b"Parent")
|
||||
{
|
||||
popups.entry(parent).or_default().push(r);
|
||||
@@ -654,4 +683,42 @@ mod tests {
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 0);
|
||||
assert_eq!(page_annots(&doc, 0).len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_with_null_irt_and_popup_with_indirect_subtype_are_imported() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 16 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (root-null-irt) /IRT null /Contents (root comment) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 14 0 R /Contents (reply to root) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype 7 0 R /Parent 4 0 R /Rect [40 40 200 120] /P 3 0 R >>",
|
||||
"/Popup",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [15 0 R] >>",
|
||||
// Three-reply chain on page 1 whose root comment (15 0 R) is on dropped page 2:
|
||||
"<< /Type /Annot /Subtype /Text /IRT 15 0 R /Contents (dropped-1) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 9 0 R /Contents (dropped-2) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 10 0 R /Contents (dropped-3) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
// Two-reply cycle on page 1:
|
||||
"<< /Type /Annot /Subtype /Text /IRT 13 0 R /Contents (cycle-a) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (cycle-b) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"4 0 R",
|
||||
"<< /Type /Annot /Subtype /Text /Contents (dropped root) /Rect [10 10 30 30] /P 8 0 R >>",
|
||||
// Comment whose /IRT chains through an intermediate reference (17 0 R -> 18 0 R -> null):
|
||||
"<< /Type /Annot /Subtype /Text /IRT 17 0 R /Contents (indirect null irt) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"18 0 R",
|
||||
"null",
|
||||
]);
|
||||
let mut dst = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut dst, &src).unwrap(), 5);
|
||||
let annots = page_annots(&dst, 0);
|
||||
assert_eq!(annots.len(), 6, "two root comments, reply via indirect IRT, two cycling replies, and the popup");
|
||||
let (root_ref, _) = by_contents(&annots, "root comment");
|
||||
assert_eq!(by_contents(&annots, "reply to root").1.reference(b"IRT"), Some(*root_ref));
|
||||
assert!(by_contents(&annots, "indirect null irt").1.get(b"IRT").is_some());
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,39 @@ fn is_resource(o: &Object) -> bool {
|
||||
/// out on purpose: it names optional content groups, whose identity matters.
|
||||
const RESOURCE_CATEGORIES: &[&[u8]] = &[b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"XObject", b"Font"];
|
||||
|
||||
/// Whether `d` carries annotation, page, field, or structure identity rather than resource data.
|
||||
/// Annotations often omit `/Type /Annot` (ISO 32000-2 Table 166 requires `/Subtype` and `/Rect`),
|
||||
/// whereas resource dictionaries and category maps never carry `/Rect`, `/Popup`, `/IRT`, or `/FT`.
|
||||
fn is_identity_dict(doc: &Document, d: &pdfcraft_cos::Dict) -> bool {
|
||||
if let Some(t) = d.get(b"Type").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name)
|
||||
&& !RESOURCE_TYPES.contains(&t)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
d.contains(b"Rect") || d.contains(b"Popup") || d.contains(b"IRT") || d.contains(b"FT") || d.contains(b"Parent")
|
||||
}
|
||||
|
||||
/// Whether a category member has the expected resource shape and is not an identity-bearing
|
||||
/// dictionary such as an annotation or form field.
|
||||
fn category_entry(doc: &Document, category: &[u8], value: &Object) -> bool {
|
||||
let value = doc.resolve(value);
|
||||
match category {
|
||||
b"Font" | b"ExtGState" => matches!(&*value, Object::Dict(d) if !is_identity_dict(doc, d)),
|
||||
b"XObject" => matches!(&*value, Object::Stream(s) if !is_identity_dict(doc, &s.dict)),
|
||||
b"Pattern" | b"Shading" => match &*value {
|
||||
Object::Dict(d) => !is_identity_dict(doc, d),
|
||||
Object::Stream(s) => !is_identity_dict(doc, &s.dict),
|
||||
_ => false,
|
||||
},
|
||||
b"ColorSpace" => match &*value {
|
||||
Object::Name(name) => matches!(name.as_slice(), b"DeviceGray" | b"DeviceRGB" | b"DeviceCMYK" | b"Pattern"),
|
||||
Object::Array(items) => items.first().and_then(Object::as_name).is_some_and(|name| COLOR_SPACE_FAMILIES.contains(&name)),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// References listed in any `/Resources` dictionary reachable inside `o` (pages, forms, Type 3
|
||||
/// fonts, annotation appearances), including the `/Resources` dictionary itself when indirect.
|
||||
fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet<ObjRef>) {
|
||||
@@ -69,6 +102,15 @@ fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet<ObjRef>)
|
||||
for cat in RESOURCE_CATEGORIES {
|
||||
let entries = res.get(cat).map(|c| doc.resolve(c));
|
||||
if let Some(entries) = entries.as_ref().and_then(|e| e.as_dict()) {
|
||||
// A malformed category can point directly at an annotation or list
|
||||
// annotations inside a category map. Only resource-name maps whose
|
||||
// members are non-identity resources are containers.
|
||||
if is_identity_dict(doc, entries) || !entries.iter().all(|(_, value)| category_entry(doc, cat, value)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(Object::Ref(cat_ref)) = res.get(cat) {
|
||||
out.insert(*cat_ref);
|
||||
}
|
||||
out.extend(entries.iter().filter_map(|(_, v)| if let Object::Ref(r) = v { Some(*r) } else { None }));
|
||||
}
|
||||
}
|
||||
@@ -189,11 +231,15 @@ fn contains_ref(o: &Object, map: &HashMap<ObjRef, ObjRef>) -> bool {
|
||||
/// Merge duplicate resources among `candidates` (into each other or into equal objects already
|
||||
/// in `doc`), rewrite references inside `candidates`, and free the merged-away objects.
|
||||
/// When `index_existing` is false, only candidates are compared (a brand-new document).
|
||||
/// Repeated candidates are processed once, by complete object reference.
|
||||
/// Returns how many objects were removed.
|
||||
pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existing: bool) -> usize {
|
||||
let cand: HashSet<ObjRef> = candidates.iter().copied().collect();
|
||||
let mut sorted: Vec<ObjRef> = candidates.to_vec();
|
||||
sorted.sort_unstable_by_key(|r| r.num);
|
||||
sorted.sort_unstable_by_key(|r| (r.num, r.generation));
|
||||
// A repeated candidate must not merge into itself: that would cycle `canon` and free
|
||||
// the canonical object. Keep generations distinct when normalizing references.
|
||||
sorted.dedup();
|
||||
// Existing objects first, so candidates merge into them rather than the reverse.
|
||||
let mut all: Vec<(ObjRef, std::sync::Arc<Object>)> = Vec::new();
|
||||
if index_existing {
|
||||
@@ -273,6 +319,37 @@ mod tests {
|
||||
doc.add(Object::Dict(d))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_candidate_keeps_the_canonical_object() {
|
||||
for index_existing in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let r = doc.add(stream(b"glyphs"));
|
||||
assert_eq!(dedupe_resources(&mut doc, &[r, r], index_existing), 0);
|
||||
assert_eq!(*doc.get(r), stream(b"glyphs"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_candidate_with_referrer_terminates_and_keeps_generations() {
|
||||
for index_existing in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let r = ObjRef::new(20, 7);
|
||||
doc.set(r, stream(b"glyphs"));
|
||||
let s = font(&mut doc, r, "A");
|
||||
assert_eq!(dedupe_resources(&mut doc, &[r, r, s], index_existing), 0);
|
||||
assert_eq!(*doc.get(r), stream(b"glyphs"));
|
||||
assert_eq!(doc.generation(r.num), 7);
|
||||
assert_eq!(doc.get(s).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r)));
|
||||
|
||||
let duplicate = ObjRef::new(30, 9);
|
||||
doc.set(duplicate, stream(b"glyphs"));
|
||||
let t = font(&mut doc, duplicate, "B");
|
||||
assert_eq!(dedupe_resources(&mut doc, &[r, r, s, duplicate, duplicate, t], index_existing), 1);
|
||||
assert_eq!(*doc.get(r), stream(b"glyphs"));
|
||||
assert_eq!(doc.get(t).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_bottom_up_and_keeps_identity_objects() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -367,4 +444,53 @@ mod tests {
|
||||
let cs = doc.get(h).as_dict().unwrap().get(b"CS").unwrap().clone();
|
||||
assert_eq!(cs, Object::Array(vec![Object::name("ICCBased"), Object::Ref(old)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_resource_category_and_resources_dictionaries_merge() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"CA".to_vec(), Object::Real(0.5));
|
||||
let g1 = doc.add(Object::Dict(gs.clone()));
|
||||
let g2 = doc.add(Object::Dict(gs));
|
||||
|
||||
let page_with_indirect_res = |doc: &mut Document, g: ObjRef| {
|
||||
let mut ext = Dict::new();
|
||||
ext.set(b"G0".to_vec(), Object::Ref(g));
|
||||
let ext_ref = doc.add(Object::Dict(ext));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"ExtGState".to_vec(), Object::Ref(ext_ref));
|
||||
let res_ref = doc.add(Object::Dict(res));
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||||
p.set(b"Resources".to_vec(), Object::Ref(res_ref));
|
||||
let p_ref = doc.add(Object::Dict(p));
|
||||
(ext_ref, res_ref, p_ref)
|
||||
};
|
||||
let (ext1, res1, p1) = page_with_indirect_res(&mut doc, g1);
|
||||
let (ext2, res2, p2) = page_with_indirect_res(&mut doc, g2);
|
||||
assert_eq!(dedupe_resources(&mut doc, &[g1, g2, ext1, res1, p1, ext2, res2, p2], false), 3);
|
||||
assert_eq!(doc.get(p1).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1)));
|
||||
assert_eq!(doc.get(p2).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1)));
|
||||
assert_eq!(doc.get(res1).as_dict().unwrap().get(b"ExtGState"), Some(&Object::Ref(ext1)));
|
||||
|
||||
// Distinct font maps (`F1` and `F10` with different fonts on one page vs the same on another)
|
||||
// deduplicate identical fonts while preserving distinct `F1`/`F10` entries.
|
||||
let ff1 = doc.add(stream(b"font-f1"));
|
||||
let ff10 = doc.add(stream(b"font-f10"));
|
||||
let f1_a = font(&mut doc, ff1, "Helvetica");
|
||||
let f10_a = font(&mut doc, ff10, "Courier");
|
||||
let f1_b = font(&mut doc, ff1, "Helvetica");
|
||||
let f10_b = font(&mut doc, ff10, "Times-Roman");
|
||||
let mut fmap_a = Dict::new();
|
||||
fmap_a.set(b"F1".to_vec(), Object::Ref(f1_a));
|
||||
fmap_a.set(b"F10".to_vec(), Object::Ref(f10_a));
|
||||
let fmap_a_ref = doc.add(Object::Dict(fmap_a));
|
||||
let mut fmap_b = Dict::new();
|
||||
fmap_b.set(b"F1".to_vec(), Object::Ref(f1_b));
|
||||
fmap_b.set(b"F10".to_vec(), Object::Ref(f10_b));
|
||||
let fmap_b_ref = doc.add(Object::Dict(fmap_b));
|
||||
assert_eq!(dedupe_resources(&mut doc, &[f1_a, f10_a, f1_b, f10_b, fmap_a_ref, fmap_b_ref], false), 1);
|
||||
assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F1"), Some(&Object::Ref(f1_a)));
|
||||
assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F10"), Some(&Object::Ref(f10_b)));
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,20 @@ pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Obj
|
||||
}
|
||||
// Names are compared and numbered as text: a key written as UTF-16 (a name with an accent
|
||||
// or Chinese letters) must not get ASCII bytes appended to it.
|
||||
let reserved: std::collections::HashSet<String> = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (k, _) in entries.iter_mut() {
|
||||
let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text();
|
||||
let mut text = base.clone();
|
||||
let mut n = 2;
|
||||
while !seen.insert(text.clone()) {
|
||||
text = format!("{base} ({n})");
|
||||
n += 1;
|
||||
if !seen.insert(text.clone()) {
|
||||
loop {
|
||||
text = format!("{base} ({n})");
|
||||
n += 1;
|
||||
if !reserved.contains(&text) && seen.insert(text.clone()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if text != base {
|
||||
*k = pdfcraft_cos::PdfString::text(&text).bytes;
|
||||
@@ -141,10 +147,11 @@ fn outline_level(m: &Copier<'_>, first: Option<ObjRef>, depth: u8, budget: &mut
|
||||
let target = item.get(b"Dest").cloned().or_else(|| {
|
||||
let a = m.src.resolve(item.get(b"A")?);
|
||||
let a = a.as_dict()?;
|
||||
(a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten()
|
||||
let is_goto = a.get(b"S").map(|s| m.src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"GoTo");
|
||||
is_goto.then(|| a.get(b"D").cloned()).flatten()
|
||||
});
|
||||
let dest = target.and_then(|t| m.map_dest(&t));
|
||||
let open = item.int(b"Count").is_some_and(|c| c > 0);
|
||||
let open = item.get(b"Count").and_then(|c| m.src.resolve(c).as_int()).is_some_and(|c| c > 0);
|
||||
let children = outline_level(m, item.reference(b"First"), depth + 1, budget);
|
||||
made.push(OutlineNode { title, colour, flags, dest, open, children });
|
||||
}
|
||||
@@ -235,3 +242,58 @@ pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), Orga
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::attachment_keys;
|
||||
|
||||
fn build(bodies: &[&str]) -> 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\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_attachments_does_not_clobber_later_numbered_attachment_name() {
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
]);
|
||||
let entries =
|
||||
vec![(b"report.pdf".to_vec(), Object::Null), (b"report.pdf".to_vec(), Object::Null), (b"report.pdf (2)".to_vec(), Object::Null)];
|
||||
set_attachments(&mut doc, entries).unwrap();
|
||||
assert_eq!(attachment_keys(&doc), ["report.pdf", "report.pdf (2)", "report.pdf (3)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_outline_resolves_indirect_goto_action_type_and_count() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>",
|
||||
"<< /Title (Parent) /Parent 4 0 R /First 6 0 R /Last 6 0 R /Count 8 0 R /A << /S 7 0 R /D [3 0 R /Fit] >> >>",
|
||||
"<< /Title (Child) /Parent 5 0 R /Dest [3 0 R /Fit] >>",
|
||||
"/GoTo",
|
||||
"1",
|
||||
]);
|
||||
let nodes = source_outline(&src, HashMap::from([(ObjRef::new(3, 0), ObjRef::new(3, 0))]));
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert!(nodes[0].dest.is_some());
|
||||
assert!(nodes[0].open);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, Or
|
||||
/// go to the copied pages.
|
||||
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
let mut labels = Vec::new();
|
||||
let mut has_labels = false;
|
||||
crate::labels::collect_page_labels(src, pages, 0, &mut labels, &mut has_labels)?;
|
||||
// One source: nothing to deduplicate. Its fields keep their names (a source with two
|
||||
// same-named field roots stays one field, as it was), so Extract and Split never rename.
|
||||
let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?;
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
// The bookmarks of these pages come along, mapped to the copies (#4020).
|
||||
let outline = kept_bookmarks(source_outline(src, imported.page_map));
|
||||
if !outline.is_empty() {
|
||||
@@ -119,6 +123,8 @@ pub struct Combiner {
|
||||
out: Document,
|
||||
marks: Vec<Mark>,
|
||||
attachments: Vec<(Vec<u8>, Object)>,
|
||||
labels: Vec<crate::LabelRange>,
|
||||
has_labels: bool,
|
||||
/// The first source's print standard, and what its pages brought along.
|
||||
standard: Option<Standard>,
|
||||
/// Whether every source so far declares the first source's print standard.
|
||||
@@ -156,6 +162,8 @@ impl Combiner {
|
||||
out: Document::new_empty(),
|
||||
marks: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
has_labels: false,
|
||||
standard: None,
|
||||
agreed: true,
|
||||
field_conflicts,
|
||||
@@ -182,7 +190,12 @@ impl Combiner {
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let at = crate::page_count(&self.out)?;
|
||||
let mut labels = self.labels.clone();
|
||||
let mut has_labels = self.has_labels;
|
||||
crate::labels::collect_page_labels(src, &pages, at, &mut labels, &mut has_labels)?;
|
||||
let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?;
|
||||
self.labels = labels;
|
||||
self.has_labels = has_labels;
|
||||
self.renamed_fields.extend(imported.renamed_fields);
|
||||
if let Some(first) = imported.pages.first() {
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
@@ -212,13 +225,14 @@ impl Combiner {
|
||||
|
||||
/// [`Combiner::finish`] reporting what the combine renamed.
|
||||
pub fn finish_with_renames(self) -> Result<CombineOutcome, OrganizeError> {
|
||||
let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self;
|
||||
let Combiner { mut out, marks, attachments, labels, has_labels, standard, agreed, renamed_fields, .. } = self;
|
||||
if let Some(first) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?;
|
||||
}
|
||||
// Sources often share fonts, images and profiles (or are the same file): store them once.
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
@@ -299,9 +313,18 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
// The pages in the runs' order. A file's first run comes before its others, and its pages
|
||||
// were copied in run order, so its bookmark (its first copied page) is its first page shown.
|
||||
let mut order: Vec<(ObjRef, Dict)> = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
let mut has_labels = false;
|
||||
for (file, start, len) in slices {
|
||||
let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default();
|
||||
let end = start.saturating_add(len);
|
||||
let at = order.len();
|
||||
let run = copies.get(file).and_then(|c| c.get(start..end)).unwrap_or_default();
|
||||
order.extend(run.iter().map(|r| (*r, Dict::new())));
|
||||
if let Some((_, f)) = files.get(file)
|
||||
&& let Some(pages) = f.pages.get(start..end)
|
||||
{
|
||||
crate::labels::collect_page_labels(f.src, pages, at, &mut labels, &mut has_labels)?;
|
||||
}
|
||||
}
|
||||
rebuild(&mut out, &order)?;
|
||||
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
|
||||
@@ -309,6 +332,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
}
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
crate::labels::finish_page_labels(&mut out, &labels, has_labels)?;
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
|
||||
@@ -50,19 +50,26 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec<Vec<(String, String
|
||||
/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name.
|
||||
/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe.
|
||||
fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet<String>) -> Vec<(ObjRef, String, String, String)> {
|
||||
let mut reserved = std::collections::HashSet::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for &root in roots {
|
||||
collect_names(doc, root, "", &mut seen, 0, &mut reserved);
|
||||
}
|
||||
let mut planned = Vec::new();
|
||||
let mut walk = RenameWalk::default();
|
||||
let mut walk = RenameWalk { reserved, ..RenameWalk::default() };
|
||||
for &root in roots {
|
||||
plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned);
|
||||
}
|
||||
planned
|
||||
}
|
||||
|
||||
/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name
|
||||
/// the suffix to try next.
|
||||
/// What one [`plan_renames`] call remembers: the nodes it visited, the incoming tree's own
|
||||
/// names (so an earlier rename does not steal a later sibling's name), and for each colliding
|
||||
/// name the suffix to try next.
|
||||
#[derive(Default)]
|
||||
struct RenameWalk {
|
||||
seen: HashSet<ObjRef>,
|
||||
reserved: HashSet<String>,
|
||||
next: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
@@ -88,18 +95,19 @@ fn plan_renames_under(
|
||||
// still taken: resume there instead of at 1.
|
||||
let mut n = walk.next.get(&fqn).copied().unwrap_or(1);
|
||||
let mut variant;
|
||||
let mut candidate;
|
||||
while {
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
variant = format!("{name}-{n}");
|
||||
taken.contains(&qualified(prefix, &variant))
|
||||
candidate = qualified(prefix, &variant);
|
||||
taken.contains(&candidate) || walk.reserved.contains(&candidate)
|
||||
} {
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
walk.next.insert(fqn.clone(), n.saturating_add(1));
|
||||
let new_fqn = qualified(prefix, &variant);
|
||||
planned.push((node, variant.clone(), fqn, new_fqn.clone()));
|
||||
taken.insert(new_fqn);
|
||||
planned.push((node, variant.clone(), fqn, candidate.clone()));
|
||||
taken.insert(candidate);
|
||||
name = variant;
|
||||
} else {
|
||||
taken.insert(fqn);
|
||||
@@ -143,15 +151,16 @@ fn field_roots(doc: &Document) -> Result<Vec<ObjRef>, OrganizeError> {
|
||||
let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for a in annots.iter() {
|
||||
let Some(w) = doc.resolve(a).as_dict().cloned() else { continue };
|
||||
if w.name(b"Subtype") != Some(b"Widget") {
|
||||
if w.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Widget") {
|
||||
continue;
|
||||
}
|
||||
// Walk up to the field the widget belongs to, however deep it sits; a widget that
|
||||
// is its own field is its own root.
|
||||
let Some(mut root) = a.as_ref() else { continue };
|
||||
let mut up = 0;
|
||||
let mut visited = HashSet::from([root]);
|
||||
while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) {
|
||||
if up >= 32 || root == parent {
|
||||
if up >= 32 || !visited.insert(parent) || doc.get(parent).as_dict().is_none() {
|
||||
break;
|
||||
}
|
||||
root = parent;
|
||||
@@ -295,3 +304,63 @@ pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build(bodies: &[&str]) -> 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\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_colliding_field_does_not_steal_later_sibling_name() {
|
||||
let dst = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>",
|
||||
]);
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (item-1) /Rect [0 20 10 30] >>",
|
||||
]);
|
||||
// Only "item" collides with dst; "item-1" in src is already unique and must not be stolen.
|
||||
assert_eq!(field_name_conflicts(&dst, &src), [("item".to_string(), "item-2".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_roots_keeps_widget_with_dangling_parent_or_indirect_subtype() {
|
||||
let dst = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype 5 0 R /FT /Tx /T (orphan) /Parent 99 0 R /Rect [0 0 10 10] >>",
|
||||
"/Widget",
|
||||
]);
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [0 0 10 10] >>",
|
||||
]);
|
||||
assert_eq!(field_name_conflicts(&dst, &src), [("orphan".to_string(), "orphan-1".to_string())]);
|
||||
}
|
||||
}
|
||||
@@ -360,11 +360,13 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
|
||||
return Err(invalid_label("the number tree has too many entries"));
|
||||
}
|
||||
// Validate every range and all effective labels before changing any document object.
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES;
|
||||
for r in ranges {
|
||||
if r.first == 0 || i64::try_from(r.start).is_err() {
|
||||
return Err(invalid_label("range starts and first numbers must be valid positive PDF integers"));
|
||||
}
|
||||
r.label(r.start)?;
|
||||
prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?;
|
||||
}
|
||||
let mut sorted = ranges.to_vec();
|
||||
sorted.sort_by_key(|r| r.start);
|
||||
@@ -450,15 +452,9 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl
|
||||
}
|
||||
// Drop ranges that only continue the previous one.
|
||||
let mut merged: Vec<LabelRange> = Vec::new();
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES;
|
||||
for r in ranges {
|
||||
let continues = merged.last().is_some_and(|p| {
|
||||
p.style == r.style
|
||||
&& p.prefix == r.prefix
|
||||
&& r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first)
|
||||
});
|
||||
if !continues {
|
||||
merged.push(r);
|
||||
}
|
||||
push_merged_range(&mut merged, &mut prefix_left, r)?;
|
||||
}
|
||||
// Plain 1, 2, 3 everywhere is what a document without labels shows: remove them.
|
||||
if merged.len() == 1 && merged[0] == default_range() {
|
||||
@@ -467,6 +463,117 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl
|
||||
set_page_label_ranges(doc, &merged)
|
||||
}
|
||||
|
||||
fn push_merged_range(merged: &mut Vec<LabelRange>, prefix_left: &mut usize, r: LabelRange) -> Result<(), OrganizeError> {
|
||||
let continues = merged.last().is_some_and(|p| {
|
||||
p.style == r.style
|
||||
&& p.prefix == r.prefix
|
||||
&& match r.style {
|
||||
LabelStyle::None => p.first == r.first,
|
||||
_ => r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first),
|
||||
}
|
||||
});
|
||||
if !continues {
|
||||
if merged.len() + 1 >= MAX_LABEL_TREE_WORK {
|
||||
return Err(invalid_label("the number tree has too many entries"));
|
||||
}
|
||||
*prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?;
|
||||
merged.push(r);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decimal numbering that shows the source page's physical number (`src_page` is 0-based).
|
||||
fn source_physical_range(dst_start: usize, src_page: usize) -> LabelRange {
|
||||
LabelRange { start: dst_start, style: LabelStyle::Decimal, prefix: String::new(), first: u32::try_from(src_page.saturating_add(1)).unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Whether `set_page_label_ranges` can store `r` while it covers `r.start..end` (`end` exclusive).
|
||||
/// Entry-count and aggregate-prefix limits stay hard errors in [`push_merged_range`].
|
||||
fn range_writable(r: &LabelRange, end: usize) -> bool {
|
||||
r.label(r.start).is_ok() && check_spans(std::slice::from_ref(r), end).is_ok()
|
||||
}
|
||||
|
||||
/// Append the label ranges that `pages` (0-based indices in `src`) carry when placed at
|
||||
/// `dst_start` in an output document. Sets `has_labels` when `src` has `/PageLabels`.
|
||||
pub(crate) fn collect_page_labels(
|
||||
src: &Document,
|
||||
pages: &[usize],
|
||||
dst_start: usize,
|
||||
out: &mut Vec<LabelRange>,
|
||||
has_labels: &mut bool,
|
||||
) -> Result<(), OrganizeError> {
|
||||
if pages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut src_ranges = checked_ranges(src).unwrap_or_default();
|
||||
if !src_ranges.is_empty() {
|
||||
*has_labels = true;
|
||||
}
|
||||
for r in &mut src_ranges {
|
||||
// An empty prefix-only range shows each page's physical number in the source.
|
||||
if r.style == LabelStyle::None && r.prefix.is_empty() {
|
||||
r.style = LabelStyle::Decimal;
|
||||
r.first = u32::try_from(r.start.saturating_add(1)).unwrap_or(1);
|
||||
}
|
||||
}
|
||||
if src_ranges.first().is_none_or(|r| r.start > 0) {
|
||||
src_ranges.insert(0, default_range());
|
||||
}
|
||||
let mut prefix_left = MAX_PREFIX_TOTAL_BYTES.saturating_sub(out.iter().map(|r| r.prefix.len()).sum::<usize>());
|
||||
let mut i = 0;
|
||||
while i < pages.len() {
|
||||
let src_start = pages[i];
|
||||
let mut len = 1;
|
||||
while i + len < pages.len() && src_start.checked_add(len) == Some(pages[i + len]) {
|
||||
len += 1;
|
||||
}
|
||||
let run_dst = dst_start + i;
|
||||
let src_end = src_start.saturating_add(len);
|
||||
let run_end = run_dst.saturating_add(len);
|
||||
let idx = src_ranges.partition_point(|r| r.start <= src_start).saturating_sub(1);
|
||||
let span_end = |following: Option<usize>| {
|
||||
following
|
||||
.filter(|start| *start > src_start && *start < src_end)
|
||||
.and_then(|start| start.checked_sub(src_start).and_then(|delta| run_dst.checked_add(delta)))
|
||||
.unwrap_or(run_end)
|
||||
};
|
||||
if let Some(first_range) = src_ranges.get(idx) {
|
||||
let mapped = match first_range.number(src_start) {
|
||||
Ok(first) => LabelRange { start: run_dst, style: first_range.style, prefix: first_range.prefix.clone(), first },
|
||||
Err(_) => source_physical_range(run_dst, src_start),
|
||||
};
|
||||
let mapped = if range_writable(&mapped, span_end(src_ranges.get(idx + 1).map(|r| r.start))) {
|
||||
mapped
|
||||
} else {
|
||||
source_physical_range(run_dst, src_start)
|
||||
};
|
||||
push_merged_range(out, &mut prefix_left, mapped)?;
|
||||
}
|
||||
let tail = src_ranges.get(idx + 1..).unwrap_or_default();
|
||||
for (n, r) in tail.iter().enumerate() {
|
||||
if r.start >= src_end {
|
||||
break;
|
||||
}
|
||||
let Some(delta) = r.start.checked_sub(src_start) else { continue };
|
||||
let Some(start) = run_dst.checked_add(delta) else { continue };
|
||||
let inner = LabelRange { start, style: r.style, prefix: r.prefix.clone(), first: r.first };
|
||||
let following = tail.get(n + 1).map(|next| next.start);
|
||||
let inner = if range_writable(&inner, span_end(following)) { inner } else { source_physical_range(start, r.start) };
|
||||
push_merged_range(out, &mut prefix_left, inner)?;
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write collected page label ranges to `dst` when at least one source carried `/PageLabels`.
|
||||
pub(crate) fn finish_page_labels(dst: &mut Document, ranges: &[LabelRange], has_labels: bool) -> Result<(), OrganizeError> {
|
||||
if has_labels && !ranges.is_empty() {
|
||||
set_page_label_ranges(dst, ranges)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1747,6 +1747,63 @@ fn bookmarks_report_the_view_their_destination_asks_for() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_resource_categories_preserve_annotation_identity_when_combining_and_inserting() {
|
||||
for typed in [true, false] {
|
||||
for with_popups in [false, true] {
|
||||
let mut source = doc_a();
|
||||
let page = pages(&source).unwrap()[0].obj;
|
||||
let mut annot = Dict::new();
|
||||
if typed {
|
||||
annot.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
}
|
||||
annot.set(b"Subtype".to_vec(), Object::name("Text"));
|
||||
annot.set(b"Rect".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
let first = source.add(Object::Dict(annot.clone()));
|
||||
let second = source.add(Object::Dict(annot.clone()));
|
||||
if with_popups {
|
||||
annot.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
let popup1 = source.add(Object::Dict(annot.clone()));
|
||||
let popup2 = source.add(Object::Dict(annot));
|
||||
source.update_dict(first, |d| d.set(b"Popup".to_vec(), Object::Ref(popup1))).unwrap();
|
||||
source.update_dict(second, |d| d.set(b"Popup".to_vec(), Object::Ref(popup2))).unwrap();
|
||||
}
|
||||
source
|
||||
.update_dict(page, |d| {
|
||||
let mut font_map = Dict::new();
|
||||
font_map.set(b"F1".to_vec(), Object::Ref(first));
|
||||
font_map.set(b"F2".to_vec(), Object::Ref(second));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(font_map));
|
||||
resources.set(b"ExtGState".to_vec(), Object::Ref(second));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
|
||||
})
|
||||
.unwrap();
|
||||
let combined = crate::combine(&[("source", &source)]).unwrap();
|
||||
let mut inserted = doc_a();
|
||||
crate::import_pages(&mut inserted, &source, &[0], 0).unwrap();
|
||||
for doc in [combined, inserted] {
|
||||
let doc = full_roundtrip(&doc);
|
||||
let page = pages(&doc).unwrap()[0].obj;
|
||||
let refs = doc.get(page).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().clone();
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_ne!(refs[0], refs[1], "equal annotations retain their separate identities");
|
||||
if with_popups {
|
||||
let popups: Vec<_> = refs.iter().map(|r| doc.resolve(r).as_dict().unwrap().reference(b"Popup").unwrap()).collect();
|
||||
assert_ne!(popups[0], popups[1], "malformed category entries must not merge distinct popups");
|
||||
for popup in popups {
|
||||
assert_eq!(doc.get(popup).as_dict().unwrap().name(b"Subtype"), Some(b"Popup".as_slice()));
|
||||
}
|
||||
}
|
||||
for r in refs {
|
||||
assert_eq!(doc.resolve(&r).as_dict().unwrap().name(b"Subtype"), Some(b"Text".as_slice()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_and_insert_share_identical_resources() {
|
||||
let a = doc_a();
|
||||
@@ -2166,6 +2223,176 @@ fn deleting_pages_keeps_the_label_ranges_on_their_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracting_splitting_and_combining_preserve_page_labels() {
|
||||
use crate::{
|
||||
LabelRange,
|
||||
LabelStyle::{Decimal, LowerRoman, UpperAlpha},
|
||||
SplitBy,
|
||||
};
|
||||
let ten = || {
|
||||
let mut d = doc_a();
|
||||
for at in 3..10 {
|
||||
crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap();
|
||||
}
|
||||
let ranges = [
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 },
|
||||
];
|
||||
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
|
||||
d
|
||||
};
|
||||
let d = ten();
|
||||
|
||||
// Extracting non-contiguous pages (e.g. first front-matter page and first appendix page)
|
||||
// keeps their labels and writes /PageLabels (#2670).
|
||||
let pair = full_roundtrip(&extract_pages(&d, &[0, 7]).unwrap());
|
||||
assert_eq!(crate::page_labels(&pair).unwrap(), ["i", "A-1"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&pair),
|
||||
[
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 1, style: Decimal, prefix: "A-".into(), first: 1 },
|
||||
]
|
||||
);
|
||||
|
||||
// Contiguous runs and out-of-order picks keep each page's label and merge continuations.
|
||||
let picked = full_roundtrip(&extract_pages(&d, &[1, 2, 8, 9, 0]).unwrap());
|
||||
assert_eq!(crate::page_labels(&picked).unwrap(), ["ii", "iii", "A-2", "A-3", "i"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&picked),
|
||||
[
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 2 },
|
||||
LabelRange { start: 2, style: Decimal, prefix: "A-".into(), first: 2 },
|
||||
LabelRange { start: 4, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
]
|
||||
);
|
||||
|
||||
// An unlabelled source stays unlabelled after extraction.
|
||||
let unlabelled = full_roundtrip(&extract_pages(&doc_a(), &[2, 0]).unwrap());
|
||||
assert!(crate::page_label_ranges(&unlabelled).is_empty());
|
||||
|
||||
// Splitting preserves each part's labels, including mid-range starting numbers.
|
||||
let parts = crate::split(&d, &SplitBy::PageCount(4)).unwrap();
|
||||
let part_labels: Vec<Vec<String>> = parts.iter().map(|p| crate::page_labels(&full_roundtrip(p)).unwrap()).collect();
|
||||
assert_eq!(part_labels, vec![vec!["i", "ii", "iii", "1"], vec!["2", "3", "4", "A-1"], vec!["A-2", "A-3"],]);
|
||||
|
||||
// Combining whole files, selected pages, and grouped runs preserves labels from each source.
|
||||
let mut b = doc_b();
|
||||
crate::set_page_label_ranges(&mut b, &[LabelRange { start: 0, style: UpperAlpha, prefix: "B-".into(), first: 1 }]).unwrap();
|
||||
let combined = full_roundtrip(&combine_selected(&[("D", &d, Some(&[0, 1, 7])), ("Plain", &doc_a(), Some(&[0, 1])), ("B", &b, None)]).unwrap());
|
||||
assert_eq!(crate::page_labels(&combined).unwrap(), ["i", "ii", "A-1", "1", "2", "B-A", "B-B"]);
|
||||
|
||||
let grouped =
|
||||
full_roundtrip(&crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (1, "B", &b, Some(&[1])), (0, "D", &d, Some(&[2, 7]))]).unwrap());
|
||||
assert_eq!(crate::page_labels(&grouped).unwrap(), ["i", "ii", "B-B", "iii", "A-1"]);
|
||||
|
||||
// Ranges starting after page 0 and prefix-only ranges (LabelStyle::None) merge and carry over.
|
||||
let mut late = doc_a();
|
||||
crate::set_page_label_ranges(
|
||||
&mut late,
|
||||
&[
|
||||
LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 },
|
||||
LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 4 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let from_late = full_roundtrip(&extract_pages(&late, &[0, 1, 1, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&from_late).unwrap(), ["1", "Cover", "Cover", "iv"]);
|
||||
assert_eq!(
|
||||
crate::page_label_ranges(&from_late),
|
||||
[
|
||||
LabelRange { start: 0, style: Decimal, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 },
|
||||
LabelRange { start: 3, style: LowerRoman, prefix: String::new(), first: 4 },
|
||||
]
|
||||
);
|
||||
|
||||
// An empty selection from a labelled source in combine_selected does not add /PageLabels.
|
||||
let empty_pick = full_roundtrip(&combine_selected(&[("D", &d, Some(&[])), ("Plain", &doc_a(), Some(&[0, 1]))]).unwrap());
|
||||
assert!(crate::page_label_ranges(&empty_pick).is_empty());
|
||||
|
||||
// A damaged (cyclic) /PageLabels tree does not prevent extracting pages.
|
||||
let mut cyclic = doc_a();
|
||||
let tree = cyclic.add(Object::Null);
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)]));
|
||||
cyclic.set(tree, Object::Dict(dict));
|
||||
cyclic.update_dict(cyclic.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted_cyclic = full_roundtrip(&extract_pages(&cyclic, &[0, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted_cyclic).unwrap(), ["1", "2"]);
|
||||
|
||||
// An empty prefix-only range falls back to physical page numbers in the source; extracting a
|
||||
// later page covered by it preserves that physical page number across save/reopen.
|
||||
let mut empty_prefix = doc_a();
|
||||
crate::set_page_label_ranges(
|
||||
&mut empty_prefix,
|
||||
&[
|
||||
LabelRange { start: 0, style: crate::LabelStyle::None, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(crate::PageLabels::new(&empty_prefix).unwrap().label(1).unwrap(), "2");
|
||||
let extracted_empty_prefix = full_roundtrip(&extract_pages(&empty_prefix, &[1]).unwrap());
|
||||
assert_eq!(crate::PageLabels::new(&extracted_empty_prefix).unwrap().label(0).unwrap(), "2");
|
||||
|
||||
// Repeated non-continuing ranges that exceed the 4 MiB total-prefix limit or the range-count
|
||||
// limit are refused rather than writing an unreadable tree or silently dropping labels.
|
||||
let mut one = flat_doc(1);
|
||||
crate::number_pages(&mut one, 0, 0, Decimal, &"x".repeat(1_000), 1).unwrap();
|
||||
let err = extract_pages(&one, &vec![0; 4_195]).unwrap_err().to_string();
|
||||
assert!(err.contains("decoded prefixes exceed 4 MiB in total"), "{err}");
|
||||
|
||||
crate::number_pages(&mut one, 0, 0, LowerRoman, "", 2).unwrap();
|
||||
let err = extract_pages(&one, &vec![0; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK]).unwrap_err().to_string();
|
||||
assert!(err.contains("the number tree has too many entries"), "{err}");
|
||||
}
|
||||
|
||||
/// A range the reader accepts but the writer cannot store (a Roman numeral past 1,024 bytes, or a
|
||||
/// decimal span whose labels grow past that) degrades to the source physical numbers. Extract
|
||||
/// still succeeds, and a neighbouring range that does fit is kept.
|
||||
#[test]
|
||||
fn extracting_unrepresentable_labels_uses_physical_numbers() {
|
||||
let mut roman = doc_a();
|
||||
let mut spec = Dict::new();
|
||||
spec.set(b"S".to_vec(), Object::name("R"));
|
||||
spec.set(b"St".to_vec(), Object::Int(4_000_000));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
|
||||
let tree = roman.add(Object::Dict(tree));
|
||||
roman.update_dict(roman.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&roman, &[0, 2]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "3"]);
|
||||
|
||||
let mut wide = flat_doc(5);
|
||||
let mut spec = Dict::new();
|
||||
spec.set(b"P".to_vec(), Object::String(PdfString::text(&"p".repeat(1_020))));
|
||||
spec.set(b"S".to_vec(), Object::name("D"));
|
||||
spec.set(b"St".to_vec(), Object::Int(9_996));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
|
||||
let tree = wide.add(Object::Dict(tree));
|
||||
wide.update_dict(wide.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&wide, &[0, 1, 2, 3, 4]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "2", "3", "4", "5"]);
|
||||
|
||||
let mut mixed = doc_a();
|
||||
let mut ok = Dict::new();
|
||||
ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-")));
|
||||
ok.set(b"S".to_vec(), Object::name("D"));
|
||||
let mut huge = Dict::new();
|
||||
huge.set(b"S".to_vec(), Object::name("R"));
|
||||
huge.set(b"St".to_vec(), Object::Int(4_000_000));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)]));
|
||||
let tree = mixed.add(Object::Dict(tree));
|
||||
mixed.update_dict(mixed.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
|
||||
let extracted = full_roundtrip(&extract_pages(&mixed, &[0, 1]).unwrap());
|
||||
assert_eq!(crate::page_labels(&extracted).unwrap(), ["ok-1", "2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_limits_reject_unsupported_edits_before_mutating() {
|
||||
use crate::LabelStyle::*;
|
||||
@@ -4278,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() {
|
||||
assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]);
|
||||
}
|
||||
|
||||
/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts
|
||||
/// at its first page, and a structure tree root listing a structure namespace (14.7.4).
|
||||
fn with_document_parts_and_namespaces() -> Document {
|
||||
// PDF 2.0, the version these structures belong to.
|
||||
let mut bytes = fixture();
|
||||
let header = bytes.get_mut(..8).expect("the fixture starts with its header");
|
||||
assert!(header.starts_with(b"%PDF-1."));
|
||||
header.copy_from_slice(b"%PDF-2.0");
|
||||
let mut doc = open(bytes);
|
||||
let first = pages(&doc).unwrap()[0].obj;
|
||||
let mut namespace = pdfcraft_cos::Dict::new();
|
||||
namespace.set(b"Type".to_vec(), Object::name("Namespace"));
|
||||
namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false }));
|
||||
let namespace = doc.add(Object::Dict(namespace));
|
||||
let mut tree = pdfcraft_cos::Dict::new();
|
||||
tree.set(b"Type".to_vec(), Object::name("StructTreeRoot"));
|
||||
tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)]));
|
||||
let tree = doc.add(Object::Dict(tree));
|
||||
let mut part = pdfcraft_cos::Dict::new();
|
||||
part.set(b"Type".to_vec(), Object::name("DPart"));
|
||||
part.set(b"Start".to_vec(), Object::Ref(first));
|
||||
part.set(b"End".to_vec(), Object::Ref(first));
|
||||
let part = doc.add(Object::Dict(part));
|
||||
let mut root = pdfcraft_cos::Dict::new();
|
||||
root.set(b"Type".to_vec(), Object::name("DPartRoot"));
|
||||
root.set(b"DPartRootNode".to_vec(), Object::Ref(part));
|
||||
let root = doc.add(Object::Dict(root));
|
||||
doc.update_dict(doc.root().unwrap(), |c| {
|
||||
c.set(b"DPartRoot".to_vec(), Object::Ref(root));
|
||||
c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) {
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let catalog = catalog.as_dict().unwrap();
|
||||
let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped")));
|
||||
let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped")));
|
||||
let part = node.as_dict().unwrap();
|
||||
assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}");
|
||||
assert!(part.get(b"Start").is_some(), "{what}: /Start dropped");
|
||||
assert!(part.get(b"End").is_some(), "{what}: /End dropped");
|
||||
let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped")));
|
||||
let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped")));
|
||||
let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]);
|
||||
assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}");
|
||||
assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed");
|
||||
assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_edits_keep_document_parts_and_structure_namespaces() {
|
||||
type Edit = fn(&mut Document);
|
||||
let edits: [(&str, Edit); 6] = [
|
||||
("rotate", |d| rotate_pages(d, &[0], 90).unwrap()),
|
||||
("move", |d| move_pages(d, &[0], 2).unwrap()),
|
||||
("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()),
|
||||
("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()),
|
||||
("insert blank", |d| {
|
||||
let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap();
|
||||
}),
|
||||
// The part's start page goes: the keys stay, the page reference is the part's to repair.
|
||||
("delete", |d| delete_pages(d, &[0]).unwrap()),
|
||||
];
|
||||
for (what, edit) in edits {
|
||||
let mut doc = with_document_parts_and_namespaces();
|
||||
let base = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup");
|
||||
doc = open(base);
|
||||
edit(&mut doc);
|
||||
assert_document_parts_and_namespaces_kept(&doc, what);
|
||||
assert_document_parts_and_namespaces_kept(
|
||||
&open(write_incremental(&doc, &SaveOptions::default()).unwrap()),
|
||||
&format!("{what}, incremental save"),
|
||||
);
|
||||
assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a
|
||||
/// `%` comment at the end of a piece without a line break continues through the first line of the
|
||||
/// next piece in both resource pruning (`extract_pages`) and `page_as_form`.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the
|
||||
//! automation tools (`automation`), which sit on the same layer and can't depend on each other.
|
||||
//! automation tools (`automation`), which sit on the same layer and can't depend on each other,
|
||||
//! and by the two executables (`owner_only`, for the `--control` file).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod desktop_theme;
|
||||
pub mod owner_only;
|
||||
pub mod staging;
|
||||
pub mod zone;
|
||||
@@ -0,0 +1,736 @@
|
||||
//! Owner-only files on Windows (#3922): the `--control` token file must be readable by its user
|
||||
//! alone, as mode 0600 makes it on Unix.
|
||||
//!
|
||||
//! A file created in a folder inherits the folder's access list, and folders such as a new folder
|
||||
//! at a drive root, `C:\Users\Public` or `C:\ProgramData` let every signed-in user read what is
|
||||
//! created in them. [`restrict_to_owner`] replaces the whole list with the current user and
|
||||
//! SYSTEM only, makes that user the owner, and checks the result. [`create_private_dir`] does the
|
||||
//! same for a new folder whose list the files created in it inherit, so a file can be private from
|
||||
//! the moment it exists. [`read_with_sddl`] reads a file and
|
||||
//! its security descriptor through one handle. [`foreign_access`] reads the descriptor in SDDL
|
||||
//! form, which names accounts by SID and so reads the same in every Windows language, and says why
|
||||
//! another user could have written or could read the file. They run the tools Windows ships rather
|
||||
//! than calling the security API directly, which would need `unsafe`.
|
||||
|
||||
/// Why the file whose security descriptor is `sddl` (`O:` owner and `D:` access list, in SDDL or
|
||||
/// as [`read_with_sddl`] writes it) can't be trusted as user `me`'s own: it is owned by someone else, it has no
|
||||
/// access list (everyone may use it), or it lets an account other than `me` or SYSTEM use it.
|
||||
/// Accounts are compared by SID; `None` means it is `me`'s alone.
|
||||
///
|
||||
/// Stricter than OpenSSH's rule for private keys, which also lets Administrators in: an entry for
|
||||
/// the Administrators group would let another administrator read the token without taking the
|
||||
/// file over. [`restrict_to_owner`] never grants it, so the app's own file always passes; a file
|
||||
/// that inherited it from a user's folder is refused.
|
||||
pub fn foreign_access(sddl: &str, me: &str) -> Option<String> {
|
||||
let me = me.trim();
|
||||
let owner = section(sddl, "O:");
|
||||
match owner {
|
||||
Some(o) if same_account(o, me) => {}
|
||||
Some(o) => return Some(format!("it is owned by {}", another_account(o, ""))),
|
||||
None => return Some("its owner can't be read".into()),
|
||||
}
|
||||
let Some(dacl) = section(sddl, "D:") else {
|
||||
return Some("it has no access list, so every user may read it".into());
|
||||
};
|
||||
if dacl.starts_with("NO_ACCESS_CONTROL") {
|
||||
return Some("it has no access control, so every user may read it".into());
|
||||
}
|
||||
for ace in dacl.split('(').skip(1) {
|
||||
let ace = ace.split(')').next().unwrap_or_default();
|
||||
let parts: Vec<&str> = ace.split(';').collect();
|
||||
// `type;flags;rights;object guid;inherited object guid;account SID[;(condition)]`.
|
||||
let (Some(kind), Some(flags)) = (parts.first(), parts.get(1)) else { continue };
|
||||
// Deny entries take access away, and inherit-only entries apply to children, not the file.
|
||||
let allows = matches!(*kind, "A" | "OA" | "XA" | "ZA");
|
||||
if !allows || flags.contains("IO") {
|
||||
continue;
|
||||
}
|
||||
// An entry that grants access but names no account can't be shown to be the user's.
|
||||
let Some(sid) = parts.get(5).filter(|s| !s.is_empty()) else {
|
||||
return Some(format!("its access list has an entry that can't be read ({ace})"));
|
||||
};
|
||||
if !(same_account(sid, me) || matches!(*sid, "SY" | "S-1-5-18" | "OW" | "S-1-3-4")) {
|
||||
return Some(another_account(sid, "has access to it"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `another account (<sid>) <rest>`, with the account's name before its SID when it is one of the
|
||||
/// well-known accounts a control file can carry, such as `another account, Administrators
|
||||
/// (S-1-5-32-544), has access to it` (review on #3956). The SID stays, so the message reads the
|
||||
/// same in every Windows language and can still be looked up.
|
||||
fn another_account(sid: &str, rest: &str) -> String {
|
||||
match (well_known_name(sid), rest.is_empty()) {
|
||||
(Some(name), true) => format!("another account, {name} ({sid})"),
|
||||
(Some(name), false) => format!("another account, {name} ({sid}), {rest}"),
|
||||
(None, true) => format!("another account ({sid})"),
|
||||
(None, false) => format!("another account ({sid}) {rest}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The English name Windows gives a well-known SID that can be on a file's access list or own it:
|
||||
/// those a folder hands down at a drive root, in `C:\Users\Public` and in a user's profile, and
|
||||
/// Everyone. The SIDs and names are Microsoft's public list of well-known SIDs
|
||||
/// (<https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids>); the names are
|
||||
/// the ones `icacls` shows, without the `BUILTIN\` or `NT AUTHORITY\` prefix. Any other SID,
|
||||
/// a user's or a domain group's, has no fixed name and gets `None`.
|
||||
fn well_known_name(sid: &str) -> Option<&'static str> {
|
||||
const NAMES: [(&str, &str); 8] = [
|
||||
("S-1-1-0", "Everyone"),
|
||||
("S-1-5-3", "BATCH"),
|
||||
("S-1-5-4", "INTERACTIVE"),
|
||||
("S-1-5-6", "SERVICE"),
|
||||
("S-1-5-11", "Authenticated Users"),
|
||||
("S-1-5-18", "SYSTEM"),
|
||||
("S-1-5-32-544", "Administrators"),
|
||||
("S-1-5-32-545", "Users"),
|
||||
];
|
||||
NAMES.iter().find(|(known, _)| known.eq_ignore_ascii_case(sid)).map(|&(_, name)| name)
|
||||
}
|
||||
|
||||
/// `section(sddl, "O:")` is the owner, `"D:"` the access list: the text after the tag up to the
|
||||
/// next top-level tag.
|
||||
fn section<'a>(sddl: &'a str, tag: &str) -> Option<&'a str> {
|
||||
let start = sddl.find(tag)?.checked_add(tag.len())?;
|
||||
let rest = sddl.get(start..)?;
|
||||
// The owner and group are bare SIDs; the access lists run until the next `X:` tag outside
|
||||
// parentheses.
|
||||
let mut depth = 0usize;
|
||||
let mut end = rest.len();
|
||||
for (i, c) in rest.char_indices() {
|
||||
match c {
|
||||
'(' => depth = depth.saturating_add(1),
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
':' if depth == 0 && i > 0 => {
|
||||
// The tag is the one letter before this colon.
|
||||
end = rest.get(..i).and_then(|s| s.char_indices().last()).map_or(i, |(j, _)| j);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rest.get(..end).map(str::trim)
|
||||
}
|
||||
|
||||
/// Whether account `a` names user SID `me`. `OW` (owner rights) and `CO` are not users. An alias
|
||||
/// is never taken for the user: [`read_with_sddl`] gives every account as a full SID, because
|
||||
/// Windows' own SDDL writes some users by alias (the built-in Administrator as `LA`, whose SID
|
||||
/// depends on the machine).
|
||||
fn same_account(a: &str, me: &str) -> bool {
|
||||
a.eq_ignore_ascii_case(me)
|
||||
}
|
||||
|
||||
/// The tools Windows ships that these checks run, by their path under `%SystemRoot%\System32`.
|
||||
#[cfg(windows)]
|
||||
const WHOAMI: &str = "whoami.exe";
|
||||
#[cfg(windows)]
|
||||
const POWERSHELL: &str = r"WindowsPowerShell\v1.0\powershell.exe";
|
||||
#[cfg(windows)]
|
||||
const ICACLS: &str = "icacls.exe";
|
||||
|
||||
/// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a
|
||||
/// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the
|
||||
/// search path) would answer instead, and `whoami`'s answer is what every check here trusts.
|
||||
#[cfg(windows)]
|
||||
fn system_tool(relative: &str) -> std::process::Command {
|
||||
let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from);
|
||||
std::process::Command::new(root.join("System32").join(relative))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
thread_local! {
|
||||
/// How many times [`current_user_sid`] ran on this thread, for [`sid_lookups_on_this_thread`].
|
||||
static SID_LOOKUPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times [`current_user_sid`] has started `whoami` on the calling thread. Each lookup
|
||||
/// starts a process, so a test can check that a caller reads the SID once and passes it on
|
||||
/// (review on #3956). Per thread, so tests running in parallel don't count each other's.
|
||||
#[cfg(windows)]
|
||||
#[doc(hidden)]
|
||||
pub fn sid_lookups_on_this_thread() -> usize {
|
||||
SID_LOOKUPS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The current user's SID (`whoami /user`). It starts a process, so a caller that needs it for
|
||||
/// several steps reads it once and passes it to each.
|
||||
#[cfg(windows)]
|
||||
pub fn current_user_sid() -> std::io::Result<String> {
|
||||
SID_LOOKUPS.with(|n| n.set(n.get().saturating_add(1)));
|
||||
let out = system_tool(WHOAMI).args(["/user", "/fo", "csv", "/nh"]).output()?;
|
||||
if !out.status.success() {
|
||||
return Err(std::io::Error::other("whoami /user failed"));
|
||||
}
|
||||
sid_from_whoami(&String::from_utf8_lossy(&out.stdout)).ok_or_else(|| std::io::Error::other("whoami /user printed no SID"))
|
||||
}
|
||||
|
||||
#[cfg(any(test, windows))]
|
||||
/// The SID in `whoami /user /fo csv /nh` output, `"DOMAIN\user","S-1-5-21-…"`: the last quoted
|
||||
/// field, when it is one. Anything else (an error message, nothing) gives `None`.
|
||||
fn sid_from_whoami(text: &str) -> Option<String> {
|
||||
let last = text.trim().rsplit(',').next()?.trim().trim_matches('"');
|
||||
let digits = last.strip_prefix("S-1-")?;
|
||||
// Every part after `S-1-` is a number; an empty remainder is one empty part, so it fails too.
|
||||
digits.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| last.to_string())
|
||||
}
|
||||
|
||||
/// Replace `path`'s access list with one that only user `me` (the current user's SID, from
|
||||
/// [`current_user_sid`], which the caller reads once for every step) and SYSTEM are on, then make
|
||||
/// that user its owner (`icacls /setowner`), then read the file's descriptor back and check it with
|
||||
/// [`foreign_access`]. The whole list is replaced, not just what was inherited: on the
|
||||
/// windows-latest runner a new file in the temp folder carries an Administrators entry that `icacls
|
||||
/// /inheritance:r` leaves in place. The owner is set because a file an elevated administrator creates
|
||||
/// is owned by the Administrators group, not by the user. The list comes first: its owner may always
|
||||
/// replace it, while setting the owner needs the Full control it grants (a folder may give Modify).
|
||||
#[cfg(windows)]
|
||||
pub fn restrict_to_owner(path: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
set_access_list(path, me, Kind::File)?;
|
||||
icacls(path, &["/setowner".into(), format!("*{me}")])?;
|
||||
still_foreign(&sddl_of(path)?, me)
|
||||
}
|
||||
|
||||
/// Create the folder `dir`, which must not exist yet, and give it an access list of user `me` (as
|
||||
/// for [`restrict_to_owner`]) and SYSTEM only, which every file created in it inherits; then make
|
||||
/// that user its owner and check the result as [`restrict_to_owner`] does. A file created in it is
|
||||
/// private from the moment it exists. Restricting a file after creating it leaves a window in which another user
|
||||
/// whom the parent folder lets read it can open a handle, and Windows checks access only when a
|
||||
/// handle is opened, so a later change of the list doesn't revoke it (review on #3956). A handle
|
||||
/// on the folder itself, opened during the folder's own window, gives no access to the files
|
||||
/// created in it later. The folder is removed again when it can't be restricted.
|
||||
#[cfg(windows)]
|
||||
pub fn create_private_dir(dir: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
std::fs::create_dir(dir)?;
|
||||
fn restrict(dir: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
set_access_list(dir, me, Kind::Folder)?;
|
||||
icacls(dir, &["/setowner".into(), format!("*{me}")])?;
|
||||
still_foreign(&sddl_of(dir)?, me)
|
||||
}
|
||||
let restricted = restrict(dir, me);
|
||||
if restricted.is_err() {
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
restricted
|
||||
}
|
||||
|
||||
/// `Err` naming the problem when the descriptor read back after restricting still isn't `me`'s
|
||||
/// alone, so a file that only looks restricted is never used as one.
|
||||
#[cfg(any(test, windows))]
|
||||
fn still_foreign(sddl: &str, me: &str) -> std::io::Result<()> {
|
||||
match foreign_access(sddl, me) {
|
||||
None => Ok(()),
|
||||
Some(problem) => Err(std::io::Error::other(format!("the file could not be restricted to its owner: {problem}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`set_access_list`] restricts: a file, or a folder whose entries the files and folders
|
||||
/// created in it inherit.
|
||||
#[cfg(windows)]
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
File,
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// Set `path`'s access list to Full control for `me` and SYSTEM and nothing else, protected from
|
||||
/// inheritance; on a folder, what is created in it inherits the two entries. Only the list is
|
||||
/// written, not the owner. `me` and the path reach PowerShell through environment variables, never
|
||||
/// as script text.
|
||||
#[cfg(windows)]
|
||||
fn set_access_list(path: &std::path::Path, me: &str, kind: Kind) -> std::io::Result<()> {
|
||||
// No double quotes: the command line would strip them.
|
||||
const FILE: &str = "$s = New-Object Security.AccessControl.FileSecurity; \
|
||||
$s.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;;FA;;;SY)', 'Access'); \
|
||||
[IO.File]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)";
|
||||
const FOLDER: &str = "$s = New-Object Security.AccessControl.DirectorySecurity; \
|
||||
$s.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;OICI;FA;;;SY)', 'Access'); \
|
||||
[IO.Directory]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)";
|
||||
let script = match kind {
|
||||
Kind::File => FILE,
|
||||
Kind::Folder => FOLDER,
|
||||
};
|
||||
let out = system_tool(POWERSHELL)
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.env("PDFCRAFT_ACL_PATH", path)
|
||||
.env("PDFCRAFT_ACL_USER", me)
|
||||
.output()?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!("PowerShell could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn icacls(path: &std::path::Path, args: &[String]) -> std::io::Result<()> {
|
||||
let out = system_tool(ICACLS).arg(path).args(args).output()?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!("icacls could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim())))
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerShell that turns `$raw`, a `RawSecurityDescriptor`, into `$sddl` as [`read_with_sddl`]
|
||||
/// describes it. No double quotes: the command line would strip them. An entry with no account (a
|
||||
/// custom entry) is written with none, and `foreign_access` refuses it.
|
||||
#[cfg(windows)]
|
||||
macro_rules! describe_raw {
|
||||
() => {
|
||||
"$sddl = 'O:' + $raw.Owner.Value; if ($raw.DiscretionaryAcl -ne $null) { $sddl += 'D:'; foreach ($a in $raw.DiscretionaryAcl) { $t = 'A'; if ([string]$a.AceType -like 'AccessDenied*') { $t = 'D' }; $f = ''; if ($a.AceFlags.HasFlag([Security.AccessControl.AceFlags]::InheritOnly)) { $f = 'IO' }; $sid = ''; if ($a.SecurityIdentifier) { $sid = $a.SecurityIdentifier.Value }; $sddl += '(' + $t + ';' + $f + ';' + $a.AccessMask + ';;;' + $sid + ')' } }; "
|
||||
};
|
||||
}
|
||||
|
||||
/// Open `path` shared for reading only, so that while it is open it can't be replaced, renamed or
|
||||
/// written, and read through that one handle both its security descriptor (owner and access list)
|
||||
/// and its first `max + 1` bytes. The descriptor checked is the one of the handle the bytes come
|
||||
/// from, so there is no moment at which the path could name a different file (a time-of-check to
|
||||
/// time-of-use race). The path reaches PowerShell through an environment variable, never as script
|
||||
/// text.
|
||||
///
|
||||
/// The descriptor is written in SDDL's shape but with every account as a full SID, read from the
|
||||
/// binary descriptor (`RawSecurityDescriptor`): `O:<owner>D:(<type>;<flags>;<rights>;;;<sid>)…`,
|
||||
/// where the type is `D` for a deny entry and `A` for any other, and the flags are `IO` for an
|
||||
/// inherit-only entry. Windows' own SDDL writes some accounts by alias, and the built-in
|
||||
/// Administrator's alias `LA` stands for a SID that depends on the machine.
|
||||
#[cfg(windows)]
|
||||
pub fn read_with_sddl(path: &std::path::Path, max: u64) -> std::io::Result<(String, Vec<u8>)> {
|
||||
const SCRIPT: &str = concat!(
|
||||
"$fs = [IO.File]::Open($env:PDFCRAFT_ACL_PATH, 'Open', 'Read', 'Read'); try { $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($fs.GetAccessControl().GetSecurityDescriptorBinaryForm(), 0); ",
|
||||
describe_raw!(),
|
||||
"$max = [int]$env:PDFCRAFT_ACL_MAX + 1; $buf = New-Object byte[] $max; $total = 0; while ($total -lt $max) { $n = $fs.Read($buf, $total, $max - $total); if ($n -le 0) { break }; $total += $n }; [Console]::Out.Write($sddl + [char]10 + [BitConverter]::ToString($buf, 0, $total)) } finally { $fs.Dispose() }"
|
||||
);
|
||||
// Bounded so the script's `[int]` and buffer stay small; a control file is far below this.
|
||||
let max = max.min(1 << 20);
|
||||
let out = system_tool(POWERSHELL)
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT])
|
||||
.env("PDFCRAFT_ACL_PATH", path)
|
||||
.env("PDFCRAFT_ACL_MAX", max.to_string())
|
||||
.output()?;
|
||||
let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None };
|
||||
read.ok_or_else(|| std::io::Error::other("PowerShell could not read the file with its owner and access list"))
|
||||
}
|
||||
|
||||
/// The security descriptor of `path` as [`read_with_sddl`] writes it, read by name without opening
|
||||
/// the file's contents, so it works on a file that is open for writing (the app restricts its
|
||||
/// control file before writing the token through the handle it created).
|
||||
#[cfg(windows)]
|
||||
pub fn sddl_of(path: &std::path::Path) -> std::io::Result<String> {
|
||||
const SCRIPT: &str = concat!(
|
||||
// Stop: a failed read (a missing file) must end the script with an error, not print `O:`.
|
||||
"$ErrorActionPreference = 'Stop'; $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @([IO.File]::GetAccessControl($env:PDFCRAFT_ACL_PATH).GetSecurityDescriptorBinaryForm(), 0); ",
|
||||
describe_raw!(),
|
||||
"[Console]::Out.Write($sddl + [char]10)"
|
||||
);
|
||||
let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]).env("PDFCRAFT_ACL_PATH", path).output()?;
|
||||
let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None };
|
||||
read.map(|(sddl, _)| sddl).ok_or_else(|| std::io::Error::other("PowerShell could not read the file's owner and access list"))
|
||||
}
|
||||
|
||||
/// [`read_with_sddl`]'s output: the SDDL, a newline, then the bytes as `[BitConverter]::ToString`
|
||||
/// prints them (`7B-22-…`, empty for none). Anything else gives `None`.
|
||||
#[cfg(any(test, windows))]
|
||||
fn sddl_and_bytes(text: &str) -> Option<(String, Vec<u8>)> {
|
||||
let (sddl, hex) = text.split_once('\n')?;
|
||||
let sddl = sddl.trim();
|
||||
if !sddl.contains("O:") {
|
||||
return None;
|
||||
}
|
||||
let hex = hex.trim();
|
||||
if hex.is_empty() {
|
||||
return Some((sddl.to_string(), Vec::new()));
|
||||
}
|
||||
// Two hex digits each; `from_str_radix` alone would also take a sign, as in `+7`.
|
||||
let byte = |pair: &str| (pair.len() == 2 && pair.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u8::from_str_radix(pair, 16).ok()).flatten();
|
||||
let bytes = hex.split('-').map(byte).collect::<Option<Vec<u8>>>()?;
|
||||
Some((sddl.to_string(), bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ME: &str = "S-1-5-21-1-2-3-1001";
|
||||
|
||||
/// The SID of the user running the tests, as `write_control_file` reads it once and passes on.
|
||||
#[cfg(windows)]
|
||||
fn user() -> String {
|
||||
current_user_sid().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_own_file_with_system_is_trusted_and_administrators_are_not() {
|
||||
assert_eq!(foreign_access(&format!("O:{ME}G:{ME}D:P(A;;FA;;;{ME})(A;;FA;;;SY)"), ME), None);
|
||||
assert_eq!(foreign_access(&format!("O:{ME}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{ME})"), ME), None);
|
||||
// Another administrator could read it through the Administrators group, by alias or by SID.
|
||||
for admins in ["BA", "S-1-5-32-544"] {
|
||||
let sddl = format!("O:{ME}D:PAI(A;;FA;;;SY)(A;;FR;;;{admins})(A;;FA;;;{ME})");
|
||||
assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains(admins)), "{sddl}");
|
||||
}
|
||||
// Inherit-only entries and deny entries don't grant access to the file itself.
|
||||
assert_eq!(foreign_access(&format!("O:{ME}D:(A;OICIIO;GA;;;CO)(D;;FA;;;BU)(A;;FA;;;{ME})"), ME), None);
|
||||
}
|
||||
|
||||
/// Review on #3956: each tool is started by its full path in System32, never looked up by
|
||||
/// name.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_tools_are_started_from_system32() {
|
||||
for tool in [WHOAMI, POWERSHELL, ICACLS] {
|
||||
let command = system_tool(tool);
|
||||
let path = std::path::Path::new(command.get_program());
|
||||
assert!(path.is_absolute() && path.is_file(), "{path:?}");
|
||||
assert!(path.to_string_lossy().to_ascii_lowercase().contains(r"\system32\"), "{path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_owners_readers_and_missing_lists_are_refused() {
|
||||
let other = "S-1-5-21-1-2-3-1002";
|
||||
assert!(foreign_access(&format!("O:{other}D:(A;;FA;;;{ME})"), ME).unwrap().contains("owned by another account"));
|
||||
// Users (BU) and Authenticated Users (AU), as a folder at a drive root passes on.
|
||||
assert!(foreign_access(&format!("O:{ME}D:AI(A;ID;FA;;;{ME})(A;ID;0x1301bf;;;AU)"), ME).unwrap().contains("AU"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FR;;;BU)(A;;FA;;;{ME})"), ME).unwrap().contains("BU"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{other})"), ME).is_some());
|
||||
assert!(foreign_access(&format!("O:{ME}G:SY"), ME).unwrap().contains("no access list"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:NO_ACCESS_CONTROL"), ME).is_some());
|
||||
assert!(foreign_access("D:(A;;FA;;;SY)", ME).unwrap().contains("owner"));
|
||||
}
|
||||
|
||||
/// A conditional entry can hold a `:` in a string; the access list doesn't end there, so the
|
||||
/// entry after it is still checked.
|
||||
#[test]
|
||||
fn a_colon_inside_an_entry_does_not_end_the_access_list() {
|
||||
let sddl = format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "a:b"))(A;;FR;;;BU)"#);
|
||||
assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains("BU")), "{sddl}");
|
||||
}
|
||||
|
||||
/// A conditional entry for the user is the user's; one that grants access but whose account
|
||||
/// can't be read is refused rather than skipped.
|
||||
#[test]
|
||||
fn conditional_and_malformed_entries() {
|
||||
assert_eq!(foreign_access(&format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "x"))"#), ME), None);
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR)"), ME).is_some_and(|p| p.contains("can't be read")));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;)"), ME).is_some_and(|p| p.contains("can't be read")));
|
||||
// A conditional entry grants access like a plain one: another account on it is refused.
|
||||
let other = format!(r#"O:{ME}D:(A;;FA;;;{ME})(XA;;FR;;;BU;(@User.dept == "x"))"#);
|
||||
assert!(foreign_access(&other, ME).is_some_and(|p| p.contains("BU")), "{other}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sid_is_read_from_whoamis_output_and_nothing_else() {
|
||||
assert_eq!(sid_from_whoami("\"desk\\ada\",\"S-1-5-21-1-2-3-1001\"\r\n").as_deref(), Some("S-1-5-21-1-2-3-1001"));
|
||||
for bad in [
|
||||
"",
|
||||
"ERROR: Access is denied.",
|
||||
"\"desk\\ada\",\"\"",
|
||||
"\"desk\\ada\",\"S-1-\"",
|
||||
"\"S-1-5-x\"",
|
||||
"\"desk\\ada\"",
|
||||
"\"desk\\ada\",\"1-5-21-7\"",
|
||||
] {
|
||||
assert_eq!(sid_from_whoami(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The real tools: a file created in a folder that lets Users read what's in it inherits that
|
||||
/// access; after `restrict_to_owner` only the current user and SYSTEM are left.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_in_a_shared_folder_is_restricted_to_its_owner() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
// Users inherit read access, so the file is refused. (Run elevated, the file is also owned
|
||||
// by Administrators, which is the reason given first.)
|
||||
let before = sddl_of(&file).unwrap();
|
||||
assert!(before.contains(";BU)") || before.contains(";S-1-5-32-545)"), "Users inherit read access: {before}");
|
||||
assert!(foreign_access(&before, &me).is_some(), "{before}");
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review on #3956: a file created in a private folder inside a folder that lets Users read
|
||||
/// what's in it is the user's alone from the moment it exists, before anything restricts it.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_created_in_a_private_folder_is_private_from_the_start() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-private-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
let private = dir.join("staging");
|
||||
create_private_dir(&private, &user()).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let folder = sddl_of(&private).unwrap();
|
||||
assert_eq!((section(&folder, "O:"), foreign_access(&folder, &me)), (Some(me.as_str()), None), "{folder}");
|
||||
let file = private.join("ctl.json");
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&file).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert!(!sddl.contains(";BU)") && !sddl.contains(";S-1-5-32-545)"), "Users must not inherit read access: {sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
// The name is taken: the folder isn't reused.
|
||||
assert!(create_private_dir(&private, &user()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A folder may give its user Modify but not Full control, so a file created there doesn't let
|
||||
/// its owner change the owner (no `WRITE_OWNER`). An owner can always replace the access list,
|
||||
/// though, and the user's Full control on it then allows setting the owner.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_in_a_folder_granting_only_modify_is_still_restricted() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-modify-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let modify = std::process::Command::new("icacls")
|
||||
.arg(&dir)
|
||||
.args(["/inheritance:r", "/grant:r"])
|
||||
.arg(format!("*{me}:(OI)(CI)M"))
|
||||
.arg("*S-1-5-18:(OI)(CI)F")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(modify.status.success(), "{}", String::from_utf8_lossy(&modify.stderr));
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A null access list (`NO_ACCESS_CONTROL`) lets every account use the file, so it is refused.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_with_no_access_list_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-null-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
// A null access list lets everyone in; the user, as owner, may set one.
|
||||
let null = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:NO_ACCESS_CONTROL', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_NULL_PATH, $s)",
|
||||
])
|
||||
.env("PDFCRAFT_NULL_PATH", &file)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(null.status.success(), "{}", String::from_utf8_lossy(&null.stderr));
|
||||
let (sddl, _) = read_with_sddl(&file, 64).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
assert!(foreign_access(&sddl, &me).is_some_and(|p| p.contains("no access list")), "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write
|
||||
/// owner* that way, the access list can't be replaced although the owner can still be set: the
|
||||
/// file isn't taken as restricted.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-ow-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
// The whole list is written, as `restrict_to_owner` does: `icacls /inheritance:r` leaves
|
||||
// the Administrators entry the windows-latest runner gives a new file, and with it an
|
||||
// elevated administrator could replace the list after all.
|
||||
let rights = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:P(A;;FR;;;' + $env:PDFCRAFT_OW_USER + ')(A;;WO;;;OW)', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_OW_PATH, $s)",
|
||||
])
|
||||
.env("PDFCRAFT_OW_PATH", &file)
|
||||
.env("PDFCRAFT_OW_USER", &me)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(rights.status.success(), "{}", String::from_utf8_lossy(&rights.stderr));
|
||||
// Nothing but the two entries: no Administrators entry for the elevated runner to use.
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}");
|
||||
assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// When the tools fail, the functions say so: nothing is taken as restricted or as checked.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tool_failures_are_errors() {
|
||||
let missing = std::env::temp_dir().join(format!("pdfcraft-owner-only-missing-{}", std::process::id())).join("none.json");
|
||||
assert!(restrict_to_owner(&missing, &user()).is_err(), "icacls on a missing file");
|
||||
assert!(sddl_of(&missing).is_err(), "Get-Acl on a missing file");
|
||||
let me = current_user_sid().unwrap();
|
||||
assert!(me.starts_with("S-1-5-") && me.split('-').count() >= 4, "{me}");
|
||||
assert!(!me.contains('"') && !me.contains('\\'), "the SID alone, not the account name: {me}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_and_bytes_are_read_from_the_scripts_output_and_nothing_else() {
|
||||
let sddl = format!("O:{ME}D:P(A;;FA;;;{ME})");
|
||||
assert_eq!(sddl_and_bytes(&format!("{sddl}\n7B-0A-ff\r\n")), Some((sddl.clone(), vec![0x7B, 0x0A, 0xFF])));
|
||||
assert_eq!(sddl_and_bytes(&format!("{sddl}\n")), Some((sddl.clone(), Vec::new())));
|
||||
for bad in [
|
||||
String::new(),
|
||||
sddl.clone(),
|
||||
"D:P(A;;FA;;;SY)\n7B".into(),
|
||||
format!("{sddl}\n7B-2"),
|
||||
format!("{sddl}\n7B--22"),
|
||||
format!("{sddl}\n7G"),
|
||||
format!("{sddl}\n+7-22"),
|
||||
] {
|
||||
assert_eq!(sddl_and_bytes(&bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes come with the descriptor of the same handle, capped at `max + 1` so an oversized
|
||||
/// file shows, and the restricted file is owned by the user.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_file_is_read_with_its_descriptor_through_one_handle() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, br#"{"port":1}"#).unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let (sddl, bytes) = read_with_sddl(&file, 64).unwrap();
|
||||
assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}");
|
||||
assert_eq!((foreign_access(&sddl, &me), bytes.as_slice()), (None, &br#"{"port":1}"#[..]));
|
||||
assert_eq!(read_with_sddl(&file, 3).unwrap().1, br#"{"po"#);
|
||||
// A limit past what the script's `[int]` holds is capped, not handed to it.
|
||||
assert_eq!(read_with_sddl(&file, u64::MAX).unwrap().1, br#"{"port":1}"#);
|
||||
assert!(read_with_sddl(&dir.join("none.json"), 64).is_err());
|
||||
// A deny entry is read as one: denying Guests takes nothing from the user.
|
||||
let guests = std::process::Command::new("icacls").arg(&file).arg("/deny").arg("*S-1-5-32-546:(R)").output().unwrap();
|
||||
assert!(guests.status.success(), "{}", String::from_utf8_lossy(&guests.stderr));
|
||||
let (sddl, _) = read_with_sddl(&file, 64).unwrap();
|
||||
assert!(sddl.contains(";;;S-1-5-32-546)") && sddl.contains("(D;"), "{sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
// The handle shares the file for reading only: a file someone has open for writing (or
|
||||
// could rename or delete) isn't read, and so can't change between the check and the read.
|
||||
let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap();
|
||||
assert!(read_with_sddl(&file, 64).is_err(), "a file open for writing is not read");
|
||||
// Restricting reads the descriptor alone, so it works on the file the app holds open to
|
||||
// write the token through.
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
assert_eq!(foreign_access(&sddl_of(&file).unwrap(), &me), None);
|
||||
drop(writer);
|
||||
assert!(read_with_sddl(&file, 64).is_ok());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Entries of the file's own, not inherited, are replaced too: an Administrators or Users
|
||||
/// entry (as the windows-latest runner's temp folder leaves on a new file, #3922) and a deny
|
||||
/// that would keep the user from setting the owner.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn entries_of_the_files_own_are_replaced() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-own-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let mine = format!("*{me}:(R)");
|
||||
for (name, entries) in [
|
||||
("others.json", ["/grant", "*S-1-5-32-544:(F)", "/grant", "*S-1-5-32-545:(R)"]),
|
||||
("deny.json", ["/deny", "*S-1-1-0:(WO)", "/grant", "*S-1-5-32-544:(F)"]),
|
||||
] {
|
||||
let file = dir.join(name);
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
// The user keeps read access, so the descriptor can be read before restricting.
|
||||
let own = std::process::Command::new("icacls").arg(&file).arg("/inheritance:r").args(entries).args(["/grant", &mine]).output().unwrap();
|
||||
assert!(own.status.success(), "{}", String::from_utf8_lossy(&own.stderr));
|
||||
assert!(foreign_access(&sddl_of(&file).unwrap(), &me).is_some(), "{name}");
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{name}: {sddl}");
|
||||
assert!(!sddl.contains("(D;"), "{name}: the deny is gone: {sddl}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_still_open_to_others_after_restricting_is_an_error() {
|
||||
assert!(still_foreign(&format!("O:{ME}D:P(A;;FA;;;SY)(A;;FA;;;{ME})"), ME).is_ok());
|
||||
for bad in [format!("O:{ME}D:P(A;;FA;;;S-1-5-32-544)(A;;FA;;;{ME})"), format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), format!("O:{ME}")] {
|
||||
assert!(still_foreign(&bad, ME).is_err_and(|e| e.to_string().contains("could not be restricted")), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`read_with_sddl`] gives accounts as full SIDs (rights as a number, no list flags), the way a
|
||||
/// CI runner's built-in Administrator reads its own restricted file. An alias such as `LA`
|
||||
/// is never taken for the user, whatever the user's RID: on a domain-joined machine a domain
|
||||
/// account can share the local Administrator's RID 500.
|
||||
#[test]
|
||||
fn descriptors_with_full_sids_and_aliases_that_are_never_the_user() {
|
||||
let admin = "S-1-5-21-1-2-3-500";
|
||||
let full = format!("O:{admin}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{admin})");
|
||||
assert_eq!(foreign_access(&full, admin), None);
|
||||
// Owner rights by SID, a deny by SID, an inherit-only entry for Creator Owner.
|
||||
let more = format!("O:{ME}D:(D;;1179785;;;S-1-5-32-546)(A;IO;268435456;;;S-1-3-0)(A;;2032127;;;S-1-3-4)(A;;2032127;;;{ME})");
|
||||
assert_eq!(foreign_access(&more, ME), None);
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;1179785;;;S-1-5-32-545)(A;;2032127;;;{ME})"), ME).is_some_and(|p| p.contains("S-1-5-32-545")));
|
||||
for me in [admin, "S-1-5-21-1-2-3-501"] {
|
||||
assert!(foreign_access("O:LAD:PAI(A;;FA;;;SY)(A;;FA;;;LA)", me).is_some_and(|p| p.contains("owned by another account (LA)")), "{me}");
|
||||
assert!(foreign_access("O:LGD:P(A;;FA;;;LG)", me).is_some(), "{me}");
|
||||
}
|
||||
assert!(foreign_access(&format!("O:{admin}D:P(A;;FA;;;{admin})(A;;FR;;;LA)"), admin).is_some_and(|p| p.contains("LA")));
|
||||
}
|
||||
|
||||
/// Review on #3956: a refusal names a well-known account as well as giving its SID, so
|
||||
/// `S-1-5-32-544` reads as Administrators without a lookup; any other SID is given alone.
|
||||
#[test]
|
||||
fn refusals_name_well_known_accounts_and_keep_their_sid() {
|
||||
for (sid, name) in [
|
||||
("S-1-5-32-544", "Administrators"),
|
||||
("S-1-5-32-545", "Users"),
|
||||
("S-1-5-11", "Authenticated Users"),
|
||||
("S-1-5-4", "INTERACTIVE"),
|
||||
("S-1-1-0", "Everyone"),
|
||||
("S-1-5-6", "SERVICE"),
|
||||
("S-1-5-3", "BATCH"),
|
||||
] {
|
||||
let sddl = format!("O:{ME}D:PAI(A;;FA;;;{ME})(A;ID;1179817;;;{sid})");
|
||||
assert_eq!(foreign_access(&sddl, ME), Some(format!("another account, {name} ({sid}), has access to it")), "{sddl}");
|
||||
}
|
||||
// SYSTEM may have access, but a file it owns isn't the user's.
|
||||
assert_eq!(foreign_access(&format!("O:S-1-5-18D:P(A;;FA;;;{ME})"), ME).as_deref(), Some("it is owned by another account, SYSTEM (S-1-5-18)"));
|
||||
assert_eq!(
|
||||
foreign_access(&format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), ME).as_deref(),
|
||||
Some("it is owned by another account, Administrators (S-1-5-32-544)")
|
||||
);
|
||||
// Another user, a lookalike and an alias have no fixed name: the account is given as it was.
|
||||
for sid in ["S-1-5-21-1-2-3-1002", "S-1-5-32-5440", "S-1-5-1", "BA"] {
|
||||
let sddl = format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR;;;{sid})");
|
||||
assert_eq!(foreign_access(&sddl, ME), Some(format!("another account ({sid}) has access to it")), "{sddl}");
|
||||
}
|
||||
assert_eq!(
|
||||
foreign_access("O:S-1-5-21-1-2-3-1002D:(A;;FA;;;SY)", ME).as_deref(),
|
||||
Some("it is owned by another account (S-1-5-21-1-2-3-1002)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_stop_at_the_next_tag() {
|
||||
assert_eq!(section("O:S-1-5-18G:S-1-5-32-544D:(A;;FA;;;SY)", "O:"), Some("S-1-5-18"));
|
||||
assert_eq!(section("O:BAG:SYD:P(A;;FA;;;SY)S:(AU;SA;FA;;;WD)", "D:"), Some("P(A;;FA;;;SY)"));
|
||||
assert_eq!(section("O:BA", "D:"), None);
|
||||
}
|
||||
}
|
||||
@@ -219,14 +219,22 @@ pub(crate) fn page_form(
|
||||
body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes());
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
}
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
// With no appearance added the form keeps the page's own Resources, shared by reference,
|
||||
// rather than a deep copy per printed page.
|
||||
let resources = match page.dict.get(b"Resources") {
|
||||
Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(),
|
||||
_ => {
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
Object::Dict(res)
|
||||
}
|
||||
};
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
d.set(b"Resources".to_vec(), resources);
|
||||
if let Some(g) = page.dict.get(b"Group") {
|
||||
d.set(b"Group".to_vec(), g.clone());
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub mod spool;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use range::{Subset, select_listed, select_pages};
|
||||
pub use range::{Subset, position, select_listed, select_pages};
|
||||
|
||||
mod error;
|
||||
mod imposition;
|
||||
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user