Merge remote-tracking branch 'upstream/main' into fix/xfa-relevant-print

This commit is contained in:
Eric Kaden committed 2026-11-08 20:52:49 -05:00
commit cdd446dcfb
101 files changed
+5141 -390

No files matched your search

Generated
+15
View File
@@ -4592,6 +4592,7 @@ dependencies = [
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"thread-priority",
"zune-jpeg",
]
@@ -6955,6 +6956,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "thread-priority"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "tiff"
version = "0.11.3"
+2 -2
View File
@@ -287,7 +287,7 @@ fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
})
.collect()
}
return key(left) == key(right);
key(left) == key(right)
}
#[cfg(not(windows))]
{
@@ -307,7 +307,7 @@ fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
wide.pop();
}
}
return Ok(std::ffi::OsString::from_wide(&wide));
Ok(std::ffi::OsString::from_wide(&wide))
}
#[cfg(not(windows))]
{
+123 -42
View File
@@ -5,10 +5,13 @@
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The path is made absolute against the working directory, then followed the way the file
//! system reads it, one name at a time from the top, its links followed. A name is looked up
//! only while the folder it is in is the root, inside it, or one of the root's own parent
//! folders: a path that leaves those is refused before anything beyond is touched (looking up
//! `/net/<host>` mounts it), and every such refusal reads the same, so it can't tell what exists
//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken
//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere).
//! - 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.
@@ -20,7 +23,6 @@
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
//! files it was given; they don't guard against other programs on the computer.
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -100,11 +102,10 @@ impl AutomationRoots {
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
};
};
let absolute = absolute(shown, target)?;
// Another network share or a device is refused by name, as `--root` refuses it: even
// resolving the path would contact the host (#4003).
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
return Ok(());
match follow(shown, &absolute(shown, target)?, folder) {
Toward::Inside => return Ok(()),
Toward::Refused(why) => return Err(why),
Toward::Outside => {}
}
let root = root_shown.display();
match access {
@@ -141,42 +142,66 @@ fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
}
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
/// folder's links followed and the rest appended.
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute;
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
/// Where a followed path ends up.
pub(crate) enum Toward {
/// Inside the root, with its links followed (a missing tail taken as names).
Inside,
/// Anywhere else. Said the same way whatever is out there.
Outside,
/// Inside the root, but it can't be followed, and why.
Refused(String),
}
/// Follow the absolute `path` the way the file system reads it, one name at a time from the top,
/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own
/// parent folders (all with their links followed already, so `..` means what it means to the file
/// system).
pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
// Another network share or a device is refused by name, as `--root` refuses it: even looking
// it up would contact the host (#4003).
if foreign_share(path, root) {
return Toward::Outside;
}
let near = |at: &Path| inside(at, root) || inside(root, at);
let mut parts = path.components().peekable();
let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect();
let mut at = match std::fs::canonicalize(&top) {
Ok(at) if near(&at) => at,
_ => return Toward::Outside,
};
while let Some(part) = parts.next() {
let name = match part {
Component::Normal(name) => name,
Component::CurDir => continue,
Component::ParentDir => {
at.pop();
continue;
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
Component::Prefix(_) | Component::RootDir => return Toward::Outside,
};
let next = at.join(name);
match std::fs::canonicalize(&next) {
Ok(found) if near(&found) => at = found,
Ok(_) => return Toward::Outside,
// Not even there yet: only inside the root does it matter why.
Err(_) if !inside(&at, root) => return Toward::Outside,
Err(e) => {
return match std::fs::symlink_metadata(&next) {
Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) {
return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
Toward::Inside
}
Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
};
}
}
}
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
@@ -306,6 +331,62 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// Outside the root, the refusal comes before anything out there is looked up, so it can't
/// tell whether a folder out there exists.
#[test]
fn paths_out_of_the_root_are_refused_alike_without_looking() {
let (base, roots) = folders("follow");
let sep = std::path::MAIN_SEPARATOR;
let root = s(base.join("in"));
let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, "<path>");
// Through a folder outside that exists, and through one that doesn't.
let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf");
let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf");
assert_eq!(refusal(&there), refusal(&missing));
assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing));
// Out through a missing folder and back in. (Windows folds `..` by name when it makes a path
// absolute, as it does when it opens one: that path is `in\a.pdf` there.)
#[cfg(not(windows))]
assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root"));
// A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it.
#[cfg(windows)]
{
let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}");
assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf")));
assert!(
refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"),
"{}",
refusal(&verbatim(r"..\nowhere\x.pdf"))
);
}
let _ = std::fs::remove_dir_all(base);
}
/// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on
/// macOS, where the temporary folder is reached through a link).
#[test]
fn a_root_may_be_named_as_given_or_as_resolved() {
let (base, roots) = folders("given");
let resolved = std::fs::canonicalize(base.join("in")).unwrap();
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// A link beside the root, in one of its parent folders, is followed: to the root it leads in,
/// anywhere else it leads out.
#[cfg(unix)]
#[test]
fn links_beside_the_root_are_followed() {
let (base, roots) = folders("beside");
std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap();
assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(()));
assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
-1
View File
@@ -48,7 +48,6 @@ use serde_json::{Value, json};
pub use pdfcraft_render::Pixels;
pub use tools::{ToolDef, tools};
use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
+8 -2
View File
@@ -1,5 +1,5 @@
use super::error::{Result, failed};
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes};
impl Automation {
// ---- paths -------------------------------------------------------------------------------
@@ -39,7 +39,13 @@ impl Automation {
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
let joined = lexical(&root.join(p)).ok_or_else(outside)?;
if foreign_share(&joined, root) {
// Followed one name at a time first: nothing outside the root or its own parent folders is
// looked up, not even to see whether it exists. What can't be followed inside the root is
// said below. Made absolute the way Windows reads it first: it drops a folder name's
// trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it
// doesn't.
let as_read = std::path::absolute(&joined).map_err(|_| outside())?;
if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) {
return Err(outside());
}
let mut existing = joined.as_path();
+18 -2
View File
@@ -134,6 +134,18 @@ impl Automation {
"shrink" => Layout::Size(SizeMode::Shrink),
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
"multiple" => {
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) {
(None, None) => None,
(Some(cols), Some(rows)) => {
if a.opt_int("per_sheet")?.is_some() {
return Err(bad("give per_sheet, or cols and rows, not both"));
}
let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows));
Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?)
}
_ => return Err(bad("a custom grid needs both cols and rows")),
};
let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize;
let order = match a.opt_str("order")?.unwrap_or("horizontal") {
"horizontal" => PageOrder::Horizontal,
@@ -143,14 +155,15 @@ impl Automation {
"cut-stack" => PageOrder::CutStack,
o => return Err(bad(format!("unknown order {o:?}"))),
};
match Layout::multiple(per) {
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
match grid.unwrap_or_else(|| Layout::multiple(per)) {
Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple {
cols,
rows,
per_sheet,
order,
border: a.opt_bool("border")?.unwrap_or(false),
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),
custom,
},
l => l,
}
@@ -172,6 +185,9 @@ impl Automation {
l => return Err(bad(format!("unknown layout {l:?}"))),
};
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cols and rows need layout multiple"));
}
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cut-stack order needs layout multiple"));
}
+23
View File
@@ -219,6 +219,7 @@ fn names_from_documents_become_one_plain_file_name() {
#[cfg(windows)]
#[test]
fn other_shares_and_device_paths_are_refused_by_name() {
use crate::file_access::foreign_share;
let local = Path::new(r"\\?\C:\work\root");
for p in [
r"\\host\share\x.pdf",
@@ -242,6 +243,28 @@ fn other_shares_and_device_paths_are_refused_by_name() {
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
}
/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so
/// with a root, a path that names the root or a folder above it that way still reaches the file
/// inside. A dotted name outside stays outside.
#[cfg(windows)]
#[test]
fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() {
let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("root")).unwrap();
std::fs::create_dir_all(base.join("outside")).unwrap();
std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap();
std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap();
let a = Automation::new().with_root(base.join("root")).unwrap();
let shown = base.to_string_lossy().into_owned();
for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] {
let resolved = a.resolve(&p, false);
assert!(resolved.is_ok(), "{p}: {resolved:?}");
}
assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err());
let _ = std::fs::remove_dir_all(&base);
}
/// A fresh, empty folder for one staging test.
fn staging_dir(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id()));
+3 -1
View File
@@ -707,7 +707,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, 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; 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!({
@@ -718,6 +718,8 @@ pub fn tools() -> Vec<ToolDef> {
"layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] },
"scale": { "type": "number", "exclusiveMinimum": 0 },
"per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 },
"cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." },
"rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." },
"order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] },
"border": { "type": "boolean" },
"auto_rotate": { "type": "boolean" },
+86
View File
@@ -8915,6 +8915,49 @@ fn cut_stack_printing_through_tools() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn custom_pages_per_sheet_prints_the_grid_as_given() {
// Two narrow pages side by side on portrait A4: 2 columns by 1 row (#6551).
let dir = workdir("custom-grid");
std::fs::write(dir.join("narrow.pdf"), fixture(4)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "narrow.pdf"}))["doc"].as_u64().unwrap();
for orientation in ["auto", "portrait"] {
let r = ok(
&mut a,
"doc_print",
json!({"doc": doc, "layout": "multiple", "cols": 2, "rows": 1, "paper": "A4", "orientation": orientation, "path": "grid.pdf"}),
);
assert_eq!(r["sheets"], 2, "{orientation}");
let printed = ok(&mut a, "doc_open", json!({"path": "grid.pdf"}))["doc"].as_u64().unwrap();
let info = ok(&mut a, "doc_info", json!({"doc": printed}));
let sheet = &info["pages"][0];
assert!(sheet["width"].as_f64().unwrap() < sheet["height"].as_f64().unwrap(), "portrait sheet with {orientation}: {sheet}");
// Side by side: the second page level with the first, to its right.
let rows = ok(&mut a, "text_extract", json!({"doc": printed, "rect": [0, 0, 600, 850]}));
let rows: Vec<&str> = rows["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim()).collect();
assert_eq!(rows, ["Page 1 Page 2", "Page 3 Page 4"], "{orientation}");
ok(&mut a, "doc_close", json!({"doc": printed}));
}
for bad in [
json!({"cols": 2}),
json!({"rows": 1}),
json!({"cols": 0, "rows": 1}),
json!({"cols": 2, "rows": 100}),
json!({"cols": -1, "rows": 2}),
json!({"cols": 2, "rows": 1, "per_sheet": 2}),
json!({"cols": 2, "rows": 1, "layout": "fit"}),
] {
let mut args = json!({"doc": doc, "layout": "multiple", "path": "refused.pdf"});
for (k, v) in bad.as_object().unwrap() {
args[k] = v.clone();
}
assert!(matches!(a.call("doc_print", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
}
assert!(!dir.join("refused.pdf").exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurements_calibrate_draw_save_reopen_and_export() {
let dir = workdir("measurements");
@@ -12122,6 +12165,49 @@ fn comments_copy_and_paste_between_documents_through_tools() {
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
}
/// A bitmap Type 3 font drawn with a flipped font matrix under a flipped text matrix (as TeX's
/// bitmap fonts are): `text_lines` boxes a line by its glyphs' `d1` boxes (ISO 32000-2 §9.6.4),
/// as high as the ink, not a 0.09-point sliver.
#[test]
fn text_lines_box_a_flipped_type3_line_by_its_glyphs() {
let content = "BT /F1 1 Tf 0.15 0 0 -0.15 10 20 Tm (ab) Tj ET";
let glyph_a = "46 0 3 -1 42 57 d1 3 -1 39 58 re f";
let glyph_b = "42 0 2 -18 40 38 d1 2 -18 38 56 re f";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 50] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type3 /FontMatrix [1 0 0 -1 0 0] /FontBBox [-4 -18 51 57] /FirstChar 97 /LastChar 98 \
/Widths [46 42] /Encoding << /Differences [97 /a /b] >> /CharProcs << /a 6 0 R /b 7 0 R >> >>"
.into(),
format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()),
format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let dir = workdir("type3-text-lines");
std::fs::write(dir.join("type3.pdf"), bytes).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "type3.pdf" }))["doc"].as_u64().unwrap();
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
// Glyph space y -18..57, turned over twice: 57 × 0.15 above the baseline at y 20 (8.55) and
// 18 × 0.15 below it (2.7). Top-left origin on a 50-point page: 21.45 down to 32.7.
assert_eq!(lines["count"], 1, "{lines}");
assert_eq!(lines["lines"][0]["text"], "ab");
assert_eq!(lines["lines"][0]["rect"], json!([10.0, 21.45, 23.2, 32.7]), "{lines}");
}
#[test]
fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() {
let temp = tempfile::tempdir().unwrap();
+5
View File
@@ -209,6 +209,11 @@ impl ImageBudget {
self.refused
}
/// Charges `work` steps paid for out of another allowance, by a thread working on its own.
pub fn charge(&mut self, work: u64) {
self.work = self.work.saturating_sub(work);
}
/// The steps of decoding still allowed.
pub fn work_left(&self) -> u64 {
self.work
+120
View File
@@ -1521,6 +1521,45 @@ fn text_page(content: &str) -> Document {
Document::open(Arc::new(out)).unwrap()
}
/// ISO 32000-2 §9.6.4: an Edit-text line set in a Type 3 bitmap font is as high as the boxes its
/// glyphs declare with `d1`, whatever its `/FontBBox` says and whichever way its font matrix
/// turns glyph space, so two lines 8 points apart are two lines with 7-point boxes, not slivers.
#[test]
fn edit_lines_in_a_type3_font_are_as_high_as_their_glyphs() {
// Glyph `a` is drawn 60 units wide, from 20 below the baseline to 50 above it, at 0.1 Tf:
// 7 points high, 2 below the baseline.
for (matrix, flip) in [("1 0 0 1 0 0", 1), ("1 0 0 -1 0 0", -1)] {
for bbox in ["[0 0 0 0]", "[0 0 1 1]"] {
let mut doc = text_page(&format!("BT /T3 0.1 Tf 1 0 0 {flip} 72 700 Tm (aa) Tj 1 0 0 {flip} 72 692 Tm (aa) Tj ET"));
let glyph = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"60 0 0 -20 60 50 d1 0 -20 60 70 re f".to_vec())));
let font = doc.add(
pdfcraft_cos::parse_indirect(
format!(
"1 0 obj << /Type /Font /Subtype /Type3 /FontMatrix [{matrix}] /FontBBox {bbox} /FirstChar 97 /LastChar 97 /Widths [60] /Encoding << /Differences [97 /a] >> /CharProcs << /a {} 0 R >> >> endobj",
glyph.num
)
.as_bytes(),
0,
&|_| None,
)
.unwrap()
.1,
);
let p = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(p, |d| {
d.set(
b"Resources".to_vec(),
Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(font))])))])),
)
})
.unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
let rects: Vec<_> = lines.iter().map(|l| (l.text.as_str(), l.rect.map(|v| (v * 100.0).round() / 100.0))).collect();
assert_eq!(rects, [("aa", [72.0, 698.0, 84.0, 705.0]), ("aa", [72.0, 690.0, 84.0, 697.0])], "{matrix} {bbox}");
}
}
}
#[test]
fn tracked_text_keeps_words_in_paragraphs_and_objects() {
let expected = "TRACKED SAMPLE TEXT";
@@ -13881,3 +13920,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft
assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added");
}
}
/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted
/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused
/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so
/// a break that was dropped would leave the words on one row instead of two. A one-line edit has
/// no next row, so there each break is written as a space.
#[test]
fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() {
let red = text::RangeStyle {
range: 0..3,
family: None,
resource: None,
size: None,
color: Some([1.0, 0.0, 0.0]),
underline: None,
char_spacing: None,
scale: None,
};
for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] {
let draft = format!("one{separator}two");
for style in [
text::BlockStyle::default(),
text::BlockStyle { ranges: vec![red.clone()], ..Default::default() },
text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() },
text::BlockStyle { bold: Some(true), ..Default::default() },
] {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator));
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator));
}
}
/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break
/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes
/// the bundled Thai face by choice or is completed by the matching one, instead of being refused
/// for a glyph no face has (#6272).
#[test]
fn thai_paragraphs_break_the_row_on_unicode_row_breaks() {
let rows = |doc: &Document| -> Vec<String> { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() };
let thai_face = pdfcraft_fonts::document_thai_font().is_some();
if !thai_face {
eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked");
}
for separator in ['\u{2028}', '\u{001C}'] {
let draft = format!("กข{separator}ค");
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE);
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
let style = BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator));
if !thai_face {
continue;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator));
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET");
let cmap = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar",
)));
doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| {
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
f.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
})
.unwrap();
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default())
.unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator));
}
}
+1 -1
View File
@@ -36,13 +36,13 @@ mod interp;
mod lines;
mod rewrite;
use blocks::paragraph_separator;
#[cfg(test)]
use blocks::wrap;
pub use blocks::{
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
replace_block, text_blocks,
};
use blocks::{paragraph_separator, row_break};
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
use editor::{builtin_thai_choice, layout_draft};
#[cfg(test)]
+9
View File
@@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
c.is_whitespace() && c != '\u{3000}'
}
/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14
/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the
/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a
/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font
/// for an outline no face has (#6272).
pub(crate) fn row_break(c: char) -> bool {
matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}')
}
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
/// those pieces join without a space. Every white-space character is written as one space, so
+7 -7
View File
@@ -1,4 +1,4 @@
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap};
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
use super::fallback::{
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family,
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
@@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet<
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
let text = text.replace(['\n', '\r'], " ");
let text = text.replace(row_break, " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
target.ensure_editable()?;
@@ -240,7 +240,7 @@ fn choose_rewrite_path(
}
let visual_indic = members.iter().any(|line| line.visual_order);
let has_special_text = text.is_some_and(|draft| {
draft.contains(['\n', '\r'])
draft.contains(row_break)
|| pdfcraft_fonts::source_shaping::required(draft)
|| !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty()
});
@@ -259,14 +259,14 @@ fn choose_rewrite_path(
}
/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed
/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are
/// breaks too; other white space is written as one space.
/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break
/// too; other white space is written as one space.
fn paragraph_text(text: &str) -> String {
text.replace("\r\n", "\n")
.chars()
.map(|c| match c {
'\r' => '\n',
c if c != '\n' && paragraph_separator(c) => ' ',
c if row_break(c) => '\n',
c if paragraph_separator(c) => ' ',
c => c,
})
.collect()
+3 -3
View File
@@ -3,9 +3,9 @@
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font,
source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph,
union, write_type3_glyphs,
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font,
same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces,
type3_glyph, union, write_type3_glyphs,
};
#[path = "text_regions/grouping.rs"]
+21 -14
View File
@@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect};
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program,
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program,
source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs,
};
@@ -181,8 +181,8 @@ impl CharacterPreparation<'_> {
"the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists"
))
})?;
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
if !covers_rows(face.bytes, span) {
let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
return Err(EditError::Invalid(match missing {
Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)),
None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family),
@@ -251,12 +251,13 @@ impl CharacterPreparation<'_> {
};
let start = range.start;
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
if ch == '\n' {
chars.push((ch, index, source.font.clone()));
continue;
}
if ch == '\r' {
let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
// A row break moves to the next row instead of asking a font for a glyph. The CR
// of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break
// is written as `\n`. Either way it keeps its place among the draft's characters,
// which rows are counted in.
if row_break(ch) {
let crlf = ch == '\r'
&& start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone()));
continue;
}
@@ -923,7 +924,7 @@ fn legacy_thai_font(
if !has_thai_source_face(cache, &family) {
return Ok(None);
}
let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty());
let rows = || span.split(row_break).filter(|row| !row.is_empty());
for row in rows() {
if let Err(error) = shape_existing_thai(cache, &source.font, row) {
// Not in the face's existing codes. A complete program of the face that shapes every
@@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
}
if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|| !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
|| !pdfcraft_fonts::embedding::covers(face.bytes, span)
|| !covers_rows(face.bytes, span)
{
return false;
}
@@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
&& pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| {
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
&& pdfcraft_fonts::embedding::covers(&bytes, span)
&& span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
&& covers_rows(&bytes, span)
&& span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
})
})
}
/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next
/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF.
fn covers_rows(bytes: &[u8], span: &str) -> bool {
span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row))
}
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
if text.len() > 262_144 || text.contains(['\n', '\r']) {
if text.len() > 262_144 || text.contains(row_break) {
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
}
let clusters =
+11 -2
View File
@@ -32,7 +32,8 @@ pub struct Document {
pub dirty: bool,
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
pub(crate) generation: u64,
/// The generation last handed out by `autosave_snapshots`.
/// The generation last handed out by `autosave_snapshots`, or another one when storing that
/// snapshot failed (`autosave_failed`), so it is due again.
pub(crate) snapshot_generation: u64,
/// Why the document cannot be edited (e.g. encryption), if so.
pub read_only_reason: Option<String>,
@@ -727,6 +728,14 @@ pub(crate) fn write_display_copy(
}
}
/// `doc` as the reader shows it: comments without an appearance stream drawn
/// (`pdfcraft_annot::with_missing_appearances`) and form fields drawn from their values where the
/// file leaves that to the viewer. `None` when `doc` needs neither. The vector print draws from
/// this copy too, so what prints is what the page shows (#371, #3475 draw them for display only).
pub(crate) fn as_displayed(doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
with_field_appearances(pdfcraft_annot::with_missing_appearances(doc), doc)
}
/// Carry the marks across a revalidation that leaves the display copy alone, when it may: only
/// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the
/// copy has to be drawn again.
@@ -778,7 +787,7 @@ pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec
let cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).ok()?;
let mut signatures = signatures_of(&cos, &bytes, &TrustStore::default(), &pdfcraft_sign::DigestCache::default());
let layered = pdfcraft_sign::has_status_layers(&cos, &signatures);
let (mut display, added) = match with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos) {
let (mut display, added) = match as_displayed(&cos) {
Some(display) => (display, true),
None if layered => (cos, false),
None => return None,
+5 -1
View File
@@ -95,7 +95,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. } => Scope::Content,
| Edit::RemoveMarks { .. }
// Recognized text is an invisible text layer stamped on its page, with its font in the
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
// a large file.
| Edit::AddOcrText { .. } => Scope::Content,
Edit::Batch { edits, .. } => {
let mut scopes = edits.iter().map(scope_of);
let first = scopes.next().unwrap_or(Scope::Full);
+2
View File
@@ -246,6 +246,7 @@ impl Scripts for JsRunner {
self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult {
rc: true,
value: value.to_string(),
value_set: false,
changes: Vec::new(),
message: None,
})
@@ -282,6 +283,7 @@ impl JsRunner {
Some(ScriptResult {
rc: o.rc,
value: o.value.clone(),
value_set: o.value_set,
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
})
+4 -1
View File
@@ -161,7 +161,10 @@ pub use session::Session;
pub use sources::{CombineSource, Combined, combine_source_check, source_page_count};
use sources::{open_source, open_source_with};
#[cfg(test)]
use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts};
use test_hooks::{
check_refresh_panic, panic_on_refresh, record_full_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts,
secondary_scan_counts, take_full_refreshes,
};
use xfa_form::{
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state,
undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets,
+68 -2
View File
@@ -971,10 +971,23 @@ impl Session {
pub fn ocr_job(&self, id: DocId, pages: &[usize], settings: OcrSettings) -> Option<OcrJob> {
let doc = self.get(id)?;
let all = doc.info.pages.len();
let pages: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() };
let mut seen = vec![false; all];
let pages: Vec<usize> = if pages.is_empty() {
seen.fill(true);
(0..all).collect()
} else {
let mut deduped = Vec::new();
for &p in pages {
if seen.get(p) == Some(&false) {
seen[p] = true;
deduped.push(p);
}
}
deduped
};
let native = (0..all)
.map(|p| {
if !pages.contains(&p) {
if seen.get(p) != Some(&true) {
return None;
}
doc.page_images(p)
@@ -2234,6 +2247,59 @@ mod tests {
assert_eq!(page, 0);
}
/// A scan selected explicitly is rendered at its native scale, like the same page in an
/// all-pages job: selecting pages must not lose the native-resolution lookup and render
/// the scan above its own resolution at a higher chosen dpi.
#[test]
fn selected_scan_page_uses_native_scale_like_all_pages() {
let mut s = Session::new();
let page = export::encode_png(2480, 3508, &vec![255; (2480 * 3508 * 4) as usize]).unwrap();
let bytes = s.create_from_images(&[("a4.png".into(), page)]).unwrap();
let id = s.open("scan.pdf", None, bytes, None).unwrap();
let settings = OcrSettings { dpi: 300.0, ..Default::default() };
let all = s.ocr_job(id, &[], settings.clone()).unwrap();
let selected = s.ocr_job(id, &[0], settings).unwrap();
assert_eq!(selected.pages, vec![0]);
let wanted = 300.0 / 72.0;
assert!(all.render_scale(0) < wanted, "native resolution limits the all-pages job: {}", all.render_scale(0));
assert!(
(selected.render_scale(0) - all.render_scale(0)).abs() < 1e-4,
"selected page {} vs all-pages {}",
selected.render_scale(0),
all.render_scale(0)
);
}
/// A job requested with the same page twice carries each page once: a duplicate would
/// otherwise be recognized twice.
#[test]
fn ocr_job_deduplicates_requested_pages() {
let mut s = Session::new();
let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap();
let job = s.ocr_job(id, &[2, 0, 2, 1, 0, 2], OcrSettings::default()).unwrap();
assert_eq!(job.pages, vec![2, 0, 1], "each requested page appears once, in request order");
}
#[test]
fn ocr_job_bounds_page_vector_reservation_by_page_count() {
let mut s = Session::new();
let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap();
let requested = vec![999usize; 1024];
let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap();
assert!(job.pages.is_empty());
assert!(job.pages.capacity() <= 3, "reservation {} exceeds the 3 pages that can survive deduplication", job.pages.capacity());
}
#[test]
fn ocr_job_does_not_reserve_capacity_for_invalid_pages() {
let mut s = Session::new();
let id = s.open("t.pdf", None, Arc::new(fixture(1024)), None).unwrap();
let requested = vec![4096usize; 1024];
let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap();
assert!(job.pages.is_empty());
assert_eq!(job.pages.capacity(), 0, "no valid pages were found, so no capacity should be reserved");
}
/// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel
/// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution
/// letter page is still never shrunk below one pixel per point.
+18 -9
View File
@@ -12,11 +12,12 @@ use pdfcraft_cos::{SaveOptions, write_full};
use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize};
/// Where an optimization is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OptimizeStage {
/// Removing the user data chosen under Discard User Data.
#[default]
/// Removing the user data chosen under Discard User Data, if any was.
Discarding,
/// Reading what the pages draw: `done` of `total` read.
Reading { done: usize, total: usize },
/// Merging the fragments of a page picture into one image.
Fragments,
/// About to process image `done` (0-based) of `total`.
@@ -29,14 +30,21 @@ pub enum OptimizeStage {
Writing,
}
impl Default for OptimizeStage {
fn default() -> Self {
OptimizeStage::Reading { done: 0, total: 0 }
}
}
impl OptimizeStage {
/// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are
/// small fixed steps.
/// How far along the whole run is, from 0 to 1, weighed by where the time goes: reading pages
/// and pictures. The rest are small fixed steps.
pub fn fraction(self) -> f32 {
let part = |done: usize, total: usize| if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 };
match self {
OptimizeStage::Discarding => 0.0,
OptimizeStage::Fragments => 0.03,
OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 },
OptimizeStage::Discarding | OptimizeStage::Fragments => 0.0,
OptimizeStage::Reading { done, total } => 0.25 * if total == 0 { 0.0 } else { part(done, total) },
OptimizeStage::Images { done, total } => 0.25 + 0.55 * part(done, total),
OptimizeStage::CleaningUp => 0.8,
OptimizeStage::Merging => 0.85,
OptimizeStage::Writing => 0.9,
@@ -91,10 +99,10 @@ impl OptimizeJob {
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
step(OptimizeStage::Discarding)?;
let discarded = if discard.is_empty() {
Vec::new()
} else {
step(OptimizeStage::Discarding)?;
// Every ticked category, as `remove_hidden` decides between them: with form fields
// ticked, discarding hidden text also takes the hidden text the flattened appearances
// bring onto the page, which no scan before flattening counts. It refuses when its scan
@@ -112,6 +120,7 @@ impl OptimizeJob {
};
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
step(match s {
optimize::Stage::Reading { done, total } => OptimizeStage::Reading { done, total },
optimize::Stage::Fragments => OptimizeStage::Fragments,
optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total },
optimize::Stage::CleanUp => OptimizeStage::CleaningUp,
+2
View File
@@ -515,6 +515,8 @@ impl Session {
}
fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> {
#[cfg(test)]
crate::record_full_refresh();
// A full refresh may follow structural or annotation changes: cached appearances
// may be stale, so they are rebuilt below.
if let Some(editor) = doc.editor.as_mut() {
+7
View File
@@ -164,6 +164,13 @@ impl Session {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
// Comments and fields without an appearance print as the page shows them (and as Print
// as Image already printed them): the vector print drew only appearance streams, so a
// text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an
// annotation with the Print flag is printed.
if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() {
cos = displayed;
}
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
// action) has changed one, the print copy's default configuration says which are on, so a
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
+9
View File
@@ -217,6 +217,15 @@ impl Session {
out
}
/// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the
/// document is due again, so the next autosave tries it again.
pub fn autosave_failed(&mut self, id: DocId) {
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
// Any generation but the current one leaves it due.
d.snapshot_generation = d.generation.wrapping_sub(1);
}
}
/// Mark a document opened from a recovery file: it has unsaved changes and belongs at
/// `path` (where Save writes), as when the session ended.
pub fn mark_recovered(&mut self, id: DocId, path: Option<String>) {
+14
View File
@@ -40,3 +40,17 @@ pub(crate) fn record_link_scan() {
counts.set((markers, links + 1));
});
}
thread_local! {
static FULL_REFRESHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A whole-document refresh (re-inspection) ran on this thread.
pub(crate) fn record_full_refresh() {
FULL_REFRESHES.with(|n| n.set(n.get() + 1));
}
/// How many whole-document refreshes ran on this thread since the last call, and starts over.
pub(crate) fn take_full_refreshes() -> usize {
FULL_REFRESHES.with(|n| n.replace(0))
}
+163 -1
View File
@@ -1779,6 +1779,45 @@ fn low_resolution_printing_prints_only_the_chosen_content() {
}
}
/// A comment and a field the file leaves without an appearance print as the page shows them:
/// a text box (`/FreeText` with only `/Contents` and `/DA`) and a check box set on with
/// `/NeedAppearances`. The vector print drew only appearance streams, so both were on screen and
/// in Print as Image but not on paper (pdf.js `bug1865341`, `freetext_print_no_appearance`).
/// ISO 32000-2 §12.5.3: an annotation with the Print flag is printed.
#[test]
fn comments_and_fields_without_an_appearance_print_as_shown() {
let content = "";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >> >> 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 /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Annot /Subtype /FreeText /F 4 /P 3 0 R /Rect [10 120 190 190] /DA (/Helv 40 Tf 0 g) /Contents (WWWW) >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (Box) /V /Yes /AS /Yes /F 4 /P 3 0 R /Rect [10 10 110 110] /MK << /CA (8) >> >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
);
let mut s = Session::new();
let id = s.open("bare.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap();
let settings =
print::Settings { pages: vec![0], paper: (200.0, 200.0), layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() };
let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap());
assert_eq!((w, h), (200, 200));
// Dark pixels in the text box (top half) and in the check box (bottom half).
let dark = |y0: usize, y1: usize| {
(y0..y1)
.flat_map(|y| (0..w as usize).map(move |x| (x, y)))
.filter(|&(x, y)| rgba.get((y * w as usize + x) * 4).is_some_and(|&r| r < 128))
.count()
};
assert!(dark(10, 80) > 50, "the text box prints");
assert!(dark(90, 190) > 50, "the check box prints");
}
#[test]
fn the_permissions_password_unlocks_an_open_document() {
// Only a permissions password (#995, #1077): opens without asking. Changes are allowed,
@@ -3120,6 +3159,66 @@ fn setting_the_calculation_order_recalculates_and_undoes() {
assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_))));
}
/// A Calculate script that sets its own field through the field object, not `event.value`,
/// gives the field that value, text included; when it sets `event.value` as well, that wins.
#[test]
fn a_calculate_script_setting_its_own_field_object_keeps_the_value() {
let (mut s, id) = session_with(1);
for (i, name) in ["origin", "copy", "both"].into_iter().enumerate() {
let y = 250.0 - 40.0 * i as f64;
let kind = NewField::Text { multiline: false };
s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap();
}
let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) };
s.apply(id, script("copy", "var o = this.getField(\"origin\");\nthis.getField(\"copy\").value = o.value;")).unwrap();
s.apply(id, script("both", "this.getField(\"both\").value = \"field\";\nevent.value = this.getField(\"origin\").value + \"!\";")).unwrap();
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("abc".into()) }).unwrap();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned());
assert_eq!(value(&s, "copy").as_deref(), Some("abc"));
assert_eq!(value(&s, "both").as_deref(), Some("abc!"), "event.value decides when the script sets both");
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("12".into()) }).unwrap();
assert_eq!(value(&s, "copy").as_deref(), Some("12"));
}
/// A Calculate script that assigns `event.value` the value its field already holds keeps it, even
/// after writing another value through its field object; and a combo box a script moves to
/// another option selects that option in `/I` too.
#[test]
fn a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection() {
let (mut s, id) = session_with(1);
s.apply(
id,
Edit::AddField { page: 0, rect: [20.0, 250.0, 180.0, 272.0], kind: NewField::Text { multiline: false }, name: Some("origin".into()) },
)
.unwrap();
s.apply(id, Edit::AddField { page: 0, rect: [20.0, 210.0, 180.0, 232.0], kind: NewField::Text { multiline: false }, name: Some("kept".into()) })
.unwrap();
let kind = NewField::Combo { options: vec!["Small".into(), "Large".into()], editable: false };
s.apply(id, Edit::AddField { page: 0, rect: [20.0, 170.0, 180.0, 192.0], kind, name: Some("size".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "size".into(), value: FieldValue::Choice(vec!["Small".into()]) }).unwrap();
let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) };
s.apply(
id,
script(
"kept",
"var v = event.value;
this.getField(\"kept\").value = \"field\";
event.value = v;",
),
)
.unwrap();
s.apply(id, script("size", "event.value = this.getField(\"origin\").value > 5 ? \"Large\" : \"Small\";")).unwrap();
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("9".into()) }).unwrap();
assert!(s.take_js_output(id).errors.is_empty(), "both scripts ran to the end");
let doc = s.get(id).unwrap();
let value = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned());
assert_eq!(value("kept").as_deref(), None, "event.value, set to the field's own value, decides");
assert_eq!(value("size").as_deref(), Some("Large"));
let size = doc.form.iter().find(|f| f.name == "size").map(|f| f.obj).unwrap();
let selected = doc.editor.as_ref().unwrap().cos.get(size).as_dict().and_then(|d| d.get(b"I")).cloned();
assert_eq!(selected, Some(pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Int(1)])));
}
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
@@ -4122,7 +4221,9 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
})
.unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert_eq!(stages.first(), Some(&OptimizeStage::Discarding));
assert_eq!(stages.first(), Some(&OptimizeStage::Reading { done: 0, total: 2 }), "it says what it does first");
assert!(!stages.contains(&OptimizeStage::Discarding), "nothing was chosen to discard: {stages:?}");
assert!(stages.iter().any(|st| matches!(st, OptimizeStage::Images { .. })), "{stages:?}");
assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]);
let fractions: Vec<f32> = stages.iter().map(|st| st.fraction()).collect();
assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}");
@@ -4133,6 +4234,20 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
"no images: no division by zero"
);
// What the bar shows before anything is reported says nothing of user data either.
assert_eq!(OptimizeStage::default(), OptimizeStage::Reading { done: 0, total: 0 });
assert_eq!(OptimizeStage::default().fraction(), 0.0);
assert!(OptimizeStage::Reading { done: 2, total: 4 }.fraction() > 0.0, "reading pages moves the bar");
// Discarding says so when something is. This document has no metadata, which only stops it after
// the stage is reported.
let mut with_discard = Vec::new();
let _ = s.optimize_job(id, &settings, &[crate::Hidden::Metadata]).unwrap().run(|st| {
with_discard.push(st);
true
});
assert_eq!(with_discard.first(), Some(&OptimizeStage::Discarding));
// Cancelled at the merge: nothing is written, the open document is untouched.
let before = s.get(id).unwrap().bytes.clone();
let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging);
@@ -5234,6 +5349,7 @@ fn sign_prepared_with(
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
value(&mut v);
let certifies = v.get(b"Reference").is_some();
let sig = doc.add(Object::Dict(v));
// The field by name, never by number: a full save (encryption, repairs) is
// free to renumber objects, and a hardcoded reference would stamp the value
@@ -5243,6 +5359,12 @@ fn sign_prepared_with(
let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields");
let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field");
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
if certifies {
// A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2).
let mut perms = Dict::new();
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap();
}
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() };
let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap();
let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]);
@@ -9089,6 +9211,46 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both");
}
/// Recognized text is an invisible text layer on its page, so applying it refreshes like any
/// other content edit, not by re-inspecting the whole document (#6310): on a 567-page file with
/// 60,102 font resources one word took 2.4 s, the full refresh nearly all of it. The page tree,
/// comments, fields, links and marks stay as they were; the words are on their page, the font
/// list picks up the OCR font on `refresh_fonts`, undo and redo take the words out and back
/// without a full refresh either, and a save keeps them.
#[test]
fn ocr_words_refresh_only_the_page_content_they_add() {
use crate::ocr::{OcrPage, PlacedWord};
let (mut s, id) = session_with(3);
let words_on = |s: &Session, page: usize| s.get(id).unwrap().text_lines(page).iter().map(|l| l.text.trim().to_string()).collect::<Vec<_>>();
let before = s.get(id).unwrap();
let view = format!("{:?}", (&before.info.pages, &before.info.annotations, &before.info.fields, &before.links, before.marks()));
let others = (words_on(&s, 0), words_on(&s, 2));
assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")));
take_full_refreshes();
let word =
PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0], confidence: None, source: "ocrs".into() };
assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() }]).unwrap(), 1);
assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document");
let after = s.get(id).unwrap();
assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view);
assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "{:?}", words_on(&s, 1));
assert_eq!((words_on(&s, 0), words_on(&s, 2)), others, "the other pages are untouched");
s.refresh_fonts(id);
let d = s.get(id).unwrap();
let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap();
assert_eq!(format!("{:?}", d.info.fonts), format!("{:?}", full.fonts), "the font list is what a full inspection reads");
assert_eq!(d.info.file_size, full.file_size);
s.undo(id).unwrap();
assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")), "undo takes the words out");
s.redo(id).unwrap();
assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "redo puts them back");
assert_eq!(take_full_refreshes(), 0, "undo and redo refresh the content alone too");
let saved = s.save_bytes(id).unwrap();
let mut reopened = Session::new();
let rid = reopened.open("saved.pdf", None, saved, None).unwrap();
assert!(reopened.get(rid).unwrap().text_lines(1).iter().any(|l| l.text.trim() == "HELLO"), "the save keeps the words");
}
/// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list
/// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it.
#[test]
+119
View File
@@ -110,6 +110,125 @@ mod tests {
data
}
/// The JBIG2 file header's ID string (ISO 14492 D.4.1).
const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A];
/// `embedded`, segments as [`encode_jbig2_generic`] writes them (11-byte headers: no references,
/// a one-byte page association), behind a file header with `flags` and the page count `pages`,
/// in the organization `flags` declares (D.4.2, bit 0): 1 sequential, each segment's header
/// before its data (D.1); 0 random access, every header first, the last an end of file segment
/// numbered 2, which tells where they end, then every segment's data in the same order (D.2).
fn with_file_header(embedded: &[u8], flags: u8, pages: Option<u32>) -> Vec<u8> {
let mut data = FILE_HEADER_ID.to_vec();
data.push(flags);
data.extend(pages.map(u32::to_be_bytes).into_iter().flatten());
if flags & 0x01 != 0 {
data.extend_from_slice(embedded);
return data;
}
let (mut headers, mut bodies, mut rest) = (Vec::new(), Vec::new(), embedded);
while !rest.is_empty() {
let (header, after) = rest.split_at(11);
assert_eq!(header[5], 0, "no references");
let (body, after) = after.split_at(u32::from_be_bytes(header[7..11].try_into().unwrap()) as usize);
headers.extend_from_slice(header);
bodies.extend_from_slice(body);
rest = after;
}
headers.extend_from_slice(&[0, 0, 0, 2, 51, 0, 1, 0, 0, 0, 0]);
data.extend(headers);
data.extend(bodies);
data
}
/// A stream that starts with a JBIG2 file header (ISO 14492 D.4), which some producers write
/// into `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded organization there,
/// decodes past it as Acrobat and PDFBox draw it (hayro-jbig2 patch 17), in either organization
/// the header declares, with the page count and without it. "Bit 0: File organisation type. If
/// this bit is 0, the file uses the random-access organisation. If this bit is 1, the file uses
/// the sequential organisation." (D.4.2) A header with its reserved flag bits set is an error,
/// not a panic.
#[test]
fn a_stream_with_a_jbig2_file_header_decodes_past_it() {
let (w, h) = (203u32, 41u32);
let bits = noise(w, h);
let max = bits.len();
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
let decode = |flags: u8, pages: Option<u32>| decode_jbig2(&with_file_header(&embedded, flags, pages), None, w, h, max);
// Sequential organization (bit 0 is 1), one page.
assert_eq!(decode(0x01, Some(1)).unwrap(), bits, "sequential, page count given");
// Sequential, number of pages unknown (bit 1 is 1): no count follows (D.4.3).
assert_eq!(decode(0x03, None).unwrap(), bits, "sequential, page count unknown");
// Random-access organization (bit 0 is 0): the segment headers, then their data.
assert_ne!(with_file_header(&embedded, 0x00, Some(1))[13..], embedded[..], "laid out otherwise");
assert_eq!(decode(0x00, Some(1)).unwrap(), bits, "random access, page count given");
assert_eq!(decode(0x02, None).unwrap(), bits, "random access, page count unknown");
assert!(decode(0x11, Some(1)).is_err(), "reserved bits");
}
/// A file header cut short, or the segments after it, is an error and not a panic: the ID string
/// alone or in part, a header whose flags promise a page count with fewer than its four bytes,
/// a header with no segments after it, and segments cut anywhere in either organization. No
/// prefix of a whole stream decodes to its samples.
#[test]
fn a_truncated_jbig2_file_header_is_an_error() {
let (w, h) = (24u32, 8u32);
let bits = noise(w, h);
let max = bits.len();
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
let decode = |data: &[u8]| decode_jbig2(data, None, w, h, max);
assert!(decode(&FILE_HEADER_ID[..5]).is_err(), "part of the ID string");
assert!(decode(&FILE_HEADER_ID).is_err(), "the ID string, no flags");
for count in 0..4 {
let mut data = FILE_HEADER_ID.to_vec();
data.push(0x01);
data.extend_from_slice(&1u32.to_be_bytes()[..count]);
assert!(decode(&data).is_err(), "{count} bytes of the page count");
}
for (flags, pages) in [(0x01, Some(1)), (0x03, None), (0x00, Some(1)), (0x02, None)] {
let whole = with_file_header(&embedded, flags, pages);
let header = 9 + if pages.is_some() { 4 } else { 0 };
assert!(decode(&whole[..header]).is_err(), "flags {flags:#04x}: the header alone");
assert!(decode(&whole[..whole.len() - 1]).is_err(), "flags {flags:#04x}: the last byte cut");
for end in 0..whole.len() {
assert_ne!(decode(&whole[..end]).ok(), Some(bits.clone()), "flags {flags:#04x}: {end} bytes");
}
assert_eq!(decode(&whole).unwrap(), bits, "flags {flags:#04x}: whole");
}
}
/// The segments after a file header count toward hayro-jbig2's cap on the segments of a stream
/// together with its `/JBIG2Globals`, in either organization (patches 8 and 17): globals of
/// extension segments, which decode to nothing, fill the cap to the image's segments, and one
/// more is refused.
#[test]
fn segments_after_a_jbig2_file_header_count_with_the_globals() {
/// hayro-jbig2's `MAX_SEGMENTS`, the most segments of a stream with its globals.
const MAX_SEGMENTS: u32 = 1 << 20;
let (w, h) = (24u32, 8u32);
let bits = noise(w, h);
let max = bits.len();
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
// Numbered past the image's segments (0 to 2), of no page, with no data.
let globals = |count: u32| -> Vec<u8> {
(16..16 + count)
.flat_map(|number| {
let mut segment = number.to_be_bytes().to_vec();
segment.extend_from_slice(&[62, 0, 0, 0, 0, 0, 0]);
segment
})
.collect()
};
// The image's page information and region, and in random access its end of file segment.
for (flags, organization, image) in [(0x01, "sequential", 2), (0x00, "random access", 3)] {
let data = with_file_header(&embedded, flags, Some(1));
let (at_cap, past_cap) = (globals(MAX_SEGMENTS - image), globals(MAX_SEGMENTS - image + 1));
assert_eq!(decode_jbig2(&data, Some(&at_cap), w, h, max).unwrap(), bits, "{organization}: at the cap");
let refused = decode_jbig2(&data, Some(&past_cap), w, h, max).unwrap_err();
assert!(refused.to_string().contains("too many segments"), "{organization}: {refused}");
}
}
/// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic
/// region of noise, and symbols for letters repeated across the page. With no rows asked for,
/// nothing is decoded.
+118 -9
View File
@@ -356,6 +356,94 @@ const MAX_CMAP_RANGES: usize = 1 << 16;
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
/// one is not decoded at all.
const MAX_CMAP_BYTES: usize = 16 << 20;
/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes.
const MAX_TYPE3_GLYPHS: usize = 256;
/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped.
const MAX_TYPE3_GLYPH_BYTES: usize = 64 << 10;
/// Decoded bytes all of one font's glyph descriptions may take together.
const MAX_TYPE3_FONT_BYTES: usize = 4 << 20;
/// Farthest a Type 3 glyph box reaches from the baseline, in text space per unit of font size. A
/// bitmap font drawn in page units at `0.01 Tf` is a few thousand; past this a box is garbage, and
/// clamping it keeps the run boxes built from it finite.
const MAX_TYPE3_EXTENT: f64 = 1e6;
/// The union of the boxes a Type 3 font's glyph descriptions declare with `d1` (ISO 32000-2
/// §9.6.4, Table 111), in glyph space, and whether every description chosen was read; `None` when
/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences`
/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a
/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`]
/// descriptions within [`MAX_TYPE3_FONT_BYTES`].
fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
let procs = dict(doc, font.get(b"CharProcs"))?;
let mut names: Vec<&[u8]> = Vec::new();
// Whether every glyph that can be drawn is read; if not, the union may be short of them.
let mut complete = true;
let encoding = dict(doc, font.get(b"Encoding"));
// A base encoding reaches glyphs `/Differences` doesn't name, which Type 3 doesn't provide for (its
// `/Differences` "shall specify the complete character encoding", Table 110): those boxes go unread.
if encoding.as_ref().is_some_and(|e| e.contains(b"BaseEncoding")) {
complete = false;
}
let diffs = encoding.as_ref().and_then(|e| e.get(b"Differences")).map(|d| doc.resolve(d));
for name in diffs.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_name) {
if procs.get(name).is_none() || names.contains(&name) {
continue;
}
if names.len() >= MAX_TYPE3_GLYPHS {
complete = false;
break;
}
names.push(name);
}
let chosen: Vec<&Object> = if names.is_empty() {
complete = procs.len() <= MAX_TYPE3_GLYPHS;
procs.iter().take(MAX_TYPE3_GLYPHS).map(|(_, proc)| proc).collect()
} else {
names.iter().filter_map(|n| procs.get(n)).collect()
};
let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0);
let mut union: Option<[f64; 4]> = None;
for proc in chosen {
let Object::Stream(s) = &*doc.resolve(proc) else { continue };
let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else {
if budget.remaining() == 0 {
// The rest go unread too.
complete = false;
break;
}
continue;
};
if let Some([llx, lly, urx, ury]) = d1_box(&data) {
let r = [llx.min(urx), lly.min(ury), llx.max(urx), lly.max(ury)];
union = Some(union.map_or(r, |u| [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])]));
}
}
union.map(|u| (u, complete))
}
/// The glyph box `llx lly urx ury` of a glyph description that begins with `wx wy llx lly urx ury
/// d1`; `None` for `d0` or anything else first.
fn d1_box(data: &[u8]) -> Option<[f64; 4]> {
let mut lexer = pdfcraft_cos::Lexer::new(data, 0);
let mut operands = [0.0; 6];
for slot in &mut operands {
lexer.skip_ws();
*slot = std::str::from_utf8(lexer.token()).ok()?.parse::<f64>().ok().filter(|v| v.is_finite())?;
}
lexer.skip_ws();
(lexer.token() == b"d1").then(|| [operands[2], operands[3], operands[4], operands[5]])
}
/// The lowest and highest text-space y of the glyph-space box `r` under a font matrix whose
/// y column is `b`, `d`, `f` (§9.2.4): a flipped matrix (`d < 0`) turns the box over. `None`
/// when the box has no height in text space or the numbers overflow; clamped to
/// [`MAX_TYPE3_EXTENT`].
fn text_y_extent(r: [f64; 4], b: f64, d: f64, f: f64) -> Option<(f64, f64)> {
let ys = [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].map(|(x, y)| b * x + d * y + f);
let lo = ys.iter().copied().fold(f64::INFINITY, f64::min);
let hi = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max);
(lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo.max(-MAX_TYPE3_EXTENT), hi.min(MAX_TYPE3_EXTENT)))
}
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
@@ -754,6 +842,8 @@ impl Metrics {
m.std14 = None;
let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec();
let descriptor;
// A Type 3 font measured from its glyph descriptions' `d1` boxes or its `/FontBBox` (see below).
let mut type3_measured = false;
if subtype == b"Type0" {
m.composite = true;
m.default = 1000.0;
@@ -814,13 +904,28 @@ impl Metrics {
descriptor = dict(doc, font.get(b"FontDescriptor"));
m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0);
if subtype == b"Type3" {
let fm = nums(doc, font.get(b"FontMatrix"));
if fm.len() == 6 {
m.scale = fm[0];
let bbox = nums(doc, font.get(b"FontBBox"));
if bbox.len() == 4 && fm[3] != 0.0 {
m.ascent = (bbox[3] * fm[3]).max(0.5);
m.descent = (bbox[1] * fm[3]).min(-0.1);
if let [a, b, _, d, _, f] = nums(doc, font.get(b"FontMatrix"))[..] {
m.scale = a;
// The glyphs' own boxes first: a bitmap font's `/FontBBox` is often all zero
// or one unit high, while each `d1` declares the box its glyph fills.
// Each `d1` box "shall be correct" (Table 111), so their union is used as
// declared, only widened to reach the baseline: a font of small glyphs gets a
// small box. A `/FontBBox` is often one unit high, so it keeps the 0.5 / 0.1 em
// minimum.
// A union cut short by a limit may miss glyphs that are drawn, so a nonzero
// `/FontBBox` comes first then; all zero, the partial union is still better
// than no box. A box with no height in text space gives way to the next.
let glyphs = type3_glyph_box(doc, font);
// All zero: no assumption about glyph sizes (Table 110).
let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0));
let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0));
let candidates = [union(true), bbox.map(|r| (r, 0.5, -0.1)), union(false)];
if let Some(((lo, hi), min_ascent, max_descent)) =
candidates.into_iter().flatten().find_map(|(r, up, down)| text_y_extent(r, b, d, f).map(|e| (e, up, down)))
{
m.ascent = hi.max(min_ascent);
m.descent = lo.min(max_descent);
type3_measured = true;
}
}
} else if m.widths.is_empty() {
@@ -840,11 +945,15 @@ impl Metrics {
let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64());
let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64());
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
if ascent.is_some() || subtype != b"Type3" {
// A Type 3 font measured from its glyphs' `d1` boxes or its `/FontBBox` keeps those:
// they are in glyph space and go through its font matrix, while a descriptor's
// /Ascent and /Descent are optional for Type 3 (Table 120) and read here as 1000-unit
// values, whatever the matrix.
if (ascent.is_some() && !type3_measured) || subtype != b"Type3" {
let a = ascent.unwrap_or(0.0) / 1000.0;
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
}
if descent.is_some() || subtype != b"Type3" {
if (descent.is_some() && !type3_measured) || subtype != b"Type3" {
let de = descent.unwrap_or(0.0) / 1000.0;
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
}
+222
View File
@@ -265,6 +265,228 @@ fn type3_descriptor_names_the_font_without_changing_its_metrics() {
assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence");
}
/// A Type 3 font with `matrix`, `bbox` and one glyph description per entry of `procs`.
fn type3(doc: &mut Document, matrix: [f64; 6], bbox: [i64; 4], procs: &[&str]) -> Dict {
let mut charprocs = Dict::new();
for (i, proc) in procs.iter().enumerate() {
let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), proc.as_bytes())));
charprocs.set(format!("g{i}").into_bytes(), Object::Ref(glyph));
}
font(
doc,
vec![
("Subtype", Object::name("Type3")),
("FontMatrix", Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect())),
("FontBBox", Object::Array(bbox.iter().map(|&v| Object::Int(v)).collect())),
("CharProcs", Object::Dict(charprocs)),
],
)
}
/// ISO 32000-2 §9.6.4: a bitmap font whose `/FontBBox` is all zero (no assumption about glyph
/// sizes) is as high as the boxes its glyphs declare with `d1`, not a fixed 0.6 of the size.
#[test]
fn a_type3_font_is_as_high_as_its_d1_glyph_boxes() {
let mut doc = Document::new_empty();
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
let f = type3(&mut doc, unit, [0, 0, 0, 0], &["100 0 0 0 100 100 d1\nq 100 0 0 100 0 0 cm BI /W 1 /H 1 /IM true ID \x00 EI Q"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (100.0, 0.0));
// Shown at `0.12 Tf`, the glyph is 12 points high.
assert!((m.ascent * 0.12 - 12.0).abs() < 1e-9);
// The union of every glyph's box, leading comments and white space allowed; `d0` glyphs
// declare none, and a non-numeric operand isn't a box.
let f = type3(&mut doc, unit, [0, 0, 0, 0], &["% c\n 50 0 0 -20 40 60 d1", "60 0 d0", "60 0 0 -30 40 x d1", "60 0 0 0 40 80 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (80.0, -20.0));
// A wrong but non-zero `/FontBBox` (one unit high) gives way to the glyphs.
let f = type3(&mut doc, unit, [0, 0, 1, 1], &["100 0 0 -10 100 90 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (90.0, -10.0));
// No `d1` anywhere: the `/FontBBox` still decides, scaled by the matrix.
let f = type3(&mut doc, [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], [0, -300, 1000, 900], &["600 0 d0"]);
let m = Metrics::from_dict(&doc, &f);
assert!((m.ascent - 0.9).abs() < 1e-12 && (m.descent + 0.3).abs() < 1e-12, "{} {}", m.ascent, m.descent);
}
/// Small glyphs keep their small box: there is no 0.5 em / 0.1 em minimum for `d1` boxes, which
/// made a 58-point line of 0.15 em glyphs 35 points high. The shape of a scanned-text bitmap
/// font: matrix 0.0042, `/FontBBox` all zero, `d1` boxes from -8 to 33 units.
#[test]
fn small_type3_glyphs_keep_their_d1_height() {
let mut doc = Document::new_empty();
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1", "26 0 2 -8 24 28 d1", "13 0 0 0 1 1 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent);
// Glyphs that all sit above the baseline still reach down to it.
let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && m.descent == 0.0, "{} {}", m.ascent, m.descent);
}
/// A font matrix that turns glyph space over (`[1 0 0 -1 0 0]`, drawn under a flipped text
/// matrix) puts the top of the glyph box at text-space y -top: the box is as high as before,
/// upside down, not clamped to a sliver.
#[test]
fn a_flipped_type3_font_matrix_keeps_the_glyph_height() {
let mut doc = Document::new_empty();
let flipped = [1.0, 0.0, 0.0, -1.0, 0.0, 0.0];
let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &["37 0 2 -1 34 38 d1", "42 0 2 -18 40 38 d1", "46 0 3 -1 42 57 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (18.0, -57.0));
let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &[]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (18.0, -57.0), "the /FontBBox, turned over the same way");
}
/// Glyph descriptions are untrusted: unreadable ones, a box of infinities, a degenerate matrix
/// and a font with more glyphs than are read all leave finite metrics.
#[test]
fn type3_glyph_boxes_are_read_within_bounds() {
let mut doc = Document::new_empty();
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
for procs in [&["inf 0 0 -inf 1 inf d1"][..], &["d1"], &["1 2 3 d1"], &[""], &["1 0 0 0 1 0 d1"], &["1e308 0 -1e308 -1e308 1e308 1e308 d1"]] {
let f = type3(&mut doc, unit, [0, 0, 0, 0], procs);
let m = Metrics::from_dict(&doc, &f);
assert!(m.ascent > 0.0 && m.descent < 0.0, "{procs:?}");
assert!(m.ascent <= MAX_TYPE3_EXTENT && m.descent >= -MAX_TYPE3_EXTENT, "{procs:?}");
}
// A matrix with no y scale gives the box no height: the fallback metrics stand.
let f = type3(&mut doc, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0, 0, 0, 0], &["1 0 0 0 1 100 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (Metrics::fallback().ascent, Metrics::fallback().descent));
// Glyphs past the first MAX_TYPE3_GLYPHS are not read.
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS];
procs.push("1 0 0 0 1 1000 d1");
let f = type3(&mut doc, unit, [0, 0, 0, 0], &procs);
assert_eq!(Metrics::from_dict(&doc, &f).ascent, 10.0);
}
/// `/Differences` with the glyph names `names` from code 0, set on the Type 3 font `f`.
fn with_differences(f: &mut Dict, names: &[String]) {
let mut items = vec![Object::Int(0)];
items.extend(names.iter().map(|n| Object::name(n)));
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(items));
f.set(b"Encoding".to_vec(), Object::Dict(enc));
}
/// The glyph descriptions the encoding reaches are the ones read, since its `/Differences`
/// "shall specify the complete character encoding" (ISO 32000-2 Table 110): a drawn glyph past
/// the first [`MAX_TYPE3_GLYPHS`] descriptions still counts.
#[test]
fn type3_glyph_boxes_are_read_for_the_glyphs_the_encoding_reaches() {
let mut doc = Document::new_empty();
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS + 44];
procs.push("1 0 0 0 1 1000 d1");
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
with_differences(&mut f, &[format!("g{}", MAX_TYPE3_GLYPHS + 44), "g0".into()]);
assert_eq!(Metrics::from_dict(&doc, &f).ascent, 1000.0);
}
/// Byte limits on glyph descriptions: one larger than [`MAX_TYPE3_GLYPH_BYTES`] is skipped, its
/// `d1` box unread, and the rest are read; once the font's [`MAX_TYPE3_FONT_BYTES`] run out,
/// the boxes read so far may miss glyphs that are drawn, so a nonzero `/FontBBox` stands.
#[test]
fn type3_glyph_byte_limits_leave_honest_metrics() {
let mut doc = Document::new_empty();
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
let pad = |head: &str, len: usize| format!("{head}{}", " ".repeat(len.saturating_sub(head.len())));
let oversized = pad("1 0 0 0 1 1000 d1", MAX_TYPE3_GLYPH_BYTES + 100);
let procs = [oversized.as_str(), "1 0 0 0 1 3 d1"];
let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs);
with_differences(&mut f, &["g0".into(), "g1".into()]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (3.0, 0.0), "the oversized glyph is skipped, the other read");
let filler = pad("0 0 m", MAX_TYPE3_GLYPH_BYTES - 1000);
let fillers = MAX_TYPE3_FONT_BYTES / (MAX_TYPE3_GLYPH_BYTES - 1000) + 1;
let mut procs = vec!["1 0 0 0 1 1 d1"];
procs.extend(std::iter::repeat_n(filler.as_str(), fillers));
procs.push("1 0 0 0 1 1000 d1");
let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs);
let names: Vec<String> = (0..procs.len()).map(|i| format!("g{i}")).collect();
with_differences(&mut f, &names);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone");
}
/// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent`
/// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120).
#[test]
fn type3_d1_glyph_boxes_win_over_descriptor_metrics() {
let mut doc = Document::new_empty();
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
let mut f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 -8 9 33 d1"]);
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"Ascent".to_vec(), Object::Int(891));
descriptor.set(b"Descent".to_vec(), Object::Int(-216));
let reference = doc.add(Object::Dict(descriptor));
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
let m = Metrics::from_dict(&doc, &f);
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent);
}
/// A `/Differences` naming more distinct glyphs than [`MAX_TYPE3_GLYPHS`] leaves some unread, so
/// a nonzero `/FontBBox` stands over the boxes of those read.
#[test]
fn type3_glyphs_past_the_limit_leave_the_font_bbox_standing() {
let mut doc = Document::new_empty();
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS];
procs.push("1 0 0 0 1 1000 d1");
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &procs);
let names: Vec<String> = (0..procs.len()).map(|i| format!("g{i}")).collect();
with_differences(&mut f, &names);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
}
/// `d1` boxes with no height in text space (all flat) give way to a usable `/FontBBox`.
#[test]
fn flat_type3_glyph_boxes_give_way_to_the_font_bbox() {
let mut doc = Document::new_empty();
let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 5 1 5 d1"]);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
}
/// A Type 3 font measured from its `/FontBBox` keeps that box through its font matrix over a
/// descriptor's `/Ascent` and `/Descent`, which would be read as 1000-unit values.
#[test]
fn a_type3_font_bbox_wins_over_descriptor_metrics() {
let mut doc = Document::new_empty();
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
let mut f = type3(&mut doc, small, [0, -50, 100, 200], &[]);
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"Ascent".to_vec(), Object::Int(891));
descriptor.set(b"Descent".to_vec(), Object::Int(-216));
let reference = doc.add(Object::Dict(descriptor));
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
let m = Metrics::from_dict(&doc, &f);
// 200 and -50 units at 0.0042, kept at the 0.5 / -0.1 em floor.
assert!((m.ascent - 0.84).abs() < 1e-9 && (m.descent + 0.21).abs() < 1e-9, "{} {}", m.ascent, m.descent);
}
/// An `/Encoding` with a `/BaseEncoding` reaches glyphs its `/Differences` doesn't name, whose
/// boxes go unread, so a nonzero `/FontBBox` stands over the boxes of those named.
#[test]
fn a_type3_base_encoding_leaves_the_font_bbox_standing() {
let mut doc = Document::new_empty();
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 0 1 10 d1", "1 0 0 0 1 1000 d1"]);
let mut enc = Dict::new();
enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding"));
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0), Object::name("g0")]));
f.set(b"Encoding".to_vec(), Object::Dict(enc));
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
}
#[test]
fn simple_fonts_decode_and_encode_through_their_encoding() {
let mut doc = Document::new_empty();
+4
View File
@@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<
let num = |s: &String| make_number(s).unwrap_or(0.0);
match c {
Calculate::None => None,
// A product of no values is 0, as an empty sum, average, minimum or maximum is, not the
// empty product 1: a blank quantity × price row reads 0, and a total over the rows
// doesn't count it as 1. (The form's `values` leaves out a field that has no value.)
Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()),
Calculate::Simple { op, fields } => {
let xs: Vec<f64> = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect();
let v = match op {
+29
View File
@@ -360,6 +360,35 @@ fn dates_times_and_masks() {
assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err());
}
#[test]
fn a_calculation_over_fields_without_values_is_0_for_every_operation() {
// A blank row: neither field has a value, so the form's lookup returns none.
let vals = |name: &str| -> Vec<String> { if name == "a" { vec!["2".into()] } else { vec![] } };
let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals);
for op in CalcOp::ALL {
assert_eq!(simple(op, &["qty", "price"]), Some("0".into()), "{op:?} of empty fields");
assert_eq!(simple(op, &[]), Some("0".into()), "{op:?} of no fields");
}
assert_eq!(simple(CalcOp::Product, &["a", "qty"]), Some("2".into()), "a field with no value is left out");
}
/// A drop-down's blank option is often a single space, the value it starts with. That is a value
/// worth 0, so a row whose quantity is still blank reads 0 × price = 0. Read as "no value", it
/// would be left out and the row would read the price alone.
#[test]
fn a_blank_option_of_a_single_space_is_a_value_worth_0() {
let vals = |name: &str| -> Vec<String> {
match name {
"qty" => vec![" ".into()],
"price" => vec!["605".into()],
_ => vec![],
}
};
let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals);
assert_eq!(simple(CalcOp::Product, &["qty", "price"]), Some("0".into()));
assert_eq!(simple(CalcOp::Product, &["qty", "none"]), Some("0".into()), "a blank quantity on a row without a price");
}
#[test]
fn validation_and_calculation() {
let r = Validate::Range { min: Some(0.0), max: Some(100.0) };
+64 -13
View File
@@ -106,12 +106,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) {
parts.push(t);
}
cur = d.get(b"Parent").and_then(|p| p.as_ref());
cur = parent_of(doc, d);
}
parts.reverse();
(!parts.is_empty()).then(|| parts.join("."))
}
/// The field `d` names as its `/Parent`, when that is a dictionary. A reference to an object the
/// file doesn't hold reads as `null`, and a `null` entry is the same as an absent one (ISO
/// 32000-2 §7.3.9): the field is then a root, as if it named no parent.
fn parent_of(doc: &Document, d: &Dict) -> Option<ObjRef> {
d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some())
}
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
/// in all, however many actions name the same stream.
pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20;
@@ -331,13 +338,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
let af = acroform(doc);
let mut page_of = std::collections::HashMap::new();
let mut annot_index = std::collections::HashMap::new();
let mut by_parent: std::collections::HashMap<ObjRef, Vec<ObjRef>> = std::collections::HashMap::new();
let pages = page_refs(doc);
for (i, p) in pages.iter().enumerate() {
if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) {
for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() {
if let Some(r) = e.as_ref() {
page_of.entry(r).or_insert(i);
annot_index.entry(r).or_insert(k);
if let Some(r) = e.as_ref()
&& let std::collections::hash_map::Entry::Vacant(slot) = page_of.entry(r)
{
slot.insert(i);
annot_index.insert(r, k);
if let Some(parent) = widget_parent(doc, r) {
by_parent.entry(parent).or_default().push(r);
}
}
}
}
@@ -351,18 +364,24 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let listed_roots: Vec<ObjRef> = af
.as_ref()
.and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()))
.unwrap_or_default()
.iter()
.filter_map(Object::as_ref)
.collect();
let mut seen = Seen {
fields: std::collections::HashSet::new(),
widgets: std::collections::HashSet::new(),
roots: listed_roots.iter().copied().collect(),
path: Vec::new(),
by_parent,
scripts: script_budget(),
values: DecodeBudget::new(MAX_VALUE_BYTES, 0),
};
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
}
}
for r in listed_roots {
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
}
let listed = out.len();
adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out);
@@ -413,7 +432,7 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<Obj
let mut top = r;
let mut visited = std::collections::HashSet::from([r]);
loop {
let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent"));
let parent = doc.get(top).as_dict().and_then(|d| parent_of(doc, d));
let Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
@@ -433,10 +452,36 @@ struct Seen {
fields: std::collections::HashSet<ObjRef>,
/// A widget belongs to the first field that lists it, however often it is listed.
widgets: std::collections::HashSet<ObjRef>,
/// The fields `/Fields` lists: root fields, with no ancestors (ISO 32000-2 §12.7.3, Table 224).
roots: std::collections::HashSet<ObjRef>,
/// The fields being walked, outermost first, whose `/Kids` are being read.
path: Vec<ObjRef>,
/// The page widgets (no `/T`) that name each field as their `/Parent`, in page-annotation order.
by_parent: std::collections::HashMap<ObjRef, Vec<ObjRef>>,
scripts: DecodeBudget,
values: DecodeBudget,
}
/// The field a page widget `r` names as its `/Parent`, when `r` is a widget and not a field of its
/// own (no `/T`).
fn widget_parent(doc: &Document, r: ObjRef) -> Option<ObjRef> {
let obj = doc.get(r);
let d = obj.as_dict()?;
if d.name(b"Subtype") != Some(b"Widget") || d.contains(b"T") {
return None;
}
d.reference(b"Parent")
}
/// Whether `k`, listed in the `/Kids` of field `r`, is not a child of `r` after all: `r` itself or
/// a field `r` descends from (a loop), or a root field (one `/Fields` lists) that doesn't name `r`
/// as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), and a
/// child names its parent (§12.7.4.1, Table 226): only the `/Kids` entry says otherwise, so the
/// field is read as the root it is, and `r` keeps its other kids.
fn not_a_child(doc: &Document, seen: &Seen, r: ObjRef, k: ObjRef) -> bool {
k == r || seen.path.contains(&k) || (seen.roots.contains(&k) && doc.get(k).as_dict().and_then(|d| d.reference(b"Parent")) != Some(r))
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
fn walk(
@@ -483,11 +528,14 @@ fn walk(
let kids: Vec<ObjRef> =
d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
// Kids with /T are fields; kids without are this field's widgets.
let field_kids: Vec<ObjRef> = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect();
let (field_kids, kids): (Vec<ObjRef>, Vec<ObjRef>) = kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T")));
let field_kids: Vec<ObjRef> = field_kids.into_iter().filter(|k| !not_a_child(doc, seen, r, *k)).collect();
if !field_kids.is_empty() {
seen.path.push(r);
for k in field_kids {
walk(doc, k, &inh, page_of, seen, out, depth + 1);
}
seen.path.pop();
return;
}
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
@@ -503,7 +551,10 @@ fn walk(
Some(b"Sig") => FieldKind::Signature,
_ => return,
};
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
// A child names its parent (ISO 32000-2 §12.7.4.1, Table 226): a page widget naming this field
// is one of its widgets even when its `/Kids` leave it out, as MuPDF and PDFium read it.
let widget_refs: Vec<ObjRef> =
if d.contains(b"Rect") { vec![r] } else { kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() };
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
+3 -1
View File
@@ -53,6 +53,8 @@ pub struct ScriptResult {
pub rc: bool,
/// `event.value` afterwards.
pub value: String,
/// Whether the script assigned `event.value`, even the value it already had.
pub value_set: bool,
pub changes: Vec<FieldChange>,
/// Why it rejected the value (its alert), for the error shown.
pub message: Option<String>,
@@ -84,7 +86,7 @@ pub struct NoScripts;
impl Scripts for NoScripts {
fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }
}
}
+222 -3
View File
@@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() {
}
}
/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root
/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3,
/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons.
#[test]
fn kids_that_loop_back_or_are_roots_are_not_children() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3
"<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6
"<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8
"<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7
"<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9
"<< /T (group) /Kids [13 0 R] >>".into(), // 10
"<< /Length 0 >>
stream
endstream"
.into(), // 11
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12
"<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13
"<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent
"<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15
];
let doc = document(&objs);
let all = fields(&doc);
let listed: Vec<(&str, FieldKind, Vec<ObjRef>)> =
all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(
listed,
[
("size", FieldKind::Radio, vec![r(6)]),
("left", FieldKind::Radio, vec![r(9)]),
("right", FieldKind::Radio, vec![r(12)]),
("group.inner.deep", FieldKind::Text, vec![r(15)]),
]
);
}
/// A page widget whose `/Parent` names a field is one of that field's widgets even when the
/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here
/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots,
/// so not children) and none of their widgets. Each field gets every widget naming it, once.
#[test]
fn widgets_naming_a_field_that_does_not_list_them_join_it() {
let w = |parent: u32, state: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>",
y + 15
)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3
w(5, "A", 700), // 4: not in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5
"<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6
w(5, "B", 650), // 7: in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9
"<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
w(8, "C", 600), // 11: in 8's /Kids
w(8, "D", 550), // 12: not in 8's /Kids
w(9, "E", 500), // 13: not in 9's /Kids
];
let doc = document(&objs);
let (all, adopted) = enumerate(&doc);
let listed: Vec<(&str, Vec<ObjRef>)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]);
assert_eq!(adopted, 0, "no widget is read as a field of its own");
let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
assert_eq!(states, ["B", "A"]);
}
/// Some writers leave every field out of `/Fields` and put the widgets only in the page
/// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway.
/// The pages' widgets are adopted as fields, after the listed tree fields.
@@ -353,6 +432,37 @@ fn fields_listed_only_on_the_pages_are_adopted() {
assert_eq!(adopted_page_fields(&doc), 4);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// has no parent: the reference reads as `null`, the same as an absent entry (ISO
/// 32000-2 §7.3.9). It is adopted as a field of its own, under its own name, and can be filled.
#[test]
fn a_widget_whose_parent_is_missing_is_a_field_of_its_own() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary
"<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field
"[1 2 3]".into(), // 9
];
let mut doc = document(&objs);
assert_combined_matches_legacy(&doc, 2);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["city", "zip"]);
let city = field(&all, "city");
assert_eq!(
(city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page),
(FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0))
);
set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap();
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "zip").value, ["0150"]);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
@@ -1574,7 +1684,7 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
struct Ran;
impl Scripts for Ran {
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
@@ -1833,6 +1943,59 @@ fn missing_fields_and_built_names_are_flagged() {
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
}
/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank
/// option, and starting value, is a single space, a prefilled price, the row's product and a total
/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row
/// without a price leaves the product nothing to multiply, and it is 0 too, not 1.
#[test]
fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() {
let tx = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20)
};
let qty = |name: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>",
y + 20
)
};
let prd = |row: u32| {
format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {row}\"\\)\\);) >> >>")
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
qty("QTY 0", 700),
tx("price 0", 700, "/V (605)"),
tx("Total 0", 700, &format!("/V (0) {}", prd(0))),
qty("QTY 1", 660),
tx("price 1", 660, "/V (780)"),
tx("Total 1", 660, &format!("/V (0) {}", prd(1))),
qty("QTY 2", 620),
tx("price 2", 620, ""),
tx("Total 2", 620, &format!("/V (0) {}", prd(2))),
tx(
"Total",
580,
r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#,
),
];
let mut doc = document(&objs);
let totals = |doc: &Document| {
let all = fields(doc);
["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default())
};
recalculate(&mut doc).unwrap();
assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0");
set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row");
set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price");
let doc = reopen(&doc);
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
@@ -2988,6 +3151,7 @@ fn recalc_chain_where_script_changes_checkbox() {
ScriptResult {
rc: true,
value: "calc1".into(),
value_set: true,
changes: vec![FieldChange {
name: "agree".into(),
value: Some(vec!["Yes".into()]),
@@ -3000,9 +3164,9 @@ fn recalc_chain_where_script_changes_checkbox() {
}
} else if script == "read_box" {
let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default();
ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: format!("box_was_{box_val}"), value_set: true, changes: Vec::new(), message: None }
} else {
ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: String::new(), value_set: false, changes: Vec::new(), message: None }
}
}
}
@@ -3023,6 +3187,61 @@ fn recalc_chain_where_script_changes_checkbox() {
assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value");
}
/// A calculation returning a combo option's display text stores the option's export value in /V
/// and selects it in /I, as choosing it does.
#[test]
fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &kind, Some("country")).unwrap();
let obj = field(&fields(&doc), "country").obj;
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "Canada"), pair("fr", "France")]))).unwrap();
set_field_script(&mut doc, "country", "calculate", Some("France")).unwrap();
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 1);
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
assert_eq!(field(&fields(&doc), "country").value, ["fr"]);
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 0, "the display text names the value it already has");
}
/// An export value names its own option even when an earlier option shows that text, whether a
/// calculation or the user sets it.
#[test]
fn an_export_value_wins_over_an_earlier_options_display_text() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
let mut objs = Vec::new();
for (name, y) in [("calculated", 10.0), ("chosen", 40.0)] {
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, y, 100.0, y + 20.0], &kind, Some(name)).unwrap();
let obj = field(&fields(&doc), name).obj;
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "fr"), pair("fr", "France")]))).unwrap();
objs.push(obj);
}
set_field_script(&mut doc, "calculated", "calculate", Some("fr")).unwrap();
recalculate_with(&mut doc, &mut Pick).unwrap();
set_value(&mut doc, "chosen", &FieldValue::Choice(vec!["fr".into()])).unwrap();
for obj in objs {
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
}
}
#[test]
fn partial_appearance_keeps_custom_default_appearance_and_font_resources() {
let mut doc = fixture();
+38 -3
View File
@@ -163,6 +163,11 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
let current = f.value.first().cloned().unwrap_or_default();
let v = if let Some(js) = &f.actions.scripts.calculate {
let r = scripts.run(FieldEvent::Calculate, js, &f, &current, &all);
// A script may set its own field through the field object (`getField("Total").value
// = …`) rather than through `event.value`. `apply_changes` leaves the event's own
// field to the event, so that value is the result unless the script also assigned
// `event.value`, even the value it already had.
let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default());
scripting::apply_changes(doc, &r.changes, &f.name)?;
if !r.changes.is_empty() {
all = fields(doc);
@@ -170,13 +175,37 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
if !r.rc {
continue;
}
r.value
match own {
Some(v) if !r.value_set => v,
_ => r.value,
}
} else {
let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
v
};
// A combo box result naming an option, by export value or display text, is stored as that
// option's export value (the first element of an /Opt pair, ISO 32000-2 Table 234), as
// `write_value` stores a choice, so /V, /I and exported form data agree.
let combo = f.kind == FieldKind::Combo;
let matched = if combo { option_named(&f, &v) } else { None };
let v = match matched.and_then(|i| f.options.get(i)) {
Some((export, _)) => export.clone(),
None => v,
};
if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
// A combo box's /I (its option indices, ISO 32000-2 Table 234) follows the value, as
// `write_value` keeps it, so the selection kept never disagrees with the value.
let picked = matched.and_then(|i| i64::try_from(i).ok());
doc.update_dict(f.obj, |d| {
d.set(b"V".to_vec(), PdfString::text(&v));
match picked {
Some(i) if combo => d.set(b"I".to_vec(), Object::Array(vec![Object::Int(i)])),
_ if combo => {
d.remove(b"I");
}
_ => {}
}
})?;
redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
if let Some(target) = all.iter_mut().find(|x| x.obj == obj) {
target.value = vec![v];
@@ -187,6 +216,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
Ok(changed)
}
/// The index of the choice option `v` names: the option whose export value it is, else the first
/// whose display text it is, so a display text never shadows a later option's export value.
fn option_named(f: &Field, v: &str) -> Option<usize> {
f.options.iter().position(|(e, _)| e == v).or_else(|| f.options.iter().position(|(_, d)| d == v))
}
/// The fields whose calculation differs between two readings of a form, matched by field object
/// and named as in `after`: the formulas an edit rewrote, such as a rename's.
pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec<String> {
@@ -280,7 +315,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
// Accept export values or display texts; free text only in editable combo boxes.
let mut exports = Vec::new();
for v in &vals {
match f.options.iter().find(|(e, d)| e == v || d == v) {
match option_named(f, v).and_then(|i| f.options.get(i)) {
Some((e, _)) => exports.push(e.clone()),
None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()),
None if f.options.is_empty() => exports.push(v.clone()),
+22 -4
View File
@@ -276,12 +276,29 @@ pub(crate) fn platform() -> &'static str {
}
}
pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
/// The `event` object for `e`, and the store behind its `value`: `value` holds what the script
/// reads and writes, and `set` turns true once the script assigns `event.value`, even the value it
/// already had. The store is out of the script's reach.
pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, JsObject)> {
fn get(_: &JsValue, _: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult<JsValue> {
store.get(js_string!("value"), c)
}
fn set(_: &JsValue, args: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult<JsValue> {
store.set(js_string!("value"), arg(args, 0), false, c)?;
store.set(js_string!("set"), true, false, c)?;
Ok(JsValue::undefined())
}
let store = ObjectInitializer::new(ctx)
.property(js_string!("value"), s(&e.value), Attribute::all())
.property(js_string!("set"), false, Attribute::all())
.build();
let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone()));
let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone()));
let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into());
ObjectInitializer::new(ctx)
let event = ObjectInitializer::new(ctx)
.property(js_string!("name"), s(&e.name), Attribute::all())
.property(js_string!("type"), s(&e.kind), Attribute::all())
.property(js_string!("value"), s(&e.value), Attribute::all())
.accessor(js_string!("value"), Some(value_get), Some(value_set), Attribute::CONFIGURABLE | Attribute::ENUMERABLE)
.property(js_string!("change"), s(&e.change), Attribute::all())
.property(js_string!("changeEx"), s(&e.change), Attribute::all())
.property(js_string!("willCommit"), e.will_commit, Attribute::all())
@@ -293,5 +310,6 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
.property(js_string!("commitKey"), 0, Attribute::all())
.property(js_string!("modifier"), false, Attribute::all())
.property(js_string!("shift"), false, Attribute::all())
.build()
.build();
Ok((event, store))
}
+2
View File
@@ -278,6 +278,8 @@ pub struct Outcome {
pub rc: bool,
/// `event.value` after the script.
pub value: String,
/// Whether the script assigned `event.value`, even the value it already had.
pub value_set: bool,
pub change: String,
/// Fields whose value or properties the script changed, as they are now.
pub changed: Vec<FieldState>,
+2 -1
View File
@@ -217,7 +217,7 @@ fn run_here(
let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect();
let result = (|| -> JsResult<()> {
install(&mut ctx)?;
let ev = event_object(&mut ctx, event);
let (ev, value_store) = event_object(&mut ctx, event)?;
ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?;
// One document script that throws (calling a method PdfCraft lacks, say) must not keep
// every field script in the form from running: what it defined before the error, and
@@ -235,6 +235,7 @@ fn run_here(
out.rc = rc.to_boolean();
let v = ev.get(js_string!("value"), &mut ctx)?;
out.value = text(&v, &mut ctx)?;
out.value_set = value_store.get(js_string!("set"), &mut ctx)?.to_boolean();
let c = ev.get(js_string!("change"), &mut ctx)?;
out.change = text(&c, &mut ctx)?;
Ok(())
+15
View File
@@ -31,6 +31,21 @@ fn calculate_scripts_read_fields_and_set_the_value() {
assert!(o.rc);
}
/// `event.value` reads and writes as a plain value, numbers included, and the outcome says whether
/// the script assigned it, even to the value it already had.
#[test]
fn the_outcome_says_whether_the_script_assigned_event_value() {
let ev = Event::field("Calculate", "total", "7");
let o = go("event.value = event.value;", &ev);
assert_eq!((o.value.as_str(), o.value_set), ("7", true));
let o = go("event.value = 2; event.value += 3; typeof event.value;", &ev);
assert_eq!((o.value.as_str(), o.value_set, o.result.as_deref()), ("5", true, Some("number")));
let o = go("var v = event.value; this.getField('price').value = 3;", &ev);
assert_eq!((o.value.as_str(), o.value_set), ("7", false));
let o = go("Object.keys(event).indexOf('value') >= 0;", &ev);
assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event");
}
#[test]
fn numbers_with_leading_zeros_stay_text() {
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
+4
View File
@@ -22,6 +22,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0).
zune-jpeg = "0.5"
# Lets other programs go first while a copy is being made.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
thread-priority = "3.1"
[dev-dependencies]
pdfcraft-render = { workspace = true }
proptest.workspace = true
+6
View File
@@ -121,6 +121,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream
about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused`
counts those the image step read. Streams it only stores with Flate (below) aren't counted.
- **Threads**: pictures are worked out on up to half the machine's processors (at most 8, one on
the web), and as many as 1 GiB holds of the largest picture. Those threads go at low priority
where the system allows it, so other programs go first; pictures done on the calling thread keep
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.
- **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
+42 -2
View File
@@ -243,14 +243,22 @@ fn merge_page(
images: &mut ImageBudget,
) -> Result<usize, OptimizeError> {
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
let resources = page_resources(doc, page).unwrap_or_default();
let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
// Reading a heavy page takes seconds, so not one that has no picture that could be a fragment.
if !xobjects.iter().any(|(name, _)| fragment_image(doc, &xobjects, name).is_some()) {
return Ok(0);
}
// A page whose content isn't all known is left alone: rewriting it would drop the rest.
let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) };
// Nor one that doesn't draw enough things for a run, each fragment being a `Do`.
if data.windows(2).filter(|w| *w == b"Do").count() < MIN_FRAGMENTS {
return Ok(0);
}
let parsed = parse(&data);
if parsed.skipped > 0 {
return Ok(0);
}
let resources = page_resources(doc, page).unwrap_or_default();
let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let ops = parsed.ops;
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let (mut i, mut merged, mut k) = (0, 0, 0);
@@ -339,6 +347,9 @@ pub(crate) fn run(
budget: &mut ImageBudget,
) -> Result<(), OptimizeError> {
for p in pages {
if !progress(Stage::Reading { done: 0, total: pages.len() }) {
return Err(OptimizeError::Cancelled);
}
report.fragments_merged += merge_page(doc, *p, report, progress, budget)?;
}
Ok(())
@@ -405,6 +416,35 @@ mod tests {
assert_eq!(budget.refused(), 1);
}
/// A page drawing too few things for a run isn't parsed for one, even with a picture that could
/// be a fragment.
#[test]
fn too_few_draws_for_a_run_read_no_groups() {
let mut doc = Document::new_empty();
let mut image = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
image.set(k.as_bytes().to_vec(), v);
}
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0])));
let drawn = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(MIN_FRAGMENTS - 1);
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes())));
let mut xobjects = Dict::new();
xobjects.set(b"Im".to_vec(), Object::Ref(image));
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));
let page = doc.add(Object::Dict(page));
GROUP_READS.with(|c| c.set(0));
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX));
assert_eq!(merged.ok(), Some(0));
assert_eq!(GROUP_READS.with(|c| c.get()), 0);
}
/// Many images drawn on top of each other are not one picture; looking for runs among them reads
/// a bounded number of groups per operator, not the rest of the page from every group.
#[test]
+323 -83
View File
@@ -4,6 +4,8 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use pdfcraft_content::{Matrix, Pieces};
use std::sync::atomic::{AtomicU64, Ordering};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage};
@@ -250,13 +252,20 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool {
/// decode within its budget or is damaged partway, every image is given `INFINITY` (never
/// resampled), since its largest use may be among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
walk_pages(doc, pages).0
let Ok((ppi, ..)) = walk_pages(doc, pages, &mut |_| true) else { return HashMap::new() };
ppi
}
/// What a walk of the pages found: each image's resolution, the images under remapped device
/// spaces, and the references followed.
type PagesWalked = (HashMap<ObjRef, f64>, HashSet<u32>, Read);
/// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is
/// in force, by object number (all of them when a use may have been missed), and the references
/// followed to reach them.
fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap<ObjRef, f64>, HashSet<u32>, Read) {
///
/// `tick` hears how many pages are done, and stops the walk by returning false.
fn walk_pages(doc: &Document, pages: &[ObjRef], tick: &mut dyn FnMut(usize) -> bool) -> Result<PagesWalked, OptimizeError> {
let mut walk = Walk {
doc,
path: Vec::new(),
@@ -269,7 +278,10 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap<ObjRef, f64>, HashSe
defaults: HashSet::new(),
read: Read::default(),
};
for p in pages {
for (done, p) in pages.iter().enumerate() {
if !tick(done) {
return Err(OptimizeError::Cancelled);
}
let Some(dict) = doc.get(*p).as_dict().cloned() else { continue };
let page = pdfcraft_model::Page { obj: *p, dict };
walk.resources_above(*p);
@@ -288,7 +300,7 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap<ObjRef, f64>, HashSe
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
walk.defaults.extend(walk.out.keys().map(|r| r.num));
}
(walk.out, walk.defaults, walk.read)
Ok((walk.out, walk.defaults, walk.read))
}
/// Whether image `r` is one [`one_bit`] may make grey: eight-bit `DeviceRGB`, not in an image codec.
@@ -1330,7 +1342,7 @@ pub(crate) fn run(
progress: &mut dyn FnMut(Stage) -> bool,
budget: &mut ImageBudget,
) -> Result<(), OptimizeError> {
let (ppi, mut defaults, read) = walk_pages(doc, pages);
let (ppi, mut defaults, read) = walk_pages(doc, pages, &mut |done| progress(Stage::Reading { done, total: pages.len() }))?;
// Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an
// indexed image's palette says which of its samples a resampled one keeps, which they may
// change: one used where the walk didn't look may be drawn so.
@@ -1347,93 +1359,73 @@ pub(crate) fn run(
})
.collect();
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
refs.sort_by_key(|(r, _)| r.num);
refs.sort_by_key(|(r, _)| (r.num, r.generation));
let total = refs.len();
let mut jbig2 = false;
let mut group: Vec<Held> = Vec::new();
// Holds a JBIG2 image for `share`, which codes the group once it is full.
let mut hold = |doc: &mut Document, image: ObjRef, size: (Option<i64>, Option<i64>), bits: Option<Vec<u8>>, report: &mut Report| {
let side = |v: Option<i64>| v.and_then(|v| u32::try_from(v).ok());
let (Some(bits), Some(width), Some(height)) = (bits, side(size.0), side(size.1)) else { return };
hold_in(&mut group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report));
// By number: a repaired object answers to two generations.
let mut smask_uses: HashMap<u32, usize> = HashMap::new();
for (r, _) in &refs {
if let Some(m) = smask_of(doc, *r) {
*smask_uses.entry(m.num).or_default() += 1;
}
}
// Too few to be worth the threads, which keeps a small file's progress in step too.
let threads = if total < MIN_FOR_THREADS {
1
} else {
let largest = refs.iter().filter_map(|(r, _)| picture_pixels(doc, *r)).max().unwrap_or(0);
threads_for(crate::pool::workers(settings.workers), largest)
};
for (done, (r, ppi)) in refs.into_iter().enumerate() {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
}
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
report.images += 1;
let refused = budget.refused();
// Packed from more bits a sample, it is lossless at one bit, but the grey or colour
// settings can make it smaller still (below).
let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1);
let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) {
Some(m) if packed_from_more => (Some(m), None),
m => (None, m),
};
if let Some((new, resampled, bits)) = mono {
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
if resampled {
report.images_resampled += 1;
} else {
report.images_recompressed += 1;
let mut done = 0;
while let Some(rest) = refs.get(done..).filter(|rest| !rest.is_empty()) {
let run = if threads > 1 { batch_len(doc, rest, &smask_uses, settings.mono.compression == MonoCompression::Jbig2) } else { 0 };
let Some(batch) = rest.get(..run).filter(|batch| batch.len() > 1) else {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
}
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
let size = (new.dict.int(b"Width"), new.dict.int(b"Height"));
let bits = bits.filter(|_| shares(&new.dict));
doc.set(r, Object::Stream(new));
hold(doc, r, size, bits, report);
continue;
}
let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs));
let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget));
// A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft
// mask resampled with it.
let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask {
Some((_, m)) => Some(m.raw.len()),
None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) {
Object::Stream(m) => m.raw.len(),
_ => 0,
}),
};
let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0)));
let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| {
processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b))
});
if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) {
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
if resampled {
report.images_resampled += 1;
} else {
report.images_recompressed += 1;
if let Some((r, ppi)) = rest.first() {
let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget);
apply(doc, decision, report, &mut jbig2, &mut group);
}
let size = (new.dict.int(b"Width"), new.dict.int(b"Height"));
let bits = bits.filter(|_| shares(&new.dict));
doc.set(r, Object::Stream(new));
hold(doc, r, size, bits, report);
continue;
}
let Some((new, smask)) = processed else {
// Read to be packed and again to be resampled, it is still one image, and it stayed.
report.images_refused += usize::from(budget.refused() > refused);
done += 1;
continue;
};
let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height");
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
if resized {
report.images_resampled += 1;
} else {
report.images_recompressed += 1;
}
doc.set(r, Object::Stream(new));
if let Some((mr, m)) = smask {
doc.set(mr, Object::Stream(m));
// Each thread works within what's left now, and pays for what it used in the document's
// order below: a picture the order can't pay for is done again as if there were no threads,
// so the same pictures are refused with any number of them. Once more than what's left has
// been used, no more are started.
let allowed = budget.work_left();
let used = AtomicU64::new(0);
let tried = {
let (doc, defaults) = (&*doc, &defaults);
crate::pool::map(
batch,
threads,
|(r, ppi)| {
if used.load(Ordering::Relaxed) >= allowed {
return None;
}
let mut own = ImageBudget::with_work(allowed);
let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, &mut own);
let cost = allowed.saturating_sub(own.work_left());
used.fetch_add(cost, Ordering::Relaxed);
(own.refused() == 0).then_some((decision, cost))
},
&mut |finished| progress(Stage::Images { done: done.saturating_add(finished), total }),
)?
};
for ((r, ppi), tried) in batch.iter().zip(tried) {
let decision = match tried.flatten() {
Some((decision, cost)) if cost <= budget.work_left() => {
budget.charge(cost);
decision
}
_ => decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget),
};
apply(doc, decision, report, &mut jbig2, &mut group);
}
done += batch.len();
}
share(doc, &mut group, report);
if !progress(Stage::Images { done: total, total }) {
@@ -1445,6 +1437,166 @@ pub(crate) fn run(
Ok(())
}
/// A picture's soft mask, if it names one.
fn smask_of(doc: &Document, r: ObjRef) -> Option<ObjRef> {
match &*doc.get(r) {
Object::Stream(s) => s.dict.reference(b"SMask"),
_ => None,
}
}
/// How many of `rest`, from the first, can be worked out beside each other.
fn batch_len(doc: &Document, rest: &[(ObjRef, f64)], smask_uses: &HashMap<u32, usize>, holds_samples: bool) -> usize {
// One object under two generations: the second must see what the first left. Under one of them
// it can be a picture, and under the other another's soft mask.
let mut numbers = HashSet::new();
let mut samples = 0usize;
rest.iter()
.take(PICTURES_AT_ONCE)
.take_while(|(r, _)| {
// Results wait for the end of the batch, samples kept to share a dictionary with them.
samples = samples.saturating_add(if holds_samples { held_samples(doc, *r) } else { 0 });
// A soft mask two pictures share is changed by the first of them, and the second must
// see that, so they don't run beside others.
samples <= SHARED_BYTES
&& !smask_uses.contains_key(&r.num)
&& numbers.insert(r.num)
&& smask_of(doc, *r).is_none_or(|m| smask_uses.get(&m.num) == Some(&1))
})
.count()
}
/// The most bytes of 1-bit samples picture `r` could keep to share a dictionary: whole bytes a row.
fn held_samples(doc: &Document, r: ObjRef) -> usize {
let object = doc.get(r);
let Object::Stream(s) = &*object else { return 0 };
let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok());
let Some((w, h)) = side(b"Width").zip(side(b"Height")) else { return 0 };
if w.saturating_mul(h) > SHARED_PIXELS {
return 0;
}
usize::try_from(w.div_ceil(8).saturating_mul(h)).unwrap_or(usize::MAX)
}
/// Below this many pictures, one thread does them.
const MIN_FOR_THREADS: usize = 16;
/// How many pictures run beside each other before what they used is paid for.
const PICTURES_AT_ONCE: usize = 32;
/// What the pictures being worked out may hold in memory together.
const PICTURE_MEMORY: u64 = 1 << 30;
/// What a picture holds while it is worked out, for each pixel: decoded, and resampled or encoded.
const PICTURE_BYTES_PER_PIXEL: u64 = 8;
/// A picture's pixels, if it is small enough to be decoded at all.
fn picture_pixels(doc: &Document, r: ObjRef) -> Option<u64> {
let object = doc.get(r);
let Object::Stream(s) = &*object else { return None };
let (w, h) = (u64::try_from(s.dict.int(b"Width")?).ok()?, u64::try_from(s.dict.int(b"Height")?).ok()?);
w.checked_mul(h).filter(|pixels| *pixels <= MAX_PIXELS)
}
/// How many pictures of `largest` pixels to work on at once: `workers`, or as many as
/// [`PICTURE_MEMORY`] holds, and at least one.
fn threads_for(workers: usize, largest: u64) -> usize {
let fit = PICTURE_MEMORY / largest.saturating_mul(PICTURE_BYTES_PER_PIXEL).max(1);
workers.min(usize::try_from(fit).unwrap_or(usize::MAX)).max(1)
}
/// What's to become of one picture.
enum Decision {
/// Not an image stream after all.
NotAnImage,
/// An image that stays as it is, `refused` if the allowance couldn't pay for it.
Stays { refused: bool },
Replace {
image: ObjRef,
before: usize,
new: Stream,
resampled: bool,
smask: Option<(ObjRef, Stream)>,
/// Its 1-bit samples, if it may share a JBIG2 dictionary ([`share`]).
bits: Option<Vec<u8>>,
},
}
/// Do what was decided for one picture, and count it.
fn apply(doc: &mut Document, decision: Decision, report: &mut Report, jbig2: &mut bool, group: &mut Vec<Held>) {
let (image, before, new, resampled, smask, bits) = match decision {
Decision::NotAnImage => return,
Decision::Stays { refused } => {
report.images += 1;
report.images_refused += usize::from(refused);
return;
}
Decision::Replace { image, before, new, resampled, smask, bits } => (image, before, new, resampled, smask, bits),
};
report.images += 1;
*jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
report.image_bytes_before += before;
report.image_bytes_after += new.raw.len();
if resampled {
report.images_resampled += 1;
} else {
report.images_recompressed += 1;
}
let side = |key: &[u8]| new.dict.int(key).and_then(|v| u32::try_from(v).ok());
let held = bits.zip(side(b"Width")).zip(side(b"Height"));
doc.set(image, Object::Stream(new));
if let Some((mr, m)) = smask {
doc.set(mr, Object::Stream(m));
}
// A JBIG2 image waits for `share`, which codes the group once it is full.
if let Some(((bits, width), height)) = held {
hold_in(group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report));
}
}
/// Work out what's to become of picture `r`, changing nothing yet.
fn decide(doc: &Document, r: ObjRef, ppi: f64, defaults: bool, settings: &Settings, budget: &mut ImageBudget) -> Decision {
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return Decision::NotAnImage };
let refused = budget.refused();
// Packed from more bits a sample, it is lossless at one bit, but the grey or colour
// settings can make it smaller still (below).
let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1);
let (packed, mono) = match mono(doc, s, ppi, defaults, &settings.mono, budget) {
Some(m) if packed_from_more => (Some(m), None),
m => (None, m),
};
if let Some((new, resampled, bits)) = mono {
let bits = bits.filter(|_| shares(&new.dict));
return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits };
}
let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs));
let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget));
// A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft
// mask resampled with it.
let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask {
Some((_, m)) => Some(m.raw.len()),
None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) {
Object::Stream(m) => m.raw.len(),
_ => 0,
}),
};
let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0)));
let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| {
processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b))
});
if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) {
let bits = bits.filter(|_| shares(&new.dict));
return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits };
}
let Some((new, smask)) = processed else {
// Read to be packed and again to be resampled, it is still one image, and it stayed.
return Decision::Stays { refused: budget.refused() > refused };
};
let resampled = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height");
Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask, bits: None }
}
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2
/// §7.7.2). A full save with object streams writes a 1.5 header anyway.
@@ -1492,6 +1644,37 @@ mod dimension_tests {
assert_eq!((count, group.len()), (SHARED_IMAGES, 0), "coded on the 16th");
}
#[test]
fn a_batch_holds_no_more_samples_than_a_group_may() {
let mut doc = Document::new_empty();
let mut add = |width: i64, height: i64| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(width));
d.set(b"Height".to_vec(), Object::Int(height));
(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))), 72.0)
};
// Eight megabytes of samples each, and one byte a pixel when one pixel wide.
let large: Vec<_> = (0..12).map(|_| add(8192, 8192)).collect();
let thin: Vec<_> = (0..8).map(|_| add(1, 32 << 20)).collect();
let small: Vec<_> = (0..40).map(|_| add(100, 100)).collect();
// Past what may share a dictionary, so never held.
let huge: Vec<_> = (0..12).map(|_| add(16384, 16384)).collect();
let none = HashMap::new();
assert_eq!(batch_len(&doc, &large, &none, true), 8);
assert_eq!(batch_len(&doc, &thin, &none, true), 2);
assert_eq!(batch_len(&doc, &small, &none, true), PICTURES_AT_ONCE);
assert_eq!(batch_len(&doc, &huge, &none, true), huge.len());
assert_eq!(batch_len(&doc, &large, &none, false), large.len(), "nothing is held but for JBIG2");
}
#[test]
fn threads_are_limited_by_the_memory_of_the_largest_picture() {
assert_eq!(threads_for(8, 1_000_000), 8);
assert_eq!(threads_for(8, 40_000_000), 3);
assert_eq!(threads_for(8, MAX_PIXELS), 1);
assert_eq!(threads_for(8, 0), 8);
}
/// Only what redaction can store again without the dictionary shares one: an image mask, or
/// DeviceGray named directly, of at most 2^26 pixels.
#[test]
@@ -1609,6 +1792,63 @@ mod dimension_tests {
assert_eq!(changed, 2, "the third is as it was");
}
/// An allowance that runs out refuses the same pictures with any number of threads as it does
/// one after another.
#[test]
fn the_allowance_refuses_the_same_pictures_with_any_number_of_threads() {
let picture = |shade: u32| {
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("XObject"));
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(400));
dict.set(b"Height".to_vec(), Object::Int(400));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let samples: Vec<u8> = (0..400u32 * 400).map(|i| (i.wrapping_mul(shade + 3) % 251) as u8).collect();
Stream::flate(dict, &samples)
};
let build = || {
let mut doc = Document::new_empty();
// A few of different sizes, so what's left when the allowance ends depends on the order.
let refs: Vec<ObjRef> = (0..40u32).map(|i| doc.add(Object::Stream(picture(i)))).collect();
let mut xobjects = Dict::new();
let mut content = String::from("q 72 0 0 72 0 0 cm ");
for (i, r) in refs.iter().enumerate() {
xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r));
content.push_str(&format!("/I{i} Do "));
}
content.push('Q');
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
(doc, refs, page)
};
let run_with = |work: u64, workers: usize| {
let (mut doc, refs, page) = build();
let settings = Settings { workers: Some(workers), ..Settings::default() };
let (mut report, mut budget) = (Report::default(), ImageBudget::with_work(work));
run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
let raws: Vec<Vec<u8>> = refs.iter().map(|r| if let Object::Stream(s) = &*doc.get(*r) { s.raw.to_vec() } else { Vec::new() }).collect();
(format!("{report:?}"), budget.work_left(), raws)
};
let all = u64::MAX - run_with(u64::MAX, 1).1;
// Past one batch of what threads run together, and part of the way through a picture.
let work = all / 40 * 37 + all / 80;
let one = run_with(work, 1);
assert!(one.0.contains("images_refused: 3"), "{}", one.0);
for workers in [2, 4, 8] {
let many = run_with(work, workers);
assert_eq!(many.0, one.0, "{workers} threads count the same");
assert_eq!(many.1, one.1, "{workers} threads leave the same allowance");
assert!(many.2 == one.2, "{workers} threads store the same pictures");
}
}
/// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled
/// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run
/// as long as one of JBIG2, and those past it stay as they are.
+7
View File
@@ -32,6 +32,7 @@ mod audit;
pub mod fragments;
mod images;
mod links;
mod pool;
mod samples;
mod unused;
@@ -41,6 +42,7 @@ use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object
use pdfcraft_filters::{Filter, Params};
pub use images::effective_resolutions;
pub use pool::give_way_to_other_programs;
pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
#[derive(Debug, thiserror::Error)]
@@ -106,6 +108,8 @@ impl Compatibility {
/// Where an optimization is, reported to [`optimize_with_progress`]'s callback.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// Reading what pages draw: `done` of `total` read, none yet while looking for fragments.
Reading { done: usize, total: usize },
/// About to process image `done` (0-based) of `total`; `done == total` once all are done.
Images { done: usize, total: usize },
/// Merging the fragments of a page picture into one image (only when a page has some).
@@ -181,6 +185,8 @@ impl ImageSettings {
pub struct Settings {
/// Target PDF version (`Retain` by default).
pub compatibility: Compatibility,
/// Threads for pictures: `None` picks from the machine. The result is the same with any number.
pub workers: Option<usize>,
/// Put page pictures drawn as many stacked fragments back together first ([`fragments`]).
pub merge_fragments: bool,
pub color: ImageSettings,
@@ -217,6 +223,7 @@ impl Default for Settings {
/// Acrobat's Reduce File Size choices.
fn default() -> Self {
Self {
workers: None,
compatibility: Compatibility::Retain,
merge_fragments: true,
color: ImageSettings::REDUCE,
+262
View File
@@ -0,0 +1,262 @@
//! Work over many pages or images on several threads, the results in order, so that what is
//! written is the same however many threads did it.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use crate::OptimizeError;
/// Threads beyond this add more memory than speed: each holds a page or an image being worked on.
const MAX_WORKERS: usize = 8;
/// How long Cancel can wait while every thread is busy with something long.
const CANCEL_CHECK: Duration = Duration::from_millis(100);
/// Let other programs go first on this thread: the wait for a copy matters less than whatever else
/// is open. A system that refuses leaves it as it was.
pub fn give_way_to_other_programs() {
#[cfg(not(target_arch = "wasm32"))]
let _ = thread_priority::set_current_thread_priority(thread_priority::ThreadPriority::Min);
}
/// How many threads to use: `asked`, or half the machine's processors up to [`MAX_WORKERS`], so a
/// bigger machine goes faster and every machine keeps room for other programs. One on the web, which
/// has no threads here.
pub(crate) fn workers(asked: Option<usize>) -> usize {
if cfg!(target_arch = "wasm32") {
return 1;
}
asked.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).clamp(1, MAX_WORKERS))).max(1)
}
/// `work` on each of `items`, results in the order of `items`. `tick` hears how many are done, and
/// stops the run by returning false. With more than one worker an item whose work panics gets
/// `None`, and the caller decides what becomes of it; with one, the panic goes on up as it does for
/// any other work.
pub(crate) fn map<T: Sync, R: Send>(
items: &[T],
workers: usize,
work: impl Fn(&T) -> R + Sync,
tick: &mut dyn FnMut(usize) -> bool,
) -> Result<Vec<Option<R>>, OptimizeError> {
let mut out: Vec<Option<R>> = Vec::with_capacity(items.len());
if workers <= 1 || items.len() <= 1 {
for (done, item) in items.iter().enumerate() {
if !tick(done) {
return Err(OptimizeError::Cancelled);
}
out.push(Some(work(item)));
}
return Ok(out);
}
out.resize_with(items.len(), || None);
let (next, stop) = (AtomicUsize::new(0), AtomicBool::new(false));
let mut stopped = false;
std::thread::scope(|scope| {
let (results, finished) = channel();
for _ in 0..workers.min(items.len()) {
let results = results.clone();
let (next, stop, work) = (&next, &stop, &work);
// A thread that can't start only leaves more for the others, or for below.
let _ = std::thread::Builder::new().name("pdfcraft-optimize-worker".into()).spawn_scoped(scope, move || {
give_way_to_other_programs();
while !stop.load(Ordering::Relaxed) {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(item) = items.get(i) else { break };
let result = catch_unwind(AssertUnwindSafe(|| work(item))).ok();
if results.send((i, result)).is_err() {
break;
}
}
});
}
drop(results);
let mut done = 0;
loop {
if !stopped && !tick(done) {
stopped = true;
stop.store(true, Ordering::Relaxed);
}
match finished.recv_timeout(CANCEL_CHECK) {
Ok((i, result)) => {
if let Some(slot) = out.get_mut(i) {
*slot = result;
}
done += 1;
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
});
if stopped {
return Err(OptimizeError::Cancelled);
}
// No thread could start, or the last of them stopped early: the rest are done here.
let claimed = next.load(Ordering::Relaxed).min(items.len());
for (done, (item, slot)) in items.iter().zip(out.iter_mut()).enumerate().skip(claimed) {
if !tick(done) {
return Err(OptimizeError::Cancelled);
}
*slot = Some(work(item));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
#[test]
fn results_keep_their_order_whatever_finishes_first() {
let items: Vec<u64> = (0..200).collect();
let out = map(
&items,
4,
|i| {
// The early ones finish last.
std::thread::sleep(Duration::from_micros(200u64.saturating_sub(*i)));
i * 2
},
&mut |_| true,
)
.unwrap();
assert_eq!(out, items.iter().map(|i| Some(i * 2)).collect::<Vec<_>>());
}
#[test]
fn one_worker_does_them_in_order_on_this_thread() {
let here = std::thread::current().id();
let order = Mutex::new(Vec::new());
let mut ticks = Vec::new();
map(
&[1, 2, 3],
1,
|i| {
assert_eq!(std::thread::current().id(), here);
order.lock().unwrap().push(*i);
},
&mut |done| {
ticks.push(done);
true
},
)
.unwrap();
assert_eq!(*order.lock().unwrap(), [1, 2, 3]);
assert_eq!(ticks, [0, 1, 2]);
}
#[test]
fn several_workers_share_the_work() {
let threads = Mutex::new(std::collections::HashSet::new());
map(
&[0; 64],
4,
|_| {
threads.lock().unwrap().insert(std::thread::current().id());
std::thread::sleep(Duration::from_millis(5));
},
&mut |_| true,
)
.unwrap();
assert!(threads.lock().unwrap().len() > 1, "it really ran on more than one");
}
#[test]
fn stopping_stops_and_says_so() {
let ran = AtomicUsize::new(0);
let r = map(
&[0; 500],
4,
|_| {
ran.fetch_add(1, Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(1));
},
&mut |done| done < 8,
);
assert!(matches!(r, Err(OptimizeError::Cancelled)));
assert!(ran.load(Ordering::Relaxed) < 500, "the rest never started");
}
/// Cancel is heard while every thread is busy with something long, not only between items.
#[test]
fn a_stop_gets_through_while_every_thread_is_busy() {
let mut asked_while_busy = 0;
map(&[0; 2], 2, |_| std::thread::sleep(Duration::from_millis(600)), &mut |done| {
if done == 0 {
asked_while_busy += 1;
}
true
})
.unwrap();
assert!(asked_while_busy >= 3, "asked {asked_while_busy} times before anything finished");
let started = std::time::Instant::now();
let mut asked = 0;
let r = map(&[0; 4], 2, |_| std::thread::sleep(Duration::from_millis(400)), &mut |_| {
asked += 1;
asked < 3
});
assert!(matches!(r, Err(OptimizeError::Cancelled)));
assert!(started.elapsed() < Duration::from_millis(1500), "the items not started never ran");
}
#[test]
fn an_item_that_panics_is_left_alone_and_the_rest_are_done() {
let items: Vec<u32> = (0..32).collect();
let out = map(
&items,
4,
|i| {
assert_ne!(*i, 7, "this one panics");
*i
},
&mut |_| true,
)
.unwrap();
for (i, got) in out.iter().enumerate() {
assert_eq!(*got, (i != 7).then_some(i as u32), "item {i}");
}
}
/// The workers give way, and the thread that asked keeps its own priority.
#[cfg(windows)]
#[test]
fn workers_give_way_to_other_programs() {
use thread_priority::get_current_thread_priority;
let here = get_current_thread_priority().unwrap();
let theirs = Mutex::new(Vec::new());
map(
&[0; 8],
2,
|_| {
theirs.lock().unwrap().push(get_current_thread_priority().unwrap());
std::thread::sleep(Duration::from_millis(2));
},
&mut |_| true,
)
.unwrap();
assert_eq!(get_current_thread_priority().unwrap(), here);
let theirs = theirs.into_inner().unwrap();
assert_eq!(theirs.len(), 8);
assert!(theirs.iter().all(|p| *p != here), "{theirs:?} against {here:?}");
}
#[test]
fn nothing_to_do_is_fine() {
let none: [u8; 0] = [];
assert!(map(&none, 4, |_| 1, &mut |_| true).unwrap().is_empty());
assert_eq!(map(&[5], 4, |i| *i, &mut |_| true).unwrap(), [Some(5)]);
}
#[test]
fn the_web_and_small_machines_get_a_sensible_count() {
assert_eq!(workers(Some(3)), if cfg!(target_arch = "wasm32") { 1 } else { 3 });
assert_eq!(workers(Some(0)), 1, "never none");
let picked = workers(None);
assert!((1..=MAX_WORKERS).contains(&picked), "{picked}");
}
}
+12 -4
View File
@@ -395,9 +395,9 @@ fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() {
#[test]
fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() {
// One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case
// reported on #3012). Fragment merging reads its header for each group it tries, before the
// first progress call. The header scan stops at the first stray byte, so that call is the
// first image, and cancelling there stops Optimize.
// reported on #3012). Fragment merging reads its header for each group it tries. The header
// scan stops at the first stray byte, so the first image comes soon, and cancelling there stops
// Optimize. Cancelling while pages are read stops it sooner still.
let (w, h) = (128, 8);
let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]);
let mut doc = Document::new_empty();
@@ -411,8 +411,16 @@ fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() {
let mut stages = Vec::new();
let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| {
stages.push(stage);
!matches!(stage, Stage::Images { .. })
});
assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}");
assert_eq!(stages.last(), Some(&Stage::Images { done: 0, total: 1 }));
assert_eq!(stages.first(), Some(&Stage::Reading { done: 0, total: 1 }), "it can be stopped from the first call");
let mut stages = 0;
let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |_| {
stages += 1;
false
});
assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}");
assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]);
assert_eq!(stages, 1);
}
+139 -2
View File
@@ -2647,7 +2647,9 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() {
};
let mut seen = Vec::new();
let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| {
seen.push(s);
if !matches!(s, Stage::Reading { .. }) {
seen.push(s);
}
true
})
.unwrap();
@@ -2665,7 +2667,10 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() {
// Stopping after the first image.
let mut calls = 0;
let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| {
let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| {
if matches!(s, Stage::Reading { .. }) {
return true;
}
calls += 1;
calls < 2
});
@@ -4289,3 +4294,135 @@ fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() {
assert_eq!(stream(&doc, img), before, "{what}");
}
}
/// Pictures run on several threads: what comes out doesn't depend on how many.
#[test]
fn the_same_file_comes_out_with_any_number_of_threads() {
let build = || {
let mut doc = Document::new_empty();
let shared_mask = image(&mut doc, 1200, 1200, 1, false, None);
// Under a soft mask they share, at different sizes on the page: the second must start from
// the mask the first left. Each needs the mask at a different size.
let first = image(&mut doc, 1200, 1200, 3, true, Some(shared_mask));
let second = image(&mut doc, 1000, 1000, 3, true, Some(shared_mask));
page(&mut doc, &[("Im0", first)], "q 144 0 0 144 100 100 cm /Im0 Do Q");
page(&mut doc, &[("Im0", second)], "q 180 0 0 180 100 100 cm /Im0 Do Q");
// Enough others for the threads.
for page_no in 0..22u32 {
let img = image(&mut doc, 500 + page_no, 500, 3, true, None);
page(&mut doc, &[("Im0", img)], "q 72 0 0 72 100 100 cm /Im0 Do Q");
}
doc
};
let run = |workers| {
let mut doc = build();
let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap();
assert_eq!(report.images, 24, "{report:?}");
assert!(report.images_resampled + report.images_recompressed > 16, "{report:?}");
(write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}"))
};
let (one, one_report) = run(1);
for workers in [2, 4, 7] {
let (many, many_report) = run(workers);
assert_eq!(many_report, one_report, "{workers} threads count the same");
assert!(many == one, "{workers} threads write the same bytes");
}
}
/// A table that disagrees with an object's header leaves it reachable under two generations, as
/// one object: each use decides from what the one before it left, with any number of threads.
#[test]
fn one_object_under_two_generations_comes_out_the_same_with_any_number_of_threads() {
let picture = |size: usize, extra: &str| {
let samples: Vec<u8> = (0..size * size).map(|i| ((i % size) * 255 / size) as u8 ^ ((i / size) as u8 & 7)).collect();
let dict = format!(
"<< /Type /XObject /Subtype /Image /Width {size} /Height {size} /BitsPerComponent 8 /ColorSpace /DeviceGray{extra} /Length {} >>\nstream\n",
samples.len()
);
[dict.as_bytes(), &samples, b"\nendstream"].concat()
};
let build = || {
// 1 the catalog, 2 the pages, 3 the mask, 4 and 5 under it, 6 the picture, 7 a picture that is
// also 8's mask, then the others.
let mut objects = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), Vec::new()];
objects.push(picture(1200, ""));
objects.push(picture(1200, " /SMask 3 0 R"));
objects.push(picture(1000, " /SMask 3 1 R"));
objects.push(picture(1200, ""));
objects.push(picture(1200, ""));
objects.push(picture(1000, " /SMask 7 1 R"));
// Small under one generation, large under the other: they want different sizes.
let mut draws = vec![
("4 0 R".to_string(), 144),
("5 0 R".to_string(), 180),
("6 0 R".to_string(), 72),
("6 1 R".to_string(), 576),
("7 0 R".to_string(), 72),
("8 0 R".to_string(), 180),
];
for i in 0..22 {
objects.push(picture(500 + i, ""));
draws.push((format!("{} 0 R", objects.len()), 72));
}
let mut kids = String::new();
for (drawn, size) in &draws {
let content = format!("q {size} 0 0 {size} 10 10 cm /Im0 Do Q");
objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes());
let contents = objects.len();
objects.push(
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {contents} 0 R /Resources << /XObject << /Im0 {drawn} >> >> >>"
)
.into_bytes(),
);
kids.push_str(&format!("{} 0 R ", objects.len()));
}
objects[1] = format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", draws.len()).into_bytes();
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1);
for (i, object) in objects.iter().enumerate() {
let num = i + 1;
// Headers say generation 0, and the table says 1 of these three.
let generation = u8::from(num == 3 || num == 6 || num == 7);
table.push_str(&format!("{:010} {generation:05} n \n", bytes.len()));
bytes.extend(format!("{num} 0 obj\n").as_bytes());
bytes.extend(object);
bytes.extend(b"\nendobj\n");
}
let start = bytes.len();
bytes.extend(table.as_bytes());
bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objects.len() + 1).as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
for (num, generation) in [(3, 0), (3, 1), (6, 0), (6, 1), (7, 0), (7, 1)] {
assert!(matches!(&*doc.get(ObjRef { num, generation }), Object::Stream(_)), "{num} {generation} finds the object");
}
doc
};
let run = |workers| {
let mut doc = build();
let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap();
(write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}"))
};
let (one, one_report) = run(1);
for workers in [2, 4, 7] {
let (many, many_report) = run(workers);
assert_eq!(many_report, one_report, "{workers} threads count the same");
assert!(many == one, "{workers} threads write the same bytes");
}
}
/// Pages too small for a run aren't read, and that must not cost the smallest run there can be.
#[test]
fn the_fewest_strips_that_make_a_picture_are_merged() {
use pdfcraft_optimize::fragments::MIN_FRAGMENTS;
let keep = Settings {
gray: ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain },
..Settings::default()
};
let (mut doc, _) = printed_page(MIN_FRAGMENTS as _, |_| false, |_, s| s);
let r = optimize(&mut doc, &keep).unwrap();
assert_eq!((r.merged_images, r.fragments_merged), (1, MIN_FRAGMENTS), "{r:?}");
let (mut doc, _) = printed_page((MIN_FRAGMENTS - 1) as _, |_| false, |_, s| s);
let r = optimize(&mut doc, &keep).unwrap();
assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}");
}
+34 -3
View File
@@ -10,7 +10,9 @@
//! - link and GoTo destinations that target a copied page point at the copy. Links to pages that
//! were not copied are dropped, so they don't dangle;
//! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the
//! destination `/AcroForm`;
//! destination `/AcroForm`. When only some of a document's pages are copied, a field brings only
//! the kids that lead to a widget on those pages: the rest of the form stays behind with its
//! values;
//! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the
//! source structure tree is not copied;
//! - named destinations are resolved against the source's name tree and become explicit ones;
@@ -18,6 +20,7 @@
//! `/OCProperties`, keeping their default on/off state.
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
@@ -34,8 +37,8 @@ pub use combine::{
CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with,
extract_pages, split, split_ranges,
};
use copy::SKIP_ON_PAGE;
pub(crate) use copy::{Copier, copy_object};
use copy::{MAX_COPY_DEPTH, SKIP_ON_PAGE};
use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field};
pub use fields::{combine_field_conflicts, field_name_conflicts};
@@ -171,6 +174,27 @@ struct Imported {
renamed_fields: Vec<(String, String)>,
}
/// The annotations on `pages` and, above each widget among them, its fields up to the top one.
/// A field copied with those pages keeps only these kids: its other kids sit on pages left behind,
/// and carry their own values and appearances.
fn fields_on(src: &Document, pages: impl IntoIterator<Item = ObjRef>) -> HashSet<ObjRef> {
let mut kept = HashSet::new();
for page in pages {
let page = src.get(page);
let Some(annots) = page.as_dict().and_then(|d| d.get(b"Annots")).map(|a| src.resolve(a)) else { continue };
for a in annots.as_array().into_iter().flatten() {
let mut at = a.as_ref().or_else(|| src.resolve(a).as_dict().and_then(|d| d.reference(b"Parent")));
// Up the field tree, as deep as a copy may go, and once: a node seen already has its
// fields above it in the set.
for _ in 0..MAX_COPY_DEPTH {
let Some(r) = at.filter(|r| kept.insert(*r)) else { break };
at = src.get(r).as_dict().and_then(|d| d.reference(b"Parent"));
}
}
}
kept
}
/// `import_pages`, also returning what was copied from where.
fn import_pages_mapped(
dst: &mut Document,
@@ -199,6 +223,9 @@ fn import_pages_mapped(
(*page, new, inherited.clone())
})
.collect();
if src_pages.iter().collect::<HashSet<_>>().len() < source.len() {
copier.kept = Some(Rc::new(fields_on(src, copier.pages.keys().copied())));
}
let mut annot_pages = HashMap::new();
let mut new_pages = Vec::new();
let mut done: HashMap<ObjRef, ObjRef> = HashMap::new();
@@ -208,7 +235,11 @@ fn import_pages_mapped(
// popup, a widget's place). Destinations still map to the pages copied.
let repeat = done.contains_key(&page);
let new = if repeat { dst.add(Object::Null) } else { new };
let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone()));
let mut own = repeat.then(|| {
let mut c = Copier::new(src, HashMap::new(), copier.pages.clone());
c.kept = copier.kept.clone();
c
});
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
let resources = resources_with_ancestors(src, &src_dict);
let mut d = Dict::new();
+73 -4
View File
@@ -1,4 +1,6 @@
use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest};
use std::rc::Rc;
use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, named_dest};
/// Keys that must not be followed when copying: they point back up or across the source
/// document, and following them would drag in unrelated objects.
@@ -10,7 +12,7 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"];
/// Real documents stay well under this (a page's resources, fonts and descriptors, nested
/// form XObjects and field trees are a few levels each); a chain of thousands of indirect
/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800).
const MAX_COPY_DEPTH: usize = 256;
pub(super) const MAX_COPY_DEPTH: usize = 256;
/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's
/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to
@@ -56,11 +58,15 @@ pub(crate) struct Copier<'a> {
pub(super) fields: Vec<ObjRef>,
/// Optional content groups copied: (source, destination).
pub(crate) ocgs: Vec<(ObjRef, ObjRef)>,
/// When only some pages are copied: the annotations on them and the fields above each, the
/// only kids a copied field keeps (see `fields_on`). `None` keeps every kid. Shared, so the
/// copiers made for a repeated page or an action don't each copy it.
pub(super) kept: Option<Rc<HashSet<ObjRef>>>,
}
impl<'a> Copier<'a> {
pub(crate) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }
Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new(), kept: None }
}
/// Copy an indirect object (once), returning its destination reference.
@@ -93,6 +99,9 @@ impl<'a> Copier<'a> {
if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) {
return Ok(o.clone());
}
if self.acts_on_none(o) {
return Ok(Object::Null);
}
self.descend()?;
let copied = match o {
Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref),
@@ -112,6 +121,8 @@ impl<'a> Copier<'a> {
fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option<ObjRef>) -> Result<Dict, OrganizeError> {
let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type"));
let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids"));
// A form action lists the fields it submits, resets or hides.
let lists_fields = matches!(d.name(b"S"), Some(b"SubmitForm" | b"ResetForm" | b"Hide"));
let mut out = Dict::new();
for (k, v) in d.iter() {
if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) {
@@ -122,6 +133,34 @@ impl<'a> Copier<'a> {
out.set(k.clone(), v.clone());
continue;
}
if is_field
&& k.as_slice() == b"Kids"
&& let Some(kept) = &self.kept
{
let kids: Vec<Object> = self
.src
.resolve(v)
.as_array()
.into_iter()
.flatten()
.filter(|kid| kid.as_ref().is_none_or(|r| kept.contains(&r)))
.cloned()
.collect();
out.set(k.clone(), self.copy_value(dst, &Object::Array(kids), None)?);
continue;
}
// Of some of the pages, an action lists only the fields on them: one left behind would
// come along with its value. Fields named by text stay.
if lists_fields
&& matches!(k.as_slice(), b"Fields" | b"T")
&& let Some(kept) = &self.kept
{
let listed = self.src.resolve(v);
let entries = listed.as_array().cloned().unwrap_or_else(|| vec![v.clone()]);
let entries: Vec<Object> = entries.into_iter().filter(|e| e.as_ref().is_none_or(|r| kept.contains(&r))).collect();
out.set(k.clone(), self.copy_value(dst, &Object::Array(entries), None)?);
continue;
}
out.set(k.clone(), self.copy_value(dst, v, None)?);
}
if let Some(this) = this {
@@ -136,6 +175,34 @@ impl<'a> Copier<'a> {
Ok(out)
}
/// Whether `o` is a reset or submit action that names only fields left behind (see `kept`).
/// Its copy would list none, and readers (ours too) may take an empty list for every field,
/// which is what an omitted one means (ISO 32000-2 §12.7.6). So it is not copied, nor are the
/// actions after it, as with a link to a page left behind. A list of fields to leave out
/// (Include/Exclude flag set) loses nothing.
fn acts_on_none(&self, o: &Object) -> bool {
let Some(kept) = &self.kept else { return false };
let held;
let action = match o {
// One copied already is not such an action, and needn't be fetched again.
Object::Ref(r) if !self.map.contains_key(r) => {
held = self.src.resolve(o);
held.as_dict()
}
Object::Dict(d) => Some(d),
_ => None,
};
let Some(a) = action.filter(|a| matches!(a.name(b"S"), Some(b"ResetForm" | b"SubmitForm"))) else { return false };
if a.get(b"Flags").and_then(|f| self.src.resolve(f).as_int()).is_some_and(|f| f & 1 != 0) {
return false;
}
let Some(v) = a.get(b"Fields") else { return false };
let listed = self.src.resolve(v);
// As `copy_dict` reads the list: a lone entry stands for a list of one.
let entries = listed.as_array().map_or(std::slice::from_ref(v), Vec::as_slice);
!entries.is_empty() && entries.iter().all(|e| e.as_ref().is_some_and(|r| !kept.contains(&r)))
}
/// Map a source destination (explicit array or reference to one) to the destination
/// document; `None` if it targets a page that was not copied.
pub(super) fn map_dest(&self, dest: &Object) -> Option<Object> {
@@ -196,11 +263,13 @@ impl<'a> Copier<'a> {
d.remove(b"A");
}
}
} else if self.acts_on_none(&action) {
d.remove(b"A");
} else {
// Other actions (URI, Launch, JavaScript…) carry no page references. The
// copier lends its map out and takes it back with the new copies in it,
// so actions sharing an object share its one copy.
let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new());
let mut c = Copier { kept: self.kept.clone(), ..Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()) };
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
self.map = c.map;
d.set(b"A".to_vec(), copied?);
+71
View File
@@ -4241,3 +4241,74 @@ fn an_outline_deeper_than_the_listing_is_exported_as_truncated() {
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
assert_eq!(crate::bookmarks(&target).len(), 33);
}
/// Does any object in `doc` hold the string value `/V (value)`?
fn holds_value(doc: &Document, value: &str) -> bool {
doc.scan_objects()
.into_iter()
.any(|(_, o)| o.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string()).is_some_and(|v| v.to_text() == value))
}
/// A field with a widget on each of two pages: extracting one page brings that widget and the field
/// above it, not the other kid with its value.
#[test]
fn extracting_a_page_leaves_the_rest_of_its_form_behind() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [8 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [9 0 R] >>".into(), // 4
raw("0 0 m"), // 5
raw("0 0 m"), // 6
"<< /T (applicant) /Kids [8 0 R 9 0 R] >>".into(), // 7
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Parent 7 0 R /P 3 0 R /Rect [0 0 50 20] >>".into(), // 8
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /Parent 7 0 R /P 4 0 R /Rect [0 0 50 20] >>".into(), // 9
];
let src = open(build(&b, "/Root 1 0 R"));
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(holds_value(&part, "Ada"));
assert!(!holds_value(&part, "90000"), "the widget on the page left behind came along with its value");
let form = part.resolve(catalog(&part).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
let fields = part.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
let applicant = part.resolve(&fields[0]).as_dict().cloned().unwrap();
assert_eq!(part.resolve(applicant.get(b"Kids").unwrap()).as_array().map(Vec::len), Some(1));
// With every page, the whole form comes along, as before.
let both = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert!(holds_value(&both, "Ada") && holds_value(&both, "90000"));
}
/// A form action lists fields by reference: extracting the page with the button doesn't bring
/// the fields it submits, resets or hides from a page left behind, with their values. A reset of
/// only those would list none, which reads as every field: it goes.
#[test]
fn a_form_action_brings_no_field_from_a_page_left_behind() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R 8 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [7 0 R 9 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [8 0 R] >>".into(), // 4
raw("0 0 m"), // 5
raw("0 0 m"), // 6
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (send) /P 3 0 R /Rect [0 0 50 20] \
/A << /S /SubmitForm /F (https://example.invalid/) /Fields [8 0 R (other)] \
/Next << /S /ResetForm /Fields [8 0 R] /Next << /S /Hide /T 8 0 R >> >> >> >>"
.into(), // 7
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /P 4 0 R /Rect [0 0 50 20] >>".into(), // 8
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (clear) /P 3 0 R /Rect [0 30 50 50] \
/A << /S /ResetForm /Fields [8 0 R] >> /AA << /U << /S /ResetForm /Flags 1 /Fields [8 0 R] >> >> >>"
.into(), // 9
];
let src = open(build(&b, "/Root 1 0 R"));
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(!holds_value(&part, "90000"), "a field the action lists came along from the page left behind");
let submit = part.resolve(annots(&part, 0)[0].get(b"A").unwrap()).as_dict().cloned().unwrap();
assert_eq!(part.resolve(submit.get(b"Fields").unwrap()).as_array().map(Vec::len), Some(1), "the field named by text stays");
assert!(submit.get(b"Next").is_none_or(|n| part.resolve(n).as_dict().is_none()), "the reset after the submit would clear every field");
let clear = annots(&part, 0).remove(1);
assert!(clear.get(b"A").is_none(), "the clear button would clear every field");
// A reset of every field but the one left behind still resets them all.
let aa = part.resolve(clear.get(b"AA").unwrap()).as_dict().cloned().unwrap();
assert!(aa.get(b"U").is_some_and(|u| part.resolve(u).as_dict().is_some()));
// With every page, the actions still list it.
assert!(holds_value(&full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()), "90000"));
}
+4 -1
View File
@@ -13,7 +13,10 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default()
- **Size**: fit (to the sheet minus an 18 pt margin), actual size, shrink oversized, custom %;
centred; auto orientation turns the sheet for landscape pages.
- **Multiple**: 2/4/6/9/16 (or any n) pages per sheet, horizontal/vertical (reversed) order,
page borders, auto-rotation of pages that don't match the cell.
page borders, auto-rotation of pages that don't match the cell. With auto orientation a
predefined count turns the sheet to suit its grid (2 per sheet prints side by side on
landscape). A custom grid (`Layout::grid`, `doc_print` `cols`/`rows`, 1-99 each) is laid
out across and down the sheet as given and doesn't turn it: auto prints portrait.
- **Cut and stack** (Multiple's page order): consecutive pages in each cell's pile,
with aligned cut marks in the gutters. For example, 10 pages at 4 per sheet give
`[1, 4, 7, 10]`, `[2, 5, 8, blank]`, `[3, 6, 9, blank]`. Print single-sided,
+1 -1
View File
@@ -37,7 +37,7 @@ pub use error::{PrintError, WORDINGS, Words, in_english};
use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints};
pub use imposition::{impose, keep_printed, sheet_pages};
pub use settings::{
Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex,
Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex,
cups_media, media_option,
};
#[cfg(test)]
+20 -1
View File
@@ -69,6 +69,11 @@ pub enum Layout {
order: PageOrder,
border: bool,
auto_rotate: bool,
/// A custom grid (the Print dialog's Custom pages per sheet): `cols` across and `rows`
/// down the sheet whatever its orientation, which is not picked for the grid, so Auto
/// prints portrait. A predefined count (`false`, [`Layout::multiple`]) is a grid on a
/// portrait sheet that turns to landscape when that suits it.
custom: bool,
},
Booklet {
subset: BookletSubset,
@@ -107,10 +112,24 @@ impl Layout {
(c, n.div_ceil(c).max(1))
}
};
Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true }
Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false }
}
/// A custom grid of `cols` across by `rows` down, each 1 to [`MAX_GRID`]; every cell holds a
/// page. `None` for a count outside that.
pub fn grid(cols: usize, rows: usize) -> Option<Layout> {
if !(1..=MAX_GRID).contains(&cols) || !(1..=MAX_GRID).contains(&rows) {
return None;
}
// At most 99 × 99, so this cannot overflow.
let per_sheet = cols.checked_mul(rows)?;
Some(Layout::Multiple { cols, rows, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: true })
}
}
/// The most columns, or rows, a custom pages-per-sheet grid can have.
pub const MAX_GRID: usize = 99;
/// Acrobat's Comments & Forms choices.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Content {
+8 -3
View File
@@ -164,8 +164,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
Layout::Label { flip } => {
sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip));
}
Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => {
let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256);
Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate, custom } => {
// A predefined count has at most 16 × 16 cells; a custom grid up to 99 each way.
let fits = if custom { cols <= crate::MAX_GRID && rows <= crate::MAX_GRID } else { cols.checked_mul(rows).is_some_and(|n| n <= 256) };
let cells = cols.checked_mul(rows).filter(|&n| n > 0 && fits);
let Some(cells) = cells else {
return Err(PrintError::Invalid("invalid number of pages per sheet".into()));
};
@@ -177,7 +179,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
// A grid with more columns than rows wants a landscape sheet, and the reverse.
let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?;
let page_landscape = first.0 > first.1;
let (gc, gr, sheet_landscape) = if cols == rows {
let (gc, gr, sheet_landscape) = if custom {
// A custom grid is laid out as given, on the sheet as chosen (Auto: portrait).
(cols, rows, false)
} else if cols == rows {
(cols, rows, page_landscape)
} else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) {
// 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper.
+45 -5
View File
@@ -159,7 +159,10 @@ fn multiple_pages_per_sheet() {
// Vertical order and borders.
let v = layout(
&sizes,
&settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false }),
&settings(
vec![0, 1, 2],
Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false, custom: false },
),
)
.unwrap();
assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]);
@@ -183,10 +186,11 @@ fn custom_pages_per_sheet_never_overfill_the_grid() {
assert_eq!(pages(4), [vec![0, 1, 2, 3], vec![4, 5, 6]]);
assert_eq!(pages(2), [vec![0, 1], vec![2, 3], vec![4, 5], vec![6]]);
// Cut-stack piles take the requested count too.
let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false };
let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false, custom: false };
assert_eq!(sheet_pages(&layout(&sizes, &settings((0..7).collect(), stacked)).unwrap()), [vec![0, 3, 6], vec![1, 4], vec![2, 5]]);
// A count larger than the grid is capped by the grid; no pages per sheet is refused.
let grid = |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false };
let grid =
|per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false };
assert_eq!(sheet_pages(&layout(&sizes[..3], &settings(vec![0, 1, 2], grid(9))).unwrap()), [vec![0, 1], vec![2]]);
assert!(matches!(layout(&sizes, &settings(vec![0], grid(0))), Err(PrintError::Invalid(_))));
// The imposed PDF has one page per sheet.
@@ -194,6 +198,34 @@ fn custom_pages_per_sheet_never_overfill_the_grid() {
assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3);
}
#[test]
fn a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet() {
// Columns across and rows down whatever the sheet, which isn't turned for the grid (#6551).
let sizes = vec![(200.0, 300.0); 4];
let at = |sheet: &Sheet, k: usize| (sheet.placed[k].matrix.0[4], sheet.placed[k].matrix.0[5]);
let grid = |cols, rows| Layout::grid(cols, rows).unwrap();
for (orientation, size) in
[(Orientation::Auto, (612.0, 792.0)), (Orientation::Portrait, (612.0, 792.0)), (Orientation::Landscape, (792.0, 612.0))]
{
let s = |layout| Settings { orientation, ..settings((0..4).collect(), layout) };
let across = layout(&sizes, &s(grid(2, 1))).unwrap();
assert_eq!((across.len(), across[0].size), (2, size), "{orientation:?}");
assert!(at(&across[0], 1).0 > at(&across[0], 0).0 && close(at(&across[0], 1).1, at(&across[0], 0).1), "side by side, {orientation:?}");
let down = layout(&sizes, &s(grid(1, 2))).unwrap();
assert_eq!(down[0].size, size, "{orientation:?}");
assert!(close(at(&down[0], 1).0, at(&down[0], 0).0) && at(&down[0], 1).1 < at(&down[0], 0).1, "stacked, {orientation:?}");
let three = layout(&sizes, &s(grid(3, 1))).unwrap();
assert_eq!(sheet_pages(&three), [vec![0, 1, 2], vec![3]]);
}
// 1 to 99 each way, and a grid too fine for the paper is refused, not drawn.
assert!(Layout::grid(0, 1).is_none() && Layout::grid(1, 0).is_none() && Layout::grid(100, 1).is_none() && Layout::grid(1, usize::MAX).is_none());
assert_eq!(layout(&sizes, &settings(vec![0], grid(17, 17))).unwrap()[0].placed.len(), 1);
assert!(matches!(layout(&sizes, &settings(vec![0], grid(99, 99))), Err(PrintError::Invalid(_))));
let hostile =
Layout::Multiple { cols: 100, rows: 1, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: true };
assert!(matches!(layout(&sizes, &settings(vec![0], hostile)), Err(PrintError::Invalid(_))));
}
#[test]
fn imposing_lists_the_pages_once() {
let n = 150;
@@ -450,7 +482,15 @@ fn driverless_printers_join_the_list_without_a_queue() {
}
fn cut_stack(cols: usize, rows: usize) -> Layout {
Layout::Multiple { cols, rows, per_sheet: cols.saturating_mul(rows), order: PageOrder::CutStack, border: false, auto_rotate: false }
Layout::Multiple {
cols,
rows,
per_sheet: cols.saturating_mul(rows),
order: PageOrder::CutStack,
border: false,
auto_rotate: false,
custom: false,
}
}
#[test]
@@ -503,7 +543,7 @@ fn cutting_and_stacking_recovers_every_selected_page() {
fn multiple_rejects_hostile_grids_without_panicking() {
let sizes = [(200.0, 300.0)];
for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] {
let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false };
let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false };
assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_))));
}
let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) };
+125 -6
View File
@@ -26,9 +26,10 @@ pub enum Hidden {
/// Optional content (layers) that is off: its content, then the layers themselves.
HiddenLayers,
Bookmarks,
/// Links, actions (open action, additional actions) and document JavaScript.
/// Links, actions (open action, additional actions, what bookmarks run) and document JavaScript.
LinksActionsScripts,
/// Private data of other applications (`/PieceInfo`).
/// Private data of other applications (`/PieceInfo`), and prepress data on images and Forms
/// (`/OPI`, `/Alternates`).
PrivateData,
}
@@ -173,11 +174,96 @@ fn outline_len_seen(
n
}
/// Takes out the action of outline item `d` when it does more than go to a place in this document
/// (a script, a launch, a web or remote link, a form submission); a jump with an action chained
/// after it (`/Next`) keeps only the jump. Items it links to that are written in place are cleaned
/// the same way, and items that are objects of their own go on `next`. Every link counts, not only
/// `/First` and `/Next`: a damaged outline may reach an item only backwards, and a full save keeps
/// that item too. `items` counts every item cleaned, inline ones included. Returns how many actions
/// went, or `None` past the depth limit or past [`OUTLINE_ITEM_BUDGET`] items.
fn clean_outline_item(doc: &Document, d: &mut Dict, next: &mut Vec<ObjRef>, items: &mut usize, depth: usize) -> Option<usize> {
*items = items.saturating_add(1);
if depth > MAX_DIRECT_DEPTH || *items > OUTLINE_ITEM_BUDGET {
return None;
}
let mut n = 0;
if let Some(action) = d.get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()) {
match (action.name(b"S"), action.get(b"D")) {
(Some(b"GoTo"), _) if !action.contains(b"Next") => {}
(Some(b"GoTo"), Some(place)) => {
let mut jump = Dict::new();
jump.set(b"S".to_vec(), Object::name("GoTo"));
jump.set(b"D".to_vec(), place.clone());
d.set(b"A".to_vec(), Object::Dict(jump));
n += 1;
}
_ => {
d.remove(b"A");
n += 1;
}
}
}
for key in [&b"First"[..], b"Last", b"Next", b"Prev", b"Parent"] {
match d.get_mut(key) {
Some(Object::Ref(r)) => next.push(*r),
Some(Object::Dict(kid)) => n += clean_outline_item(doc, kid, next, items, depth + 1)?,
_ => {}
}
}
Some(n)
}
/// [`clean_outline_item`] over the outline from the objects in `next`, after `items` items cleaned
/// already: the outline objects to write back, and how many actions went. Each object is read once;
/// `None` when the outline is larger than [`OUTLINE_ITEM_BUDGET`] items or nests in place past the
/// depth limit, so the removal can't be shown complete.
fn outline_without_actions(doc: &Document, mut next: Vec<ObjRef>, mut items: usize) -> Option<(Vec<(ObjRef, Dict)>, usize)> {
let (mut out, mut n, mut seen) = (Vec::new(), 0, std::collections::HashSet::new());
while let Some(r) = next.pop() {
if !seen.insert(r) {
continue;
}
if seen.len() > OUTLINE_ITEM_BUDGET {
return None;
}
let Some(mut d) = doc.get(r).as_dict().cloned() else { continue };
let went = clean_outline_item(doc, &mut d, &mut next, &mut items, 0)?;
if went > 0 {
out.push((r, d));
n += went;
}
}
Some((out, n))
}
/// How many outline actions Links, actions and JavaScripts would take out ([`clean_outline_item`]).
/// An outline too large or too deep to follow counts as one, so the removal runs and refuses it.
fn outline_action_count(doc: &Document, cat: &Dict) -> usize {
let (mut next, mut items) = (Vec::new(), 0);
let mut n = match cat.get(b"Outlines") {
Some(Object::Ref(r)) => {
next.push(*r);
0
}
Some(Object::Dict(o)) => clean_outline_item(doc, &mut o.clone(), &mut next, &mut items, 0).unwrap_or(1),
_ => return 0,
};
n += outline_without_actions(doc, next, items).map_or(1, |(_, went)| went);
n
}
/// Entries that hold a category's data wherever they appear, not only on the catalog and pages:
/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and
/// annotations, additional actions on fields.
const OWNED: [(Hidden, &[u8]); 4] =
[(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")];
/// annotations, additional actions on fields. Images and Forms also carry prepress data: an OPI
/// proxy's link to the original file, and alternate images, which can be the unedited original.
const OWNED: [(Hidden, &[u8]); 6] = [
(Hidden::Metadata, b"Metadata"),
(Hidden::PrivateData, b"PieceInfo"),
(Hidden::PrivateData, b"OPI"),
(Hidden::PrivateData, b"Alternates"),
(Hidden::Attachments, b"AF"),
(Hidden::LinksActionsScripts, b"AA"),
];
/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph
/// procedures, named destinations, role and class maps, private data by application). A name
@@ -464,7 +550,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
// A graph nested too deeply to walk counts as holding everything, so the removal runs and
// refuses it.
let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0);
let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0);
let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::<usize>();
let mut doc2 = doc.clone();
HIDDEN
.into_iter()
@@ -502,6 +588,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
+ owned(Hidden::LinksActionsScripts)
+ annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count()
+ sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t))
+ outline_action_count(doc, &cat)
}
Hidden::PrivateData => owned(Hidden::PrivateData),
};
@@ -510,6 +597,34 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
.collect()
}
/// Take from the outline of catalog `cat` every action that does more than go to a place in this
/// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what
/// they run. An outline too large or nested too deep to follow is refused, not left half cleaned:
/// neither `cat` nor `doc` changes until the whole outline is cleaned.
fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), RedactError> {
let (mut next, mut items) = (Vec::new(), 0);
let root = match cat.get(b"Outlines") {
Some(Object::Dict(o)) => {
let mut o = o.clone();
clean_outline_item(doc, &mut o, &mut next, &mut items, 0).ok_or(RedactError::TooDeep)?;
Some(o)
}
Some(Object::Ref(r)) => {
next.push(*r);
None
}
_ => None,
};
let (objs, _) = outline_without_actions(doc, next, items).ok_or(RedactError::TooDeep)?;
if let Some(o) = root {
cat.set(b"Outlines".to_vec(), Object::Dict(o));
}
for (r, d) in objs {
doc.set(r, Object::Dict(d));
}
Ok(())
}
/// Remove the chosen categories. Returns what was removed per category.
///
/// # Errors
@@ -628,6 +743,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
if let Some(n) = names.as_mut() {
n.remove(b"JavaScript");
}
// Bookmarks that stay keep where they go, not what they run.
if !which.contains(&Hidden::Bookmarks) {
strip_outline_actions(doc, &mut cat)?;
}
}
Hidden::HiddenLayers if !off.is_empty() => {
// The off layers leave the configuration (their content is gone already).
+93
View File
@@ -2853,6 +2853,73 @@ fn a_remove_hidden_copy_gets_a_new_identifier() {
assert_ne!(ids[1], vec![0x22; 16], "the copy keeps the source's second identifier string");
}
/// Links, actions and JavaScripts takes the scripts out of bookmarks that stay: an item that runs
/// JavaScript loses its action, a jump with a script chained after it keeps only the jump, and a
/// plain jump is left alone.
#[test]
fn bookmarks_that_stay_lose_their_scripts() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
stream("", b""),
b"<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>".to_vec(),
// 6 runs a script, 7 jumps and then runs one, 8 only jumps; 9 the script
b"<< /Title (Runs) /Parent 5 0 R /Next 7 0 R /A 9 0 R >>".to_vec(),
b"<< /Title (Jumps then runs) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >> >>".to_vec(),
b"<< /Title (Jumps) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [3 0 R /Fit] >> >>".to_vec(),
b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(),
]);
assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 2)));
assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 2)]);
let item = |n: u32| doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap();
assert!(!item(6).contains(b"A"), "{:?}", item(6));
let jump = |n: u32| item(n).get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()).unwrap();
assert_eq!((jump(7).name(b"S"), jump(7).contains(b"D"), jump(7).contains(b"Next")), (Some(&b"GoTo"[..]), true, false));
assert_eq!(jump(8).name(b"S"), Some(&b"GoTo"[..]));
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(bytes.windows(7).any(|w| w == b"(Jumps)"), "the bookmarks stay");
assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "the bookmark's script survived the save");
}
/// A damaged outline may reach an item only backwards (`/Last`, `/Prev`, `/Parent`). The full save
/// keeps that item all the same, so its script goes too.
#[test]
fn bookmarks_reached_only_backwards_lose_their_scripts() {
use crate::sanitize::{Hidden, remove_hidden, scan};
// /First and /Next reach only 6; 7, 8 and 9 hang off its /Last, /Prev and /Parent and share script 10
let mut doc = outlined_document(vec![
b"<< /Title (Reached) /Last 7 0 R /Prev 8 0 R /Parent 9 0 R >>".to_vec(),
b"<< /Title (Last) /A 10 0 R >>".to_vec(),
b"<< /Title (Prev) /A 10 0 R >>".to_vec(),
b"<< /Title (Parent) /A 10 0 R >>".to_vec(),
b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(),
]);
assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 3)));
assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 3)]);
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(bytes.windows(8).any(|w| w == b"(Parent)"), "the bookmarks stay");
assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "a script reached only backwards survived the save");
}
/// Outline items written in place count towards the outline budget too: 60,000 items with an inline
/// child each are 120,000 items, so the outline is refused rather than walked past the budget.
#[test]
fn an_outline_past_the_budget_with_inline_items_is_refused() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let siblings = 60_000;
let mut items = Vec::with_capacity(siblings);
for i in 0..siblings {
let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() };
items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes());
}
let mut doc = outlined_document(items);
assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 1)));
let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]);
assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}");
}
/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
/// annotations, the URL and the comment text included, while the page no longer lists them.
@@ -4513,3 +4580,29 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() {
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file");
}
/// Private data takes an image's prepress data too: its OPI link to the original file and its
/// alternate images, which can be the unedited original.
#[test]
fn private_data_takes_opi_links_and_alternate_images() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R /Fm 6 0 R >> >> >>".to_vec(),
stream("", b"q 50 0 0 50 20 20 cm /Im Do Q /Fm Do"),
stream(
"/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Alternates [<< /Image 7 0 R /DefaultForPrinting true >>]",
b"SHOWNIMAGE!",
),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /OPI << /1.3 << /Type /OPI /Version 1.3 /F (OPISERVERPATH.tif) >> >>", b""),
stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"ALTERNATEORIGINAL"),
]);
assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::PrivateData), Some((Hidden::PrivateData, 2)));
assert_eq!(remove_hidden(&mut doc, &[Hidden::PrivateData]).unwrap(), [(Hidden::PrivateData, 2)]);
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(has(b"SHOWNIMAGE!"), "the image the page shows stays");
assert!(!has(b"ALTERNATEORIGINAL"), "the alternate image survived the save");
assert!(!has(b"OPISERVERPATH"), "the OPI link survived the save");
}
+89 -9
View File
@@ -118,6 +118,16 @@ impl<'a> Inspector<'a> {
let mut comments_full = false;
for (page, index, a) in entries {
let Some(d) = self.dict(a) else { continue };
// An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166):
// a font or other dictionary a page lists among its annotations isn't one, and neither is
// one whose `/Type` isn't a name at all. A null or dangling `/Type` counts as absent
// (§7.3.9: a null value is the same as leaving the entry out).
if let Ok(t) = d.get(b"Type").map(|t| self.resolve(t))
&& !matches!(t, Object::Null | Object::Reference(_))
&& t.as_name().ok() != Some(b"Annot".as_slice())
{
continue;
}
let Some(subtype) = self.name(d, b"Subtype") else { continue };
if subtype == "FileAttachment"
&& let Ok(fs) = d.get(b"FS")
@@ -370,14 +380,14 @@ impl<'a> Inspector<'a> {
o: &'a Object,
parent: Option<(&str, Option<String>, Option<i64>)>,
out: &mut Vec<Field>,
seen: &mut HashSet<ObjectId>,
walk: &mut FieldWalk,
depth: u32,
) {
if depth > 32 {
return;
}
if let Object::Reference(id) = o
&& !seen.insert(*id)
&& !walk.seen.insert(*id)
{
return;
}
@@ -396,10 +406,16 @@ impl<'a> Inspector<'a> {
Ok(Object::Array(k)) => k.as_slice(),
_ => &[],
};
let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect();
let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok()));
let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect();
if !field_kids.is_empty() {
let id = if let Object::Reference(id) = o { Some(*id) } else { None };
walk.path.extend(id);
for k in field_kids {
self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1);
self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1);
}
if id.is_some() {
walk.path.pop();
}
return;
}
@@ -429,14 +445,67 @@ impl<'a> Inspector<'a> {
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
_ => None,
};
// The widget is either this dict (merged field/widget) or its first kid.
let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) };
let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids));
// The widget is either this dict (merged field/widget) or its first kid. With no widget
// among its kids, it is the first page widget naming this field as its `/Parent`: a child
// names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and PDFium read it.
let merged = d.get(b"Rect").is_ok();
let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) };
let widget = if merged {
Some(d)
} else {
widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok()))
};
let page = widget
.and_then(|w| w.get(b"P").ok())
.and_then(|p| self.page_of(p))
.or_else(|| self.find_widget_page(o, kids))
.or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied()));
let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok());
let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r)));
out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect });
}
/// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself
/// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't
/// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224),
/// and a child names its parent (§12.7.4.1, Table 226).
fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool {
let Object::Reference(k) = k else { return false };
let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
_ => None,
});
let o = if let Object::Reference(o) = o { Some(*o) } else { None };
o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o))
}
/// The first page widget that names field `o` as its `/Parent` and is not a field of its own
/// (no `/T`), in page-annotation order.
fn widget_naming(&self, o: &Object) -> Option<ObjectId> {
let Object::Reference(id) = o else { return None };
self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied()
}
/// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order.
fn parent_widgets(&self) -> HashMap<ObjectId, ObjectId> {
let mut first = HashMap::new();
for &pid in &self.page_ids {
let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue };
let Ok(annots) = self.resolve(annots).as_array() else { continue };
for a in annots {
let Object::Reference(id) = a else { continue };
let Ok(w) = self.doc.get_dictionary(*id) else { continue };
if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() {
continue;
}
if let Ok(Object::Reference(parent)) = w.get(b"Parent") {
first.entry(*parent).or_insert(*id);
}
}
}
first
}
/// The first page whose `/Annots` lists the field or one of its kids.
fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option<usize> {
let pages = self.annotation_pages.get_or_init(|| self.annotation_pages());
@@ -458,7 +527,7 @@ impl<'a> Inspector<'a> {
pages
}
/// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an
/// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
let mut top = id;
@@ -466,8 +535,10 @@ impl<'a> Inspector<'a> {
loop {
let o = Object::Reference(top);
let d = self.dict(&o)?;
// A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as
// `null`, the same as no parent (ISO 32000-2 §7.3.9).
let parent = match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p),
_ => None,
};
let Some(p) = parent else {
@@ -484,6 +555,15 @@ impl<'a> Inspector<'a> {
}
}
/// What one reading of the form's fields keeps: the fields read, the root fields `/Fields` lists,
/// and the fields whose `/Kids` are being read, outermost first.
#[derive(Default)]
pub(super) struct FieldWalk {
pub(super) seen: HashSet<ObjectId>,
pub(super) roots: HashSet<ObjectId>,
path: Vec<ObjectId>,
}
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
/// `u32` counts as no flags.
fn draw_flags(o: &Object) -> u32 {
+10 -6
View File
@@ -1,4 +1,4 @@
use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES;
use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES};
use super::model::{AttachmentSource, PageInfo, Xfa};
use super::navigation::text_string;
use super::{
@@ -144,13 +144,16 @@ pub(super) struct Inspector<'a> {
/// has no `/P`. Searching every page's `/Annots` for each such field was O(fields ×
/// annotations): a second on a form of 8,000 fields.
pub(super) annotation_pages: OnceCell<HashMap<ObjectId, usize>>,
/// The first page widget naming each field as its `/Parent`, built when a field has no widget
/// among its `/Kids`.
pub(super) parent_widgets: OnceCell<HashMap<ObjectId, ObjectId>>,
}
impl<'a> Inspector<'a> {
pub(super) fn new(doc: &'a dyn Structure) -> Self {
let page_ids = doc.page_ids();
let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() };
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() };
let mut named = HashMap::new();
if let Some(tree) =
doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o))
@@ -210,12 +213,13 @@ impl<'a> Inspector<'a> {
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
self.annotations(info, &mut text_budget);
self.transitions(info);
let mut field_seen = HashSet::new();
let mut walk = FieldWalk::default();
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
&& let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array())
{
walk.roots = fields.iter().filter_map(|f| if let Object::Reference(id) = f { Some(*id) } else { None }).collect();
for f in fields {
self.field(f, None, &mut info.fields, &mut field_seen, 0);
self.field(f, None, &mut info.fields, &mut walk, 0);
}
}
// Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them);
@@ -231,9 +235,9 @@ impl<'a> Inspector<'a> {
continue;
}
if let Object::Reference(id) = a
&& let Some(root) = self.unlisted_field(*id, &field_seen)
&& let Some(root) = self.unlisted_field(*id, &walk.seen)
{
self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0);
self.field(&Object::Reference(root), None, &mut info.fields, &mut walk, 0);
}
}
}
+105
View File
@@ -786,6 +786,26 @@ fn fields_listed_only_on_the_pages_are_listed() {
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// is listed as a field of its own: that parent reads as `null`, the same
/// as none (ISO 32000-2 §7.3.9).
#[test]
fn a_widget_whose_parent_is_missing_is_listed() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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] /Annots [5 0 R 6 0 R] >> endobj
4 0 obj << /Fields [] >> endobj
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj
7 0 obj [1 2 3] endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect();
assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields);
}
/// A `Structure` that counts the objects read through it.
struct CountingReads<'a> {
doc: &'a Document,
@@ -805,6 +825,30 @@ impl Structure for CountingReads<'_> {
}
}
/// A font (or any dictionary whose `/Type` is present and isn't `/Annot`) a page lists in `/Annots`
/// isn't an annotation (ISO 32000-2 §12.5.2, Table 166) and isn't listed as a comment; the real ones
/// around it keep their place in the array, and an entry without `/Type` is still read as one. A
/// `/Type` that isn't a name (`/Type 42`) isn't `/Annot` either, while `/Type null` counts as absent.
#[test]
fn dictionaries_of_another_type_in_annots_are_not_annotations() {
let pdf = b"%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 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (typed) >> endobj
6 0 obj << /Type /Font /Subtype /TrueType /BaseFont /Arial >> endobj
7 0 obj << /Subtype /Square /Rect [40 40 60 60] /Contents (untyped) >> endobj
8 0 obj << /Type 42 /Subtype /Text /Rect [70 70 90 90] /Contents (number type) >> endobj
9 0 obj << /Type null /Subtype /Circle /Rect [100 100 120 120] /Contents (null type) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let mut listed: Vec<_> = info.annotations.iter().map(|a| (a.subtype.as_str(), a.index, a.contents.as_deref())).collect();
listed.sort_by_key(|a| a.1);
assert_eq!(listed, [("Text", 1, Some("typed")), ("Square", 3, Some("untyped")), ("Circle", 5, Some("null type"))], "{:?}", info.annotations);
}
/// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad
/// with a non-number is left out rather than drawn with a zero, an indirect number is followed,
/// and a trailing partial quad is dropped.
@@ -1966,3 +2010,64 @@ fn inspection_keeps_no_private_page_data() {
};
assert_eq!(report(with), report(without), "what inspection reports does not change");
}
/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its
/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field
/// keeps its other kids and is listed with them.
#[test]
fn kids_that_loop_back_or_are_roots_are_not_children() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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] /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj
4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj
5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj
6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj
8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj
9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj
10 0 obj << /T (group) /Kids [13 0 R] >> endobj
12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj
14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj
15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect();
assert_eq!(
listed,
[
("size", FieldKind::Radio, Some(0), Some(150.0)),
("left", FieldKind::Radio, Some(0), Some(120.0)),
("right", FieldKind::Radio, Some(0), Some(90.0)),
("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)),
],
"{:?}",
info.fields
);
}
/// A field with no widget among its `/Kids` takes the page widget that names it as its
/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other`
/// list each other (roots, so not children), and only a page widget says it belongs to `other`.
#[test]
fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 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] /Annots [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj
5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj
6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj
7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj
8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj
9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj
11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj
13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect();
assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields);
}
+17
View File
@@ -4073,6 +4073,23 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[
}
}
/// A `JBIG2Decode` image whose data starts with a JBIG2 file header (ISO 14492 D.4) draws, in
/// either organization the header declares (hayro-jbig2 patch 17): sequential (flags bit 0 is 1,
/// D.1) and random access (bit 0 is 0, D.2: the segment headers, ending with an end of file
/// segment, then their data). A header with reserved flag bits set leaves the image out.
#[test]
fn a_jbig2_image_with_a_file_header_draws() {
const ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A];
let page = black_jbig2_page();
let header = |flags: u8| [&ID[..], &[flags], &1u32.to_be_bytes()].concat();
let (page_header, page_data) = page.split_at(11);
let end_of_file = [0, 0, 0, 1, 51, 0, 1, 0, 0, 0, 0];
assert!(jbig2_image_draws(&page), "without a header");
assert!(jbig2_image_draws(&[&header(0x01)[..], &page].concat()), "sequential");
assert!(jbig2_image_draws(&[&header(0x00)[..], page_header, &end_of_file, page_data].concat()), "random access");
assert!(!jbig2_image_draws(&[&header(0x11)[..], &page].concat()), "reserved flag bits");
}
/// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with
/// its own. The images differ by an extension segment: one image decoded again isn't decoded twice.
#[test]
+3 -1
View File
@@ -131,7 +131,9 @@ impl PageText {
}
/// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks,
/// which count as a single space).
/// which count as a single space, or as nothing where the line runs on between two Chinese or
/// Japanese characters or ends in a soft hyphen: there a query with a space across the break
/// doesn't match, and one without does).
pub fn find(&self, needle: &str) -> Vec<std::ops::Range<usize>> {
self.find_opts(needle, false, false)
}
+134 -4
View File
@@ -1,4 +1,8 @@
//! Literal search in normalized page text, retaining the original glyph owners.
//!
//! Words and lines are separated by one space, except where a line of Chinese or Japanese runs on
//! to the next: a break between two such characters is no word break, so it reads as nothing and
//! "検知機構" is found when the line ends after "検知".
use super::{PageText, fold_case};
use std::ops::Range;
@@ -7,21 +11,51 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
return Vec::new();
}
let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) };
let needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect();
let needle: Vec<char> = query.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
if needle.is_empty() {
return Vec::new();
}
let mut chars: Vec<(char, usize)> = Vec::new();
// Whether a skipped ZERO WIDTH SPACE came right before each character: it draws nothing, but
// it is a word boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words
// with no visible gap, so whole-word search still sees two words there.
let mut zero_width_space: Vec<bool> = Vec::new();
let mut pending_zero_width_space = false;
// The last character pushed other than a variation selector, kept as characters are pushed so
// a long run of selectors costs no rescan.
let mut last_base: Option<char> = None;
for (i, glyph) in page.glyphs.iter().enumerate() {
let text = fold(&glyph.text);
let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b);
let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break;
// A soft hyphen marks where a word may be divided at a line end (ISO 32000-2 §14.8.2.3), so
// a line that ends in one runs on to the next with no word break between them.
// Other invisible characters after the soft hyphen don't hide it.
let soft_break = line_break && last_kept(page, i) == Some('\u{AD}');
// So does a line of Chinese or Japanese that runs on to the next. The characters on both
// sides of the break are the visible ones: invisible characters are skipped below, and a
// variation selector after the last one only picks its glyph.
let joined = line_break && last_base.is_some_and(unspaced) && first_visible(page, i).is_some_and(unspaced) && wraps(page, i);
let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break));
if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') {
chars.push((' ', i));
last_base = Some(' ');
zero_width_space.push(false);
pending_zero_width_space = false;
}
for c in fold(&glyph.text).chars() {
for c in text.chars() {
if is_invisible(c) {
pending_zero_width_space |= c == '\u{200B}';
continue;
}
let c = if c.is_whitespace() { ' ' } else { c };
if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {
chars.push((c, i));
if !is_variation_selector(c) {
last_base = Some(c);
}
zero_width_space.push(pending_zero_width_space && c != ' ');
pending_zero_width_space = false;
}
}
}
@@ -48,7 +82,9 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
}
let end = i.saturating_add(1);
let start = end.saturating_sub(needle.len());
let bounded = !whole_words || (!word(start.checked_sub(1)) && !word(Some(end)));
// A skipped zero width space before the first character or after the last is a boundary.
let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false);
let bounded = !whole_words || ((after_break(start) || !word(start.checked_sub(1))) && (after_break(end) || !word(Some(end))));
if bounded {
if let Some((_, first)) = chars.get(start)
&& let Some(last) = owner.checked_add(1)
@@ -68,6 +104,100 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
hits
}
/// A character that draws nothing inside a word and only tells layout where a line may break or
/// letters may join: SOFT HYPHEN, ZERO WIDTH SPACE, ZERO WIDTH NON-JOINER and JOINER, WORD JOINER
/// and ZERO WIDTH NO-BREAK SPACE. All six are Unicode default-ignorable format characters, so
/// find skips them in the page text and in the query alike: "Mitarbeiterinnen" matches a word
/// stored with soft hyphens. A visible word gap is its own space in the page text, so skipping a
/// zero width space next to one still keeps the two words apart.
fn is_invisible(c: char) -> bool {
matches!(c, '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')
}
/// A variation selector (VS1-VS16 and VS17-VS256): it picks a variant glyph of the character
/// before it and is no character of its own.
fn is_variation_selector(c: char) -> bool {
matches!(c as u32, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
}
/// The last character before glyph `i` on the line that glyph `i - 1` ends, other than the
/// invisible characters find skips; a soft hyphen counts, since it marks a divided word. Looks
/// back no further than that line's start, so each line is read once.
fn last_kept(page: &PageText, i: usize) -> Option<char> {
let end = i.checked_sub(1)?;
let line = page.line_of.get(end)?;
(0..=end)
.rev()
.take_while(|&k| page.line_of.get(k) == Some(line))
.filter_map(|k| page.glyphs.get(k))
.flat_map(|g| g.text.chars().rev())
.find(|&c| c == '\u{AD}' || !is_invisible(c))
}
/// Whether `c` is set solid, with no spaces between words, in text that a line break may divide
/// anywhere: East Asian Width Wide, Fullwidth or Halfwidth (Unicode Standard Annex #11) Han,
/// kana, bopomofo, CJK symbols and punctuation and fullwidth forms other than Latin letters and
/// digits. Hangul is left out: Korean separates its words with spaces, so a line end there is a
/// word break. A line break between two such characters is the CSS segment-break rule's case for removing the break, not turning
/// it into a space (CSS Text Module, segment break transformation).
fn unspaced(c: char) -> bool {
matches!(c as u32,
0x2E80..=0x2FFF // CJK radicals, Kangxi radicals, ideographic description characters
| 0x3001..=0x303E // CJK symbols and punctuation (U+3000 is a space; U+303F is narrow)
| 0x3041..=0x30FF // Hiragana, Katakana
| 0x3105..=0x312F // Bopomofo
| 0x3190..=0x31FF // Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
| 0x3200..=0x4DBF // Enclosed CJK letters, CJK compatibility, Han extension A
| 0x4E00..=0x9FFF // Han
| 0xF900..=0xFAFF // CJK compatibility ideographs
| 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical, compatibility and small forms
// Fullwidth forms, less the fullwidth digits and Latin letters (U+FF10-U+FF19, U+FF21-U+FF3A,
// U+FF41-U+FF5A): those are Latin text set wide, whose words still end at a line end.
| 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF60 | 0xFFE0..=0xFFE6
| 0xFF61..=0xFF9F // halfwidth CJK punctuation and Katakana (not halfwidth Hangul)
| 0x16FE0..=0x16FFF | 0x1AFF0..=0x1B2FF // ideographic symbols, kana supplements and extensions
| 0x20000..=0x3FFFD // Han extensions B and later, compatibility supplement
) && !matches!(c as u32, 0x3200..=0x321E | 0x3260..=0x327E) // parenthesised and circled Hangul
}
/// Whether glyph `i` starts the line that the text of glyph `i - 1` runs on to: both run the same
/// way, it sits past that glyph across the lines, by more than half the glyph's line height and
/// at most three, and starts no further along the line than half a line height past it, as a
/// wrapped line does (a line of one character starts level with the one before). Text set side by
/// side on one line (table cells, labels), a cell set lower and further along the row, a column
/// that starts back at the top, a block further down the page and a flow in another direction
/// keep their word break.
fn wraps(page: &PageText, i: usize) -> bool {
// Measured on the glyphs that own the visible characters either side of the break: an
// invisible glyph or a variation selector of its own (drawn with no height) says nothing about
// the lines. Each search stays within one line.
let Some(end) = i.checked_sub(1) else { return false };
let visible = |k: &usize| page.glyphs.get(*k).is_some_and(|g| g.text.chars().any(|c| !is_invisible(c) && !is_variation_selector(c)));
let same_line = |k: &usize, of: usize| page.line_of.get(*k).is_some() && page.line_of.get(*k) == page.line_of.get(of);
let before = (0..=end).rev().take_while(|k| same_line(k, end)).find(visible).unwrap_or(end);
let after = (i..page.glyphs.len()).take_while(|k| same_line(k, i)).find(visible).unwrap_or(i);
let Some((before, after)) = page.glyphs.get(before).zip(page.glyphs.get(after)) else { return false };
// Unit directions: the same flow has a dot product of 1.
let same_flow = before.direction[0] * after.direction[0] + before.direction[1] * after.direction[1] > 0.99;
let centre = |q: &[[f32; 2]; 4]| [q.iter().map(|p| p[0]).sum::<f32>() / 4.0, q.iter().map(|p| p[1]).sum::<f32>() / 4.0];
let (a, b) = (centre(&before.quad), centre(&after.quad));
// Towards the next line: the writing direction turned a quarter clockwise in y-down view space.
let [dx, dy] = before.direction;
let across = (b[0] - a[0]) * -dy + (b[1] - a[1]) * dx;
let along = (b[0] - a[0]) * dx + (b[1] - a[1]) * dy;
let [near, far] = [before.quad[0], before.quad[3]];
let height = (near[0] - far[0]).hypot(near[1] - far[1]);
// NaN compares false: no join.
same_flow && across > height * 0.5 && across <= height * 3.0 && along < height * 0.5
}
/// The first visible character on the line that glyph `i` starts, skipping the invisible characters
/// that find drops, so a line that opens with a zero width character still joins on its first letter.
fn first_visible(page: &PageText, i: usize) -> Option<char> {
let line = page.line_of.get(i)?;
page.glyphs.iter().zip(&page.line_of).skip(i).take_while(|(_, l)| *l == line).flat_map(|(g, _)| g.text.chars()).find(|&c| !is_invisible(c))
}
/// Extend a prefix, falling back through strictly shorter proper prefixes on a mismatch.
fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> usize {
while matched > 0 && needle.get(matched) != Some(&c) {
+517 -9
View File
@@ -12,28 +12,53 @@ fn page(parts: &[&str]) -> PageText {
// The original straightforward search is an independent oracle for valid page metadata.
fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec<Range<usize>> {
let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) };
let needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
// Soft hyphens and zero width characters draw nothing and are skipped on both sides.
let visible =
|s: String| s.chars().filter(|c| !matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')).collect::<String>();
let needle: Vec<char> = visible(fold(query)).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
if needle.is_empty() || limit == 0 {
return Vec::new();
}
let mut chars = Vec::new();
// Each character, its glyph, and whether a skipped zero width space came right before it.
let mut chars: Vec<(char, usize, bool)> = Vec::new();
let mut after_zwsp = false;
for (i, glyph) in page.glyphs.iter().enumerate() {
if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') {
chars.push((' ', i));
// A line that ends in a soft hyphen, past any empty or other invisible glyphs and
// characters after it on that line, runs on to the next.
let ends_in_soft_hyphen = |end: usize| {
(0..=end)
.rev()
.take_while(|&k| page.line_of[k] == page.line_of[end])
.flat_map(|k| page.glyphs[k].text.chars().rev())
.find(|&c| c == '\u{AD}' || !visible(c.to_string()).is_empty())
== Some('\u{AD}')
};
let line_break = i > 0 && page.line_of[i] != page.line_of[i - 1] && !ends_in_soft_hyphen(i - 1);
if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') {
chars.push((' ', i, false));
after_zwsp = false;
}
for c in fold(&glyph.text).chars() {
after_zwsp |= c == '\u{200B}';
if visible(c.to_string()).is_empty() {
continue;
}
let c = if c.is_whitespace() { ' ' } else { c };
if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') {
chars.push((c, i));
if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') {
chars.push((c, i, after_zwsp && c != ' '));
after_zwsp = false;
}
}
}
let word = |i: Option<usize>| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric());
let word = |i: Option<usize>| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric());
// A word ends at a skipped zero width space as it does at a space.
let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp);
let mut hits = Vec::new();
let mut start = 0;
while start + needle.len() <= chars.len() {
let end = start + needle.len();
if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) {
let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end)));
if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) {
hits.push(chars[start].1..chars[end - 1].1 + 1);
if hits.len() == limit {
break;
@@ -69,7 +94,7 @@ fn whole_word_rejection_keeps_overlapping_candidates() {
#[test]
fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() {
let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"];
let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"];
let mut seed = 1234567u64;
let mut next = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
@@ -110,3 +135,486 @@ fn long_repeated_prefix_search_preserves_the_last_match_and_limit() {
assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]);
assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty());
}
/// pdf.js issue 13226 stores "Mitarbeiterinnen" with soft hyphens at its hyphenation points, and
/// issue 9186 puts zero width spaces next to the gaps between words. Find skips both characters
/// (and the other invisible joiners) in the page and in the query, and a match still starts and
/// ends on visible letters.
#[test]
fn find_skips_soft_hyphens_and_zero_width_characters() {
let word = page(&["M", "i", "t", "\u{AD}", "a", "r", "b", "e", "i", "\u{AD}", "t", "e", "r", "i", "n", "n", "e", "n"]);
assert_eq!(word.find("Mitarbeiterinnen"), vec![0..18]);
assert_eq!(word.find_opts("Mitarbeiterinnen", true, true), vec![0..18]);
assert_eq!(word.find("arbei"), vec![4..9]);
// Inside one word, a piece of it is not a whole word.
assert!(word.find_opts("terinnen", false, true).is_empty());
// A query that carries the soft hyphens still matches.
assert_eq!(word.find("Mit\u{AD}arbei\u{AD}terinnen"), vec![0..18]);
let mut gaps = page(&["is", "\u{200B}", "\u{200B}", "made", "\u{2060}", "as"]);
gaps.space_before = vec![false, false, true, false, false, true];
assert_eq!(gaps.find("is made"), vec![0..4]);
assert_eq!(gaps.find_opts("made as", false, true), vec![3..6]);
for joiner in ['\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}'] {
assert_eq!(page(&["ab", &joiner.to_string(), "cd"]).find("abcd"), vec![0..3], "{joiner:?}");
// The same character typed in the query is skipped too.
assert_eq!(page(&["ab", "cd"]).find_opts(&format!("ab{joiner}cd"), true, false), vec![0..2], "{joiner:?}");
}
assert!(page(&["\u{200B}"]).find("\u{200B}").is_empty());
}
/// A soft hyphen at a line end is where the word was divided (ISO 32000-2 §14.8.2.3): the break
/// after it is no word break, so "Mitar\u{AD}" ending one line and "beiterinnen" starting the next
/// are found as one word, highlighted on both lines. A line break after any other glyph still
/// separates words.
#[test]
fn find_joins_a_word_divided_at_a_line_end_soft_hyphen() {
let parts: Vec<String> = "Mitar\u{AD}beiterinnen".chars().map(String::from).collect();
let mut text = page(&parts.iter().map(String::as_str).collect::<Vec<_>>());
for (i, glyph) in text.glyphs.iter_mut().enumerate() {
let x = i as f32 * 10.0;
glyph.rect = [x, 0.0, x + 10.0, 10.0];
}
// "Mitar" and the soft hyphen on the first line, "beiterinnen" on the second.
for line in text.line_of.iter_mut().skip(6) {
*line = 1;
}
let hits = text.find("Mitarbeiterinnen");
assert_eq!(hits, vec![0..17]);
assert_eq!(text.line_rects(hits[0].clone()).len(), 2);
assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![0..17]);
assert!(text.find("Mitar beiterinnen").is_empty());
// The soft hyphen as the last character of a longer glyph works the same way.
let mut text = page(&["Mitar\u{AD}", "beiterinnen"]);
text.line_of = vec![0, 1];
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..2]);
// Without the soft hyphen the line break is a word break.
let mut text = page(&["Mitar", "beiterinnen"]);
text.line_of = vec![0, 1];
assert!(text.find("Mitarbeiterinnen").is_empty());
assert_eq!(text.find("Mitar beiterinnen"), vec![0..2]);
}
/// Horizontal glyphs of `size` points, one per character, each line `lead` points below the
/// last and starting at `x`: (x, first line's top, text) per line.
fn laid_out(lines: &[(f32, f32, &str)], size: f32) -> PageText {
let mut glyphs = Vec::new();
for &(x0, top, text) in lines {
for (k, c) in text.chars().enumerate() {
let x = x0 + size * k as f32;
let (y0, y1) = (top, top + size);
glyphs.push(TextGlyph {
text: c.into(),
rect: [x, y0, x + size, y1],
quad: [[x, y1], [x + size, y1], [x + size, y0], [x, y0]],
direction: [1.0, 0.0],
});
}
}
pdfcraft_render::text::layout(glyphs)
}
#[test]
fn a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines() {
// "検知機構" wraps after "検知" (pdf.js #15340); "ソレノイド" after "ソレ".
let text = laid_out(&[(10.0, 10.0, "装置の検知"), (10.0, 25.0, "機構です"), (10.0, 40.0, "ソレ"), (10.0, 55.0, "ノイド")], 10.0);
assert_eq!(text.line_of.iter().copied().max(), Some(3));
let hits = text.find("検知機構");
assert_eq!(hits, vec![3..7]);
// One highlight per line, the end of the first line and the start of the second.
assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 10.0, 60.0, 20.0], [10.0, 25.0, 30.0, 35.0]]);
assert_eq!(text.find("ソレノイド"), vec![9..14]);
// The query typed with a space still finds nothing across the joined break.
assert!(text.find("検知 機構").is_empty());
// Copied text keeps the line break.
assert_eq!(text.text_of(3..7), "検知\n機構");
}
#[test]
fn a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text() {
// Latin and Korean separate words with spaces: the line end is one.
let latin = laid_out(&[(10.0, 10.0, "abc"), (10.0, 25.0, "def")], 10.0);
assert_eq!(latin.find("abc def"), vec![0..6]);
assert!(latin.find("abcdef").is_empty());
let mixed = laid_out(&[(10.0, 10.0, "PDF"), (10.0, 25.0, "検知")], 10.0);
assert_eq!(mixed.find("PDF 検知"), vec![0..5]);
assert!(mixed.find("PDF検知").is_empty());
let hangul = laid_out(&[(10.0, 10.0, "한국"), (10.0, 25.0, "어")], 10.0);
assert_eq!(hangul.find("한국 어"), vec![0..3]);
assert!(hangul.find("한국어").is_empty());
// Two cells on one row read as two lines, but the second does not continue the first.
let row = laid_out(&[(10.0, 10.0, "東京"), (200.0, 10.0, "大阪")], 10.0);
assert_eq!(row.line_of, vec![0, 0, 1, 1]);
assert!(row.find("東京大阪").is_empty());
assert_eq!(row.find("東京 大阪"), vec![0..4]);
// Nor does a cell set lower and further along the row: a wrapped line starts back behind the
// end of the line before it.
let staggered = laid_out(&[(10.0, 10.0, "東京"), (200.0, 17.0, "大阪")], 10.0);
assert_eq!(staggered.line_of, vec![0, 0, 1, 1]);
assert!(staggered.find("東京大阪").is_empty());
assert_eq!(staggered.find("東京 大阪"), vec![0..4]);
// Fullwidth Latin letters and digits are Latin text set wide: their line end is a word break.
let wide = laid_out(&[(10.0, 10.0, "AB"), (10.0, 25.0, "CD"), (10.0, 40.0, "12"), (10.0, 55.0, "34")], 10.0);
assert!(wide.find_opts("ABCD", true, false).is_empty());
assert_eq!(wide.find_opts("AB CD", true, false), vec![0..4]);
assert!(wide.find("1234").is_empty());
assert_eq!(wide.find("12 34"), vec![4..8]);
}
/// Vertical glyphs (WMode 1) of `size` points, one per character, written downwards: each
/// column starts at `top` with its left edge at `x`: (x, top, text) per column, in reading order.
/// Each column is one line, as the text device groups a vertical flow.
fn columns(cols: &[(f32, f32, &str)], size: f32) -> PageText {
let mut text = PageText::default();
for (line, &(x, top, chars)) in (0u32..).zip(cols) {
for (k, c) in chars.chars().enumerate() {
let y = top + size * k as f32;
// Written downwards: the em box's edge towards the next line is its left one.
text.glyphs.push(TextGlyph {
text: c.into(),
rect: [x, y, x + size, y + size],
quad: [[x, y], [x, y + size], [x + size, y + size], [x + size, y]],
direction: [0.0, 1.0],
});
text.line_of.push(line);
text.space_before.push(false);
}
}
text
}
#[test]
fn a_word_divided_between_vertical_columns_is_found_in_both() {
// Japanese set vertically: columns run top to bottom and follow each other to the left.
// "検知機構" breaks after "検知" at the foot of the right column.
let text = columns(&[(40.0, 10.0, "装置の検知"), (25.0, 10.0, "機構です")], 10.0);
let hits = text.find("検知機構");
assert_eq!(hits, vec![3..7]);
// One highlight per column: the foot of the right one and the head of the left one.
assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 40.0, 50.0, 60.0], [25.0, 10.0, 35.0, 30.0]]);
assert_eq!(
text.text_of(3..7),
"検知
機構"
);
// A column that stands to the right of the one before does not continue it.
let back = columns(&[(40.0, 10.0, "装置の検知"), (55.0, 10.0, "機構です")], 10.0);
assert!(back.find("検知機構").is_empty());
assert_eq!(back.find("検知 機構"), vec![3..7]);
}
/// A zero width space draws nothing and is skipped, but UAX #29 breaks words on both sides of it:
/// Thai and Khmer write it between words with no visible gap, so each word is still a whole word.
/// The joiners and the soft hyphen join, so they are no boundary.
#[test]
fn a_zero_width_space_is_a_word_boundary_for_whole_words() {
let thai = page(&["สวัสดี", "\u{200B}", "ครับ"]);
assert_eq!(thai.find_opts("ครับ", false, true), vec![2..3]);
assert_eq!(thai.find_opts("สวัสดี", false, true), vec![0..1]);
// The words read together, as typed with no space, still match.
assert_eq!(thai.find_opts("สวัสดีครับ", false, true), vec![0..3]);
// Inside one glyph's text too.
assert_eq!(page(&["ab\u{200B}cd"]).find_opts("cd", false, true), vec![0..1]);
for joiner in ["\u{AD}", "\u{200C}", "\u{200D}", "\u{2060}", "\u{FEFF}"] {
let text = page(&["ab", joiner, "cd"]);
assert!(text.find_opts("cd", false, true).is_empty(), "{joiner:?}");
assert!(text.find_opts("ab", false, true).is_empty(), "{joiner:?}");
assert_eq!(text.find_opts("abcd", false, true), vec![0..3], "{joiner:?}");
}
}
/// Every range `unspaced` lists joins a line it ends to the next one, on either side of the break;
/// Hangul, parenthesised and circled Hangul, fullwidth Latin and digits, Yi and ASCII keep a space.
#[test]
fn each_unspaced_range_joins_and_spaced_scripts_do_not() {
let joined = [
'\u{2E80}',
'\u{2F00}', // CJK radicals, Kangxi radicals
'\u{3001}',
'\u{3006}', // CJK symbols and punctuation
'\u{3042}',
'\u{30A2}', // Hiragana, Katakana
'\u{3105}', // Bopomofo
'\u{3190}',
'\u{31A0}',
'\u{31C0}',
'\u{31F0}', // Kanbun, Bopomofo extended, strokes, Katakana extensions
'\u{3220}',
'\u{3300}',
'\u{3400}', // enclosed ideograph, CJK compatibility, Han extension A
'\u{4E2D}', // Han
'\u{F900}', // CJK compatibility ideographs
'\u{FE10}',
'\u{FE30}',
'\u{FE50}', // vertical, compatibility and small forms
'\u{FF01}',
'\u{FF1A}',
'\u{FF3B}',
'\u{FF5B}',
'\u{FFE0}', // fullwidth punctuation and signs
'\u{FF61}',
'\u{FF71}', // halfwidth CJK punctuation, halfwidth Katakana
'\u{16FE0}',
'\u{1AFF0}',
'\u{1B000}', // ideographic symbols, kana extended and supplement
'\u{20000}',
'\u{2F800}',
'\u{30000}', // Han extension B, compatibility supplement, extension G
];
for c in joined {
let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0);
assert_eq!(text.find_opts(&format!("中{c}{c}中"), true, false), vec![0..4], "U+{:04X}", c as u32);
}
let spaced = ['\u{AC00}', '\u{3200}', '\u{3260}', '\u{FF10}', '\u{FF21}', '\u{FF41}', '\u{FFA1}', '\u{A000}', 'a'];
for c in spaced {
let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0);
assert!(text.find_opts(&format!("中{c}{c}中"), true, false).is_empty(), "U+{:04X}", c as u32);
assert_eq!(text.find_opts(&format!("中{c} {c}中"), true, false), vec![0..4], "U+{:04X}", c as u32);
}
}
/// The two line-end rules meet: a soft hyphen after a Chinese or Japanese character, a line of
/// Chinese that runs on, a German word divided by a soft hyphen, and a line that opens with a zero
/// width space, all on one page. Each break reads as nothing, and plain Latin line ends stay spaces.
#[test]
fn a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine() {
let text = laid_out(
&[
(10.0, 10.0, "装置の検\u{AD}"),
(10.0, 25.0, "知機構の"),
(10.0, 40.0, "ソレ"),
(10.0, 55.0, "\u{200B}ノイド"),
(10.0, 70.0, "Mitar\u{AD}"),
(10.0, 85.0, "beiterinnen"),
(10.0, 100.0, "abc"),
(10.0, 115.0, "def"),
],
10.0,
);
// Both rules fire at the first break: one hit, highlighted on both lines.
let hits = text.find("検知機構");
assert_eq!(hits, vec![3..8]);
assert_eq!(text.line_rects(hits[0].clone()).len(), 2);
assert!(text.find("検 知機構").is_empty());
// A line of Chinese or Japanese with no soft hyphen, and one that opens with a zero width space.
assert_eq!(text.find("機構のソレノイド"), vec![6..15]);
// A soft hyphen between Latin letters.
assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![15..32]);
// Latin with neither: still a word break.
assert_eq!(text.find("abc def"), vec![32..38]);
assert!(text.find("abcdef").is_empty());
}
/// Glyphs of `size` points as [`laid_out`] sets them, each line `lead` below the last, with the
/// writing direction turned to `direction` about the first glyph's corner (a unit vector in
/// y-down view space; `[1, 0]` is [`laid_out`]).
fn turned(lines: &[&str], size: f32, lead: f32, direction: [f32; 2]) -> PageText {
let [dx, dy] = direction;
// Towards the next line: the writing direction turned a quarter clockwise in view space.
let [nx, ny] = [-dy, dx];
let at = |along: f32, across: f32| [100.0 + along * dx + across * nx, 100.0 + along * dy + across * ny];
let mut glyphs = Vec::new();
for (l, line) in lines.iter().enumerate() {
for (k, c) in line.chars().enumerate() {
let (a, b) = (size * k as f32, lead * l as f32);
// Baseline edge first, in the writing direction; then the far edge back.
let quad = [at(a, b + size), at(a + size, b + size), at(a + size, b), at(a, b)];
let xs = quad.map(|p| p[0]);
let ys = quad.map(|p| p[1]);
let rect = [
xs.iter().copied().fold(f32::MAX, f32::min),
ys.iter().copied().fold(f32::MAX, f32::min),
xs.iter().copied().fold(f32::MIN, f32::max),
ys.iter().copied().fold(f32::MIN, f32::max),
];
glyphs.push(TextGlyph { text: c.into(), rect, quad, direction });
}
}
let mut text = PageText::default();
for (l, line) in (0u32..).zip(lines) {
for _ in line.chars() {
text.line_of.push(l);
text.space_before.push(false);
}
}
text.glyphs = glyphs;
text
}
/// Text turned a quarter either way, or upside down, runs on between lines as horizontal text
/// does: the line after is the next one across the turned lines, starting back at the turned
/// line's head.
#[test]
fn a_chinese_or_japanese_word_divided_in_turned_text_is_found_on_both_lines() {
for direction in [[0.0, -1.0], [0.0, 1.0], [-1.0, 0.0]] {
let text = turned(&["装置の検知", "機構です"], 10.0, 15.0, direction);
assert_eq!(text.find("検知機構"), vec![3..7], "{direction:?}");
assert!(text.find("検知 機構").is_empty(), "{direction:?}");
}
}
/// The line end after a soft hyphen is found past other invisible characters that follow it, in
/// the same glyph or in glyphs of their own.
#[test]
fn a_soft_hyphen_line_end_joins_past_invisible_characters() {
let hyphen = laid_out(&[(10.0, 10.0, "Mitar\u{AD}\u{200B}"), (10.0, 25.0, "beiterinnen")], 10.0);
assert_eq!(hyphen.find("Mitarbeiterinnen"), vec![0..18]);
let mut text = page(&["Mitar\u{AD}", "\u{2060}", "beiterinnen"]);
text.line_of = vec![0, 0, 1];
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..3]);
}
/// A variation selector after the last Chinese or Japanese character of a line only picks its
/// glyph: the line still runs on to the next.
#[test]
fn a_variation_selector_at_a_line_end_keeps_the_line_running_on() {
let selector = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0);
assert_eq!(selector.find("葛\u{E0100}飾区"), vec![0..4]);
assert!(selector.find("葛\u{E0100} 飾区").is_empty());
}
/// A wrapped line is near the one before: a block far down the page keeps its word break.
#[test]
fn a_line_end_does_not_join_a_block_far_below() {
let far = laid_out(&[(10.0, 10.0, "検知"), (10.0, 200.0, "機構")], 10.0);
assert!(far.find("検知機構").is_empty());
assert_eq!(far.find("検知 機構"), vec![0..4]);
}
/// A wrapped line runs the same way as the one before: a line in another writing direction keeps
/// its word break, wherever it sits.
#[test]
fn a_line_end_does_not_join_a_line_in_another_direction() {
let mut across = laid_out(&[(10.0, 10.0, "検知"), (10.0, 25.0, "機構")], 10.0);
for g in across.glyphs.iter_mut().skip(2) {
g.direction = [0.0, 1.0];
}
assert!(across.find("検知機構").is_empty());
assert_eq!(across.find("検知 機構"), vec![0..4]);
}
/// A line of one character starts level with the one before, not behind it, and still runs on.
#[test]
fn lines_of_one_character_run_on() {
let single = laid_out(&[(10.0, 10.0, "検"), (10.0, 25.0, "知"), (10.0, 40.0, "機")], 10.0);
assert_eq!(single.find("検知機"), vec![0..3]);
}
/// A minimal PDF of `objects` (numbered from 1), each with its xref entry.
fn pdf(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
/// The same vertical Japanese as [`a_word_divided_between_vertical_columns_is_found_in_both`],
/// drawn by a page in an Identity-V font: extracted text runs on from the foot of the right
/// column to the head of the left one.
#[test]
fn a_word_divided_between_vertical_columns_of_a_page_is_found_in_both() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
let chars: Vec<char> = "装置の検知機構です".chars().collect();
let pairs: String = (1u32..).zip(&chars).map(|(code, c)| format!("<{code:04X}> <{:04X}> ", *c as u32)).collect();
let to_unicode = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar {pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
chars.len()
);
let content = "BT /F1 10 Tf 1 0 0 1 300 700 Tm <0001000200030004 0005> Tj 1 0 0 1 285 700 Tm <0006000700080009> Tj ET";
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 800] /Resources << /Font << /F1 4 0 R >> >> /Contents 7 0 R >>".into(),
"<< /Type /Font /Subtype /Type0 /BaseFont /TestMincho /Encoding /Identity-V /DescendantFonts [5 0 R] /ToUnicode 6 0 R >>".into(),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /TestMincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 1000 /CIDToGIDMap /Identity >>".into(),
stream("", &to_unicode),
stream("", content),
]);
let mut renderer = PageRenderer::new(std::sync::Arc::new(bytes), RenderConfig::default());
let result = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
assert!(result.error.is_none(), "{:?}", result.error);
let text = result.text.expect("text");
assert_eq!(text.find("検知機構"), vec![3..7], "{:?}", text.plain_text());
assert_eq!(text.line_rects(3..7).len(), 2, "one highlight per column");
assert!(text.find("検知 機構").is_empty());
}
/// A line of Chinese or Japanese that ends in a zero width space drawn with no height still runs
/// on: the break is measured on the glyphs that draw the characters either side of it.
#[test]
fn a_line_end_after_an_invisible_glyph_with_no_height_still_joins() {
let mut text = laid_out(&[(10.0, 10.0, "検知\u{200B}"), (10.0, 25.0, "機構")], 10.0);
if let Some(g) = text.glyphs.get_mut(2) {
g.rect = [30.0, 15.0, 30.0, 15.0];
g.quad = [[30.0, 15.0]; 4];
}
assert_eq!(text.find("検知機構"), vec![0..5]);
}
/// A long run of variation selectors, each a glyph of its own, is searched in time linear in the
/// page: the line-end check doesn't rescan the selectors before every glyph.
#[test]
fn a_long_run_of_variation_selectors_is_searched_quickly() {
let selectors: String = std::iter::once('葛').chain(std::iter::repeat_n('\u{FE00}', 200_000)).collect();
let text = laid_out(&[(10.0, 10.0, &selectors)], 0.001);
let started = std::time::Instant::now();
assert!(text.find("飾").is_empty());
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
}
/// A variation selector drawn as a glyph of its own with no height at a line end stays in the
/// searchable text, and the line still runs on: the break is measured on the letter it follows.
#[test]
fn a_line_end_after_a_zero_height_variation_selector_glyph_still_joins() {
let mut text = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0);
if let Some(g) = text.glyphs.get_mut(1) {
g.rect = [20.0, 15.0, 20.0, 15.0];
g.quad = [[20.0, 15.0]; 4];
}
assert_eq!(text.find("葛\u{E0100}飾区"), vec![0..4]);
}
/// The visible glyphs either side of a break are found however many invisible glyphs stand
/// between them and the break.
#[test]
fn a_line_end_after_many_invisible_glyphs_still_joins() {
let line: String = std::iter::once('検').chain(std::iter::once('知')).chain(std::iter::repeat_n('\u{200B}', 40)).collect();
let mut text = laid_out(&[(10.0, 10.0, &line), (10.0, 25.0, "機構")], 10.0);
for g in text.glyphs.iter_mut().skip(2).take(40) {
let [x, y] = [g.rect[0], g.rect[1]];
g.rect = [x, y, x, y];
g.quad = [[x, y]; 4];
}
assert_eq!(text.find("検知機構"), vec![0..44]);
}
/// The reference search and `find` agree on a soft hyphen followed, before the line end, by an
/// empty glyph and an invisible one: both join the word.
#[test]
fn reference_and_find_join_past_empty_and_invisible_glyphs_after_a_soft_hyphen() {
let mut text = page(&["Mitar\u{AD}", "", "\u{200B}", "beiterinnen"]);
text.line_of = vec![0, 0, 0, 1];
assert_eq!(text.find("Mitarbeiterinnen"), vec![0..4]);
assert_eq!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX), vec![0..4]);
// A visible glyph after the soft hyphen ends the hyphenated word there: the line end stays a space.
let mut text = page(&["Mitar\u{AD}", "x", "", "\u{200B}", "beiterinnen"]);
text.line_of = vec![0, 0, 0, 0, 1];
assert!(text.find("Mitarbeiterinnen").is_empty());
assert!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX).is_empty());
assert_eq!(text.find("Mitarx beiterinnen"), vec![0..5]);
assert_eq!(reference(&text, "Mitarx beiterinnen", false, false, usize::MAX), vec![0..5]);
}
+3 -1
View File
@@ -41,7 +41,7 @@ pub mod kind {
pub const PAGES: &str = "pages added or removed";
pub const OTHER: &str = "other changes";
/// Not a change but the reason there is nothing to compare: the signed revision itself could not
/// be read, so what came after it cannot be allowed.
/// be read, or does not hold the signature, so what came after it cannot be allowed.
pub const UNREADABLE: &str = "the signed version could not be read";
/// Every word above, for an interface to translate.
@@ -177,6 +177,8 @@ pub const WORDINGS: &[&str] = &[
"The signature value does not match the signer's certificate: the signature is corrupt.",
"The signature could not be checked ({}).",
"The signature could not be verified ({}).",
// Whether the signature certifies the document.
"This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.",
// What came after the signature.
"This document has not been modified since this signature was applied.",
"The document has been modified since this signature was applied, but the changes are permitted ({}).",
+42 -11
View File
@@ -38,9 +38,9 @@ pub(super) fn is_doc_timestamp(d: &Dict) -> bool {
d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig"))
}
/// The DocMDP permission level (`/P`, 1–3) of signature value `v`, or `None` for an approval
/// signature.
fn docmdp(doc: &Document, v: &Dict) -> Option<u8> {
/// The DocMDP permission level (`/P`, 1 to 3) that signature value `v` asks for in its `/Reference`,
/// or `None` when it asks for none.
fn docmdp_reference(doc: &Document, v: &Dict) -> Option<u8> {
v.get(b"Reference")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
@@ -51,6 +51,31 @@ fn docmdp(doc: &Document, v: &Dict) -> Option<u8> {
.map(|r| r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8)
}
/// Whether the catalog's `/Perms /DocMDP` in `doc` is the signature dictionary `v`.
fn names_certification(doc: &Document, v: &Dict) -> bool {
let Some(root) = doc.root() else { return false };
let catalog = doc.get(root);
let Some(perms) = catalog.as_dict().and_then(|c| c.get(b"Perms")).map(|p| doc.resolve(p)) else { return false };
let Some(named) = perms.as_dict().and_then(|p| p.get(b"DocMDP")).map(|d| doc.resolve(d)) else { return false };
named.as_dict() == Some(v)
}
/// Set `info.certify` from `doc`, the revision signature value `v` covers. A document has at most
/// one certification, the signature its catalog's `/Perms /DocMDP` names (ISO 32000-2, 12.8.2.2),
/// so a DocMDP reference in any other signature dictionary does not choose the permissions later
/// changes are judged by. Such a signature is checked as an approval signature, with a note.
fn set_certification(doc: &Document, v: &Dict, info: &mut SignatureInfo) {
let asked = docmdp_reference(doc, v);
info.certify = asked.filter(|_| names_certification(doc, v));
let note = Detail::plain(
"This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.",
);
info.details.retain(|d| *d != note);
if asked.is_some() && info.certify.is_none() {
info.details.push(note);
}
}
pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) {
// A document timestamp can also be the value of a signature field (Documenso writes
// `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the
@@ -68,7 +93,7 @@ pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v:
info.contact = text(doc, v, b"ContactInfo");
info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned());
info.signer = text(doc, v, b"Name");
info.certify = docmdp(doc, v);
set_certification(doc, v, info);
let invalid = |info: &mut SignatureInfo, why: Detail| {
info.status = Status::Invalid;
info.details.push(why);
@@ -243,18 +268,24 @@ fn finish_validation(context: SignatureValidation<'_>, verified: VerifiedSignatu
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
} else {
// The permissions are the signed revision's: a later one can rewrite `/P` (#1788). This
// signature is the one whose byte range ends that revision.
// The permissions are the signed revision's: a later one can rewrite `/P` (#1788) or the
// catalog's `/Perms`. This signature is the one whose byte range ends that revision. A
// revision that holds no such signature does not say which permissions apply, so what
// came after it is judged as when the revision cannot be read: not permitted.
let old = cache.revision(doc, bytes, covered);
if let Some(old) = &old
&& let Some(v) = sig_fields(old)
let signed = old.as_ref().and_then(|old| {
sig_fields(old)
.iter()
.filter_map(|f| f.signature(old))
.find(|v| nums(old, v, b"ByteRange").is_some_and(|b| b.len() == 4 && b[2] + b[3] == covered as f64))
{
info.certify = docmdp(old, &v);
});
match (old, signed) {
(Some(old), Some(v)) => {
set_certification(&old, &v, info);
classify_changes(doc, Some(old), info.certify)
}
_ => classify_changes(doc, None, info.certify),
}
classify_changes(doc, old, info.certify)
};
let mut problems = false;
// The changes are handed over as the words they are, not stitched into the sentence, so an
+73
View File
@@ -538,6 +538,79 @@ fn a_document_timestamp_keeps_a_no_changes_certification_valid() {
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SIGNATURE)), "{:?}", s.modification);
}
/// Signature field "Approval" whose value, object 4, asks for DocMDP P 1 in its `/Reference`;
/// object 5 is another signature dictionary. `perms` goes into the catalog. The signature itself
/// is a dummy: which signature certifies is decided before its bytes are checked.
fn certification_of(perms: &str) -> pdfcraft_sign::SignatureInfo {
let catalog = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [3 0 R] >> {perms} >>");
let bytes = write_pdf(&[
catalog.as_bytes(),
b"<< /Type /Pages /Kids [] /Count 0 >>",
b"<< /FT /Sig /T (Approval) /V 4 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>] >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached >>",
]);
signatures(&open(&bytes), &bytes, &TrustStore { certs: Vec::new() }).into_iter().find(|s| s.field == "Approval").unwrap()
}
/// Only the signature the catalog's `/Perms /DocMDP` names certifies the document: a DocMDP
/// reference in any other signature dictionary leaves it an approval signature, with a note.
#[test]
fn only_the_signature_the_catalog_names_certifies() {
let note = "This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.";
let noted = |s: &pdfcraft_sign::SignatureInfo| s.details.iter().filter(|d| d.message == note).count();
let named = certification_of("/Perms << /DocMDP 4 0 R >>");
assert_eq!((named.certify, noted(&named)), (Some(1), 0), "{:?}", named.details);
for perms in ["", "/Perms << /DocMDP 5 0 R >>", "/Perms << /UR3 4 0 R >>"] {
let s = certification_of(perms);
assert_eq!((s.certify, noted(&s)), (None, 1), "{perms:?}: {:?}", s.details);
}
}
/// [`certified_p1`] with a fraction after its last `/ByteRange` number, signed again over the
/// edited bytes. The ranges still cover whole bytes, so the signature verifies, but no signature
/// in the signed revision has a byte range that ends exactly where that revision ends.
fn certified_p1_with_a_fractional_range() -> (TrustStore, Vec<u8>) {
let (id, trust, mut signed) = certified_p1();
let prefix = b"/ByteRange [";
let at = signed.windows(prefix.len()).position(|w| w == prefix).unwrap() + prefix.len();
let end = at + signed[at..].iter().position(|b| *b == b']').unwrap();
let text = std::str::from_utf8(&signed[at..end]).unwrap().to_owned();
let range: Vec<usize> = text.split_ascii_whitespace().map(|n| n.parse().unwrap()).collect();
let mut fractional = format!("{}.5", text.trim_end()).into_bytes();
assert!(fractional.len() <= end - at, "no room for the fraction in {text:?}");
fractional.resize(end - at, b' ');
signed[at..end].copy_from_slice(&fractional);
let (start, stop) = (range[1], range[2]);
signed[start + 1..stop - 1].fill(b'0');
let alg = id.key.preferred_digest();
let digest = alg.digest(&[&signed[..start], &signed[stop..]]);
let cms = pdfcraft_sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap();
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
signed[start + 1..start + 1 + hex.len()].copy_from_slice(&hex);
(trust, signed)
}
/// When the signed revision holds no signature whose byte range ends it, nothing signed says
/// which permissions apply: later changes are not permitted, whatever the latest catalog says.
#[test]
fn changes_after_an_unmatched_signed_revision_are_not_permitted() {
let (trust, signed) = certified_p1_with_a_fractional_range();
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
let s = check(&signed);
assert_eq!((s.status, &s.modification), (Status::Valid, &Modification::None), "{:?}", s.details);
let edited = edit_after(&signed, |doc| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.remove(b"Perms");
})
.unwrap();
add_comment(doc);
});
let s = check(&edited);
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::UNREADABLE])));
}
#[test]
fn invisible_signatures_and_refusals() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
+117 -3
View File
@@ -17,6 +17,9 @@ const MAX_DISPLACEMENT: f32 = 1_000_000.0;
pub(crate) struct AutoScroll {
anchor: Option<Pos2>,
organize: bool,
/// Consecutive or total frames spent past [`DEAD_ZONE`] while the initiating middle press
/// remained held (`>= 2` distinguishes a held scroll from a quick click).
held_frames_past_dead_zone: u8,
/// Own a cancelling click through its release, so it cannot also edit page content.
cancel_button: Option<PointerButton>,
block_input: bool,
@@ -36,6 +39,7 @@ impl AutoScroll {
pub(crate) fn cancel(&mut self) {
self.anchor = None;
self.held_frames_past_dead_zone = 0;
self.drag = None;
self.cancel_button = None;
self.block_input = false;
@@ -59,7 +63,7 @@ impl AutoScroll {
fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 {
let ctx = ui.ctx();
let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| {
let (pointer, middle_press, middle_down, middle_released, has_release_event, cancel, cancel_button, dt) = ctx.input(|i| {
let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2]
.into_iter()
.find(|button| i.pointer.button_pressed(*button));
@@ -72,6 +76,7 @@ impl AutoScroll {
}),
i.pointer.button_down(PointerButton::Middle),
i.pointer.button_released(PointerButton::Middle),
i.events.iter().any(|e| matches!(e, Event::PointerButton { button: PointerButton::Middle, pressed: false, .. })),
!i.focused
|| i.key_pressed(Key::Escape)
|| cancel_button.is_some()
@@ -80,7 +85,8 @@ impl AutoScroll {
i.stable_dt,
)
});
self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released;
self.block_input =
self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released || has_release_event;
if let Some(button) = self.cancel_button {
if !ctx.input(|i| i.pointer.button_down(button)) {
self.cancel_button = None;
@@ -106,13 +112,23 @@ impl AutoScroll {
{
self.anchor = Some(pressed_at);
self.organize = organize;
self.held_frames_past_dead_zone = 0;
}
}
let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO };
let displacement = pointer.y - anchor.y;
let past_dead_zone = displacement.is_finite() && displacement.abs() > DEAD_ZONE;
if middle_down && !has_release_event && past_dead_zone {
self.held_frames_past_dead_zone = self.held_frames_past_dead_zone.saturating_add(1);
}
if (middle_released || has_release_event) && self.held_frames_past_dead_zone >= 2 {
self.cancel();
self.block_input = true;
return Vec2::ZERO;
}
// Cap the elapsed time too: returning from an idle/hidden window must never jump pages.
let delta = scroll_delta(displacement, dt);
if displacement.is_finite() && displacement.abs() > DEAD_ZONE {
if past_dead_zone {
// Continuous redraws let stable_dt use measured frame time. Delayed redraws
// instead use predicted_dt, which can make speed depend on the actual frame rate.
ctx.request_repaint();
@@ -124,6 +140,7 @@ impl AutoScroll {
/// blocked from the press through the release, so the drag cannot also draw, select or move.
fn update_drag(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 {
self.anchor = None;
self.held_frames_past_dead_zone = 0;
self.cancel_button = None;
let ctx = ui.ctx();
let (pointer, middle_press, middle_down, middle_released, focused) = ctx.input(|i| {
@@ -319,6 +336,103 @@ mod tests {
assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still");
}
#[test]
fn holding_middle_button_to_auto_scroll_turns_off_on_release() {
let ctx = Context::default();
let mut scroll = AutoScroll::default();
let p = egui::pos2(100.0, 100.0);
let far = p + vec2(0.0, 60.0);
frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, d| {
assert!(s.active() && s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
for _ in 0..2 {
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active() && s.blocks_input());
assert!(d.y < 0.0, "holding and moving past the dead zone scrolls: {d:?}");
});
}
// Primary case: releasing while still past the dead zone stops auto-scroll immediately.
frame(&ctx, &mut scroll, vec![middle(far, false)], true, (true, false), |s, d| {
assert!(!s.active(), "releasing at +60pt after holding must turn auto-scroll off");
assert!(s.blocks_input(), "the release frame itself still blocks page tools");
assert_eq!(d, Vec2::ZERO);
});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(!s.active() && !s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
// Returning inside the dead zone before releasing still counts as a held scroll gesture.
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
for _ in 0..2 {
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| assert!(s.active() && d.y < 0.0));
}
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, 5.0)), middle(p + vec2(0.0, 5.0), false)], true, (true, false), |s, d| {
assert!(!s.active(), "releasing after scrolling while held must turn auto-scroll off");
assert!(s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
}
#[test]
fn middle_click_released_within_dead_zone_stays_latched() {
let ctx = Context::default();
let mut scroll = AutoScroll::default();
let p = egui::pos2(100.0, 100.0);
let far = p + vec2(0.0, 60.0);
frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
frame(
&ctx,
&mut scroll,
vec![Event::PointerMoved(p + vec2(0.0, DEAD_ZONE)), middle(p + vec2(0.0, DEAD_ZONE), false)],
true,
(true, false),
|s, d| {
assert!(s.active(), "releasing within the dead zone keeps auto-scroll latched");
assert_eq!(d, Vec2::ZERO);
},
);
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active());
assert!(d.y < 0.0);
});
scroll.cancel();
// Pointer movement after middle-button release in the same frame does not count as held movement.
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
frame(&ctx, &mut scroll, vec![middle(p, false), Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active(), "movement after release in the same frame keeps auto-scroll latched");
assert!(d.y < 0.0);
});
scroll.cancel();
// A press, move, and release arriving in a single frame is a click at the initial press position,
// and stays latched across subsequent frames when egui delivers its deferred button_released state
// or runs multiple layout passes within the activation frame.
frame(
&ctx,
&mut scroll,
vec![Event::PointerMoved(p), middle(p, true), Event::PointerMoved(far), middle(far, false)],
true,
(true, false),
|s, d| {
assert!(s.active(), "single-frame click and move latches auto-scroll");
assert!(d.y < 0.0);
},
);
frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| {
assert!(s.active(), "deferred button_released in the next frame must keep auto-scroll latched");
assert!(d.y < 0.0);
});
frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| {
assert!(s.active(), "subsequent frames after deferred release must keep auto-scroll latched");
assert!(d.y < 0.0);
});
}
#[test]
fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() {
let ctx = Context::default();
+16 -3
View File
@@ -108,6 +108,16 @@ impl DocView {
self.current = page;
}
/// The end of the document (End): the last page, scrolled to its bottom as far as the view
/// goes, as browsers and word processors do (#6434). [`go_to_page`](Self::go_to_page) shows a
/// page's top.
pub fn go_to_end(&mut self) {
self.go_to_page(usize::MAX);
// A fraction past a page's top scrolls as far as the content allows (`goto`, clamped when
// applied), so the whole page down to its bottom edge.
self.goto = Some((self.current, 1.0));
}
/// Reveal a user-space rectangle without changing zoom, centred where the scroll bounds allow.
pub(crate) fn reveal_rect(&mut self, page: usize, rect: [f32; 4], info: &DocInfo) {
self.go_to_page(page);
@@ -407,11 +417,14 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
view.find = None;
}
}
if key(Key::Home) {
// Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl
// (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434).
let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k);
if key(Key::Home) || pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)) {
view.go_to_page(0);
}
if key(Key::End) {
view.go_to_page(usize::MAX);
if key(Key::End) || pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)) {
view.go_to_end();
}
// Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the
// arrows and Page Up / Page Down already turn pages where nothing scrolls, below).
+5 -2
View File
@@ -103,10 +103,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
};
if let Some(o) = ptr.pressed() {
// A press with Shift or ⌘ in Edit text is the objects', whatever lies under it
// (`object_ui::modified_hit`): the page's objects are read for it alone.
// (`object_ui::modified_hit`): the page's objects are read for it alone. Crop mode holds
// it on its page as any other press, so a modified click away applies the crop rather
// than taking the picture into a group (#6422).
let adds = tool == QuickTool::EditText
&& can_modify
&& xf.rect.contains(o)
&& !hits.cropping(view)
&& crate::object_ui::adds(ptr.modifiers)
&& !crate::object_ui::menu_open(ui, resp);
let objects = adds.then(|| view.objects.inventory(doc, i));
@@ -532,7 +535,7 @@ impl PageHits<'_> {
images,
clip,
} = *self;
// A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`).
// A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page.
if let Some(objects) = objects {
return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect();
}
+3
View File
@@ -30,6 +30,9 @@ pub(crate) const PASTE: Shortcut = Shortcut::cmd("V");
pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A");
pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←");
pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→");
/// Go to first / last page beside Home and End (`canvas::navigation`).
pub(crate) const FIRST_PAGE: Shortcut = Shortcut::cmd_shift("↑");
pub(crate) const LAST_PAGE: Shortcut = Shortcut::cmd_shift("↓");
/// Whether shortcuts are written the macOS way (`⇧⌘S`) rather than `Ctrl+Shift+S`. egui knows the
/// platform from the build target, and on the web from the browser's user agent.
+15 -2
View File
@@ -1498,7 +1498,8 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool {
ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0)));
ui.add_space(8.0);
use crate::commands::{
ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT,
ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIRST_PAGE, FIT_WIDTH, LAST_PAGE, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW,
SELECT_ALL, ZOOM_IN, ZOOM_OUT,
};
// Registered commands first (always in sync with the real bindings), then the
// keys the document view handles itself.
@@ -1528,7 +1529,10 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool {
(key(FIT_WIDTH), tl!("Fit to width").to_string()),
(pair(ZOOM_IN, ZOOM_OUT), scroll),
(pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()),
(format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()),
(
format!("{} / {}, {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End"), pair(FIRST_PAGE, LAST_PAGE)),
tl!("First / last page").to_string(),
),
(format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()),
(alt_arrows, format!("{} / {}", tl!("Previous view"), tl!("Next view"))),
(named("V"), tl!("Select (V)").to_string()),
@@ -1842,6 +1846,9 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
let which: Vec<pdfcraft_engine::Hidden> = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect();
let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum();
if app.apply_edit(Edit::RemoveHidden { which }) {
if let Some((_, id)) = app.active_ids() {
app.renew_recovery(id);
}
if n == 1 {
app.notify_tr("Removed 1 hidden item. Save to remove them from the file.");
} else {
@@ -1850,11 +1857,17 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
}
}
Some(Dialog::Sanitize) if app.apply_edit(Edit::Sanitize) => {
if let Some((_, id)) = app.active_ids() {
app.renew_recovery(id);
}
app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file.");
}
Some(Dialog::RedactApply) => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
if app.apply_edit(Edit::ApplyRedactions { pages: None }) {
if let Some((_, id)) = app.active_ids() {
app.renew_recovery(id);
}
if marks == 1 {
app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file.");
} else {
+1 -1
View File
@@ -112,7 +112,7 @@ impl Hotbar {
fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) -> bool {
let settings = ui
.push_id("hotbar-settings", |ui| {
icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools"))
icons::labeled_button(ui, "settings", cell, false, tl!("Hotbar settings"), tl!("Hotbar settings / add tools"))
})
.inner;
if settings.clicked() {
+11
View File
@@ -320,6 +320,9 @@ impl PdfCraftApp {
RecoveryMeta { key, name: snap.name, path: snap.path, saved_at: now_secs(), encrypted: snap.encrypted, instance: store.instance() };
if let Err(e) = store.write(&meta, &snap.bytes) {
log::warn!("autosave failed: {e}");
// Still due: the next autosave writes it. After `renew_recovery` there is no
// older snapshot to fall back on.
self.session.autosave_failed(snap.doc);
}
}
}
@@ -331,6 +334,14 @@ impl PdfCraftApp {
}
}
/// Replace a document's recovery snapshot with its current state now. After an edit that takes
/// content out (Apply Redactions, Sanitize, Remove Hidden Information), the last snapshot still
/// holds what went, until the next autosave, and for good if the app is killed before then.
pub(crate) fn renew_recovery(&mut self, doc: pdfcraft_engine::DocId) {
self.forget_recovery(doc);
self.autosave_now();
}
/// Autosave when the interval has passed (called by `logic`, which also runs while hidden).
///
/// The deadline is monotonic (`web_time::Instant`): egui's input clock does not advance
+139 -1
View File
@@ -469,7 +469,7 @@ mod tests {
app.pick_override = Some(vec![out.to_string_lossy().into_owned()]);
// Save As is still open on another tab when the optimizer finishes.
app.pickers.showing.store(true, Ordering::SeqCst);
app.save_optimized(doc, crate::optimize_ui::OptimizeKind::Reduce, Ok((reduced.clone(), String::new())));
app.save_optimized(doc, crate::optimize_ui::OptimizeKind::Reduce, Ok((reduced.clone(), String::new())), None);
app.pickers.deliver(None, vec![], Box::new(|_, _| {}));
app.process_picked();
app.process_picked();
@@ -482,6 +482,144 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// An app with a document that compresses, and a folder for its copy.
fn reducing(tag: &str) -> (PdfCraftApp, PathBuf) {
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-pickers-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.pdf", None, text_pdf("Minutes of the harbour board")).unwrap();
(app, dir)
}
/// Reduce File Size asks first, so a heavy file can finish by itself.
#[test]
fn reduce_file_size_asks_where_to_save_before_it_compresses() {
let (mut app, dir) = reducing("first");
let out = dir.join("notes (reduced).pdf");
app.pick_override = Some(vec![out.to_string_lossy().into_owned()]);
app.reduce_file_size();
assert!(app.optimize_run.is_none() && app.progress_notice.is_none(), "nothing runs until the answer is in");
assert!(app.pickers.pending(), "the question is out");
app.process_picked();
let run = app.optimize_run.as_ref().expect("the answer starts it");
assert_eq!(run.destination.as_deref(), out.to_str());
// Nothing more is asked at the end: the copy is simply there.
app.pick_override = Some(vec![dir.join("not asked.pdf").to_string_lossy().into_owned()]);
let start = std::time::Instant::now();
while app.optimize_run.is_some() && start.elapsed() < std::time::Duration::from_secs(30) {
app.poll_optimize();
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!app.pickers.pending(), "it doesn't ask again");
let written = std::fs::read(&out).unwrap_or_default();
let mut again = pdfcraft_engine::Session::new();
let id = again.open("notes (reduced).pdf", None, Arc::new(written), None).unwrap();
let text = pdfcraft_engine::export::Exporter::new(again.get(id).unwrap()).text(0).unwrap();
assert!(text.contains("Minutes of the harbour board"), "the copy reopens with its text: {text:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cancelling_reduce_file_size_compresses_nothing() {
let (mut app, dir) = reducing("cancel");
app.pick_override = Some(vec![]);
app.reduce_file_size();
app.process_picked();
assert!(app.optimize_run.is_none() && app.progress_notice.is_none());
assert!(!app.pickers.pending());
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing was written");
let _ = std::fs::remove_dir_all(&dir);
}
/// The answer is for the document it was asked about.
#[test]
fn switching_documents_while_reduce_file_size_asks_compresses_nothing() {
let (mut app, dir) = reducing("switch");
let out = dir.join("notes (reduced).pdf");
app.pick_override = Some(vec![out.to_string_lossy().into_owned()]);
app.run_inline = true;
app.reduce_file_size();
app.open_bytes("other.pdf", None, text_pdf("Another document")).unwrap();
app.process_picked();
assert!(app.optimize_run.is_none() && !out.exists(), "nothing runs, and nothing is written");
let _ = std::fs::remove_dir_all(&dir);
}
/// Minutes of work aren't lost because the place chosen first has gone bad.
#[test]
fn a_reduced_copy_that_cant_be_written_where_chosen_asks_again() {
let (mut app, dir) = reducing("again");
let (gone, good) = (dir.join("gone").join("notes (reduced).pdf"), dir.join("notes (reduced).pdf"));
app.pick_override = Some(vec![gone.to_string_lossy().into_owned()]);
app.run_inline = true;
app.reduce_file_size();
app.pick_override = Some(vec![good.to_string_lossy().into_owned()]);
app.process_picked();
let told = app.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(told.contains("Couldn't write"), "{told}");
assert!(app.pickers.pending(), "it asks again");
app.process_picked();
assert!(good.exists(), "the copy is saved at the second place");
let _ = std::fs::remove_dir_all(&dir);
}
/// However many places turn out bad, the copy waits for one that works.
#[test]
fn a_reduced_copy_asks_until_it_can_be_written() {
let (mut app, dir) = reducing("until");
let bad = |n: &str| dir.join(n).join("notes (reduced).pdf").to_string_lossy().into_owned();
let good = dir.join("notes (reduced).pdf");
app.pick_override = Some(vec![bad("gone")]);
app.run_inline = true;
app.reduce_file_size();
app.pick_override = Some(vec![bad("gone too")]);
app.process_picked();
assert!(app.pickers.pending(), "it asks after the first");
app.pick_override = Some(vec![good.to_string_lossy().into_owned()]);
app.process_picked();
assert!(app.pickers.pending(), "and after the second");
app.process_picked();
assert!(good.exists(), "the copy is saved at the third place");
let _ = std::fs::remove_dir_all(&dir);
}
/// A file that's chosen and then has nothing to replace it stays as it was.
#[test]
fn a_reduced_copy_that_isnt_smaller_leaves_the_chosen_file_alone() {
let (mut app, dir) = reducing("small");
let out = dir.join("existing.pdf");
std::fs::write(&out, b"what was there").unwrap();
app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap();
app.pick_override = Some(vec![out.to_string_lossy().into_owned()]);
app.run_inline = true;
app.reduce_file_size();
app.process_picked();
assert_eq!(std::fs::read(&out).unwrap(), b"what was there");
let told = app.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(told.contains("No copy was saved"), "{told}");
let _ = std::fs::remove_dir_all(&dir);
}
/// While a run is going, nothing is asked that couldn't be used.
#[test]
fn reduce_file_size_doesnt_ask_while_a_run_is_going() {
let (mut app, dir) = reducing("busy");
let (_, doc) = app.active_ids().unwrap();
app.optimize_run = Some(crate::optimize_ui::OptimizeRun {
doc,
kind: crate::optimize_ui::OptimizeKind::Reduce,
destination: None,
progress: Arc::default(),
});
app.pick_override = Some(vec![dir.join("no.pdf").to_string_lossy().into_owned()]);
app.reduce_file_size();
assert!(!app.pickers.pending(), "no question");
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_follow_up_that_asks_again_gets_its_picker_before_a_request_that_came_first() {
let mut app = PdfCraftApp::new();
+32 -7
View File
@@ -441,12 +441,25 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
let reset_clicked = gate
&& reset
.is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p))));
// A click away from the picture (its shape, uncropped, not its bounds) and its crop box and
// handles applies the crop and lets the picture go, as Done and then that click would
// (#6422). It is pressed away too ([`CropPart::Page`]), so a press on the picture let go just
// past it is not one; a control's click is the control's.
let pressed_away = matches!(press, PagePress::Mine(Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. }));
let away_clicked = gate
&& resp.clicked()
&& pressed_away
&& !(done_clicked || cancel_clicked || reset_clicked)
&& hover_pos.is_some_and(|p| matches!(crop_part(xf, info, page, Some(image), mode, p), None | Some(CropPart::Page)));
if reset_clicked {
mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep };
mode.drag = None;
} else if done_clicked {
} else if done_clicked || away_clicked {
apply_crop_mode(view, image);
if away_clicked {
view.image_selection = None;
}
} else if cancel_clicked && let Some(s) = view.image_selection.as_mut() {
s.crop = None;
}
@@ -580,28 +593,40 @@ pub(crate) fn handle_hit(
pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option<Hit> {
let s = view.image_selection.as_ref().filter(|s| s.page == page)?;
let mode = s.crop.as_ref()?;
let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon));
let part = crop_part(xf, info, page, images.get(s.index), mode, p)?;
let icon = match part {
CropPart::Page if mode.controls.iter().any(|r| r.contains(p)) => egui::CursorIcon::PointingHand,
CropPart::Grip(g) => GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon()),
CropPart::Keep => egui::CursorIcon::Grab,
CropPart::Page | CropPart::Cut => egui::CursorIcon::Default,
};
Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon))
}
/// What of crop mode `mode` on the picture `image` lies under `p` ([`crop_hit`]): a control (as
/// [`CropPart::Page`]), a grip, the box kept, the picture outside it, or the rest of the page;
/// `None` off the page, its grips and its controls.
fn crop_part(xf: &PageXform, info: &DocInfo, page: usize, image: Option<&pdfcraft_engine::PageImage>, mode: &CropMode, p: Pos2) -> Option<CropPart> {
if mode.controls.iter().any(|r| r.contains(p)) {
return crop(CropPart::Page, egui::CursorIcon::PointingHand);
return Some(CropPart::Page);
}
let image = images.get(s.index);
let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k);
// The grips reach past the page's edge, where those of a picture filling its page lie half.
if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) {
return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon()));
return Some(CropPart::Grip(g));
}
if !xf.rect.contains(p) {
return None;
}
if kept.is_some_and(|k| k.contains(p)) {
return crop(CropPart::Keep, egui::CursorIcon::Grab);
return Some(CropPart::Keep);
}
let on_picture = image.is_some_and(|image| {
let shown = ImageObb::from_image(image, xf, info, page);
let whole = ImageObb::from_matrix(image.matrix, xf, info, page);
quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p)
});
crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default)
Some(if on_picture { CropPart::Cut } else { CropPart::Page })
}
/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it
+80 -8
View File
@@ -140,7 +140,8 @@ enum Grab {
Draw(Pos2),
/// Selecting fields inside a dragged rectangle.
Select(Pos2),
Move,
/// Moving the selected field; with Shift held, the axis the move keeps to once [`lock_axis`] chose it.
Move(Option<Axis>),
/// Resizing by a corner: (moves left edge, moves top edge) in screen terms.
Corner(bool, bool),
}
@@ -229,11 +230,47 @@ fn handles(r: Rect) -> [(Pos2, bool, bool); 4] {
[(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)]
}
/// The rect while a move or resize is in progress. With `shift` held a corner keeps the field's
/// proportions, as it does for comments and stamps.
/// The direction a Shift-constrained move keeps to.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Axis {
Horizontal,
Vertical,
}
impl Axis {
/// The axis `delta` has gone further along; horizontal on a tie.
fn of(delta: egui::Vec2) -> Self {
if delta.x.abs() >= delta.y.abs() { Axis::Horizontal } else { Axis::Vertical }
}
}
/// How far, in screen pixels, a Shift-move goes before its axis is chosen, so the first pixel of
/// hand jitter does not pick it.
const AXIS_LOCK: f32 = 4.0;
/// The axis a move keeps to with `shift` held: chosen once the drag has gone [`AXIS_LOCK`] pixels,
/// from the direction it has gone furthest, and then kept while Shift stays down, so a drag that
/// turns and crosses the diagonal does not flip it and make the field jump. Releasing Shift frees
/// the move, and pressing it again chooses afresh.
fn lock_axis(axis: Option<Axis>, delta: egui::Vec2, shift: bool) -> Option<Axis> {
if !shift {
return None;
}
axis.or_else(|| (delta.length() >= AXIS_LOCK).then(|| Axis::of(delta)))
}
/// The rect while a move or resize is in progress. With `shift` held a move keeps to one axis,
/// horizontal or vertical, as Acrobat's Prepare Form does ("To constrain the vertical or horizontal
/// movement as you drag, press and hold Shift"): the one [`lock_axis`] chose, or until it has
/// chosen, the one the drag has gone furthest along. A corner keeps the field's proportions, as it
/// does for comments and stamps.
fn dragged(r: Rect, grab: Grab, delta: egui::Vec2, shift: bool) -> Rect {
match grab {
Grab::Move => r.translate(delta),
Grab::Move(axis) if shift => r.translate(match axis.unwrap_or_else(|| Axis::of(delta)) {
Axis::Horizontal => vec2(delta.x, 0.0),
Axis::Vertical => vec2(0.0, delta.y),
}),
Grab::Move(_) => r.translate(delta),
Grab::Corner(left, top) => {
let handle = (if left { -1 } else { 1 }, if top { -1 } else { 1 });
comments::resized(r, handle, delta, comments::corner_ratio(r, None, shift))
@@ -315,10 +352,14 @@ pub(crate) fn page_input(
}
// A grab in progress on this page.
if let Some((gp, grab, start)) = prep.grab
if let Some((gp, mut grab, start)) = prep.grab
&& gp == page
{
out.consumed = true;
if let (Grab::Move(axis), Some(p)) = (grab, pointer) {
grab = Grab::Move(lock_axis(axis, p - start, ui.input(|i| i.modifiers.shift)));
prep.grab = Some((gp, grab, start));
}
if resp.drag_stopped() || !ptr.primary_down {
prep.grab = None;
let end = pointer.unwrap_or(start);
@@ -361,7 +402,7 @@ pub(crate) fn page_input(
view.pending_edit = Some(Edit::AddField { page, rect, kind: tool.new_field(join), name: None });
out.placed = true;
}
Grab::Move | Grab::Corner(..) => {
Grab::Move(_) | Grab::Corner(..) => {
let Some((name, wi)) = prep.selected.clone() else { return out };
let Some(w) = form.iter().find(|f| f.name == name).and_then(|f| f.widgets.get(wi)) else { return out };
let delta = end - start;
@@ -402,7 +443,7 @@ pub(crate) fn page_input(
prep.grab = Some((page, Grab::Corner(left, top), origin));
} else if let Some((f, wi)) = hit {
prep.selected = Some((f.name.clone(), wi));
prep.grab = Some((page, Grab::Move, origin));
prep.grab = Some((page, Grab::Move(None), origin));
} else {
prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect());
if !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
@@ -778,6 +819,37 @@ mod tests {
assert_eq!(if left { out.max.x } else { out.min.x }, if left { r.max.x } else { r.min.x });
assert_eq!(if top { out.max.y } else { out.min.y }, if top { r.max.y } else { r.min.y });
}
assert_eq!(dragged(r, Grab::Move, vec2(5.0, 6.0), true), r.translate(vec2(5.0, 6.0)));
assert_eq!(dragged(r, Grab::Move(None), vec2(5.0, 6.0), false), r.translate(vec2(5.0, 6.0)));
}
/// Shift keeps a move to one axis: whichever the drag has gone further along, so the field
/// follows the pointer's main direction and never drifts on the other.
#[test]
fn shift_keeps_a_move_horizontal_or_vertical() {
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 132.0));
assert_eq!(dragged(r, Grab::Move(None), vec2(40.0, 6.0), true), r.translate(vec2(40.0, 0.0)), "mostly right");
assert_eq!(dragged(r, Grab::Move(None), vec2(-40.0, 6.0), true), r.translate(vec2(-40.0, 0.0)), "mostly left");
assert_eq!(dragged(r, Grab::Move(None), vec2(5.0, -30.0), true), r.translate(vec2(0.0, -30.0)), "mostly up");
assert_eq!(dragged(r, Grab::Move(None), vec2(-5.0, 30.0), true), r.translate(vec2(0.0, 30.0)), "mostly down");
// Releasing Shift moves freely again.
assert_eq!(dragged(r, Grab::Move(None), vec2(40.0, 6.0), false), r.translate(vec2(40.0, 6.0)));
// A chosen axis holds even where the drag has gone further along the other one.
assert_eq!(dragged(r, Grab::Move(Some(Axis::Horizontal)), vec2(40.0, 60.0), true), r.translate(vec2(40.0, 0.0)));
assert_eq!(dragged(r, Grab::Move(Some(Axis::Vertical)), vec2(60.0, 40.0), true), r.translate(vec2(0.0, 40.0)));
assert_eq!(dragged(r, Grab::Move(Some(Axis::Vertical)), vec2(60.0, 40.0), false), r.translate(vec2(60.0, 40.0)));
}
/// The axis is chosen once the drag has gone a few pixels with Shift held, kept while Shift
/// stays down whichever way the drag then turns, and freed when Shift is let go.
#[test]
fn a_shift_move_locks_its_axis_until_shift_is_let_go() {
assert_eq!(lock_axis(None, vec2(2.0, 1.0), true), None, "not yet past the jitter");
let axis = lock_axis(None, vec2(40.0, 0.0), true);
assert_eq!(axis, Some(Axis::Horizontal));
assert_eq!(lock_axis(axis, vec2(40.0, 42.0), true), axis, "crossing the diagonal keeps it");
assert_eq!(lock_axis(axis, vec2(0.0, 300.0), true), axis);
assert_eq!(lock_axis(axis, vec2(40.0, 42.0), false), None, "Shift let go frees it");
assert_eq!(lock_axis(None, vec2(40.0, 42.0), true), Some(Axis::Vertical), "and pressing it again chooses afresh");
assert_eq!(lock_axis(None, vec2(f32::NAN, 0.0), true), None, "a non-finite delta chooses nothing");
}
}
+8 -1
View File
@@ -34,7 +34,14 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) {
}
/// Square icon button: transparent until hovered; `selected` gets the accent treatment.
/// `tooltip` is both its accessible name and its one tooltip.
pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
labeled_button(ui, name, box_size, selected, tooltip, tooltip)
}
/// [`button`] whose tooltip says more than its accessible name (`label`). It shows that one
/// tooltip, so callers add no `on_hover_text` of their own (#6548: two tooltips stacked).
pub fn labeled_button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, label: &str, tooltip: &str) -> Response {
let t = Tokens::get(ui.ctx());
// The shared button paints no fill of its own: the eased press wash below replaces it.
let clear = |icon| ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon };
@@ -46,7 +53,7 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool
corner_radius: t.radius.into(),
focus_stroke: Stroke::new(1.0, t.accent),
};
let label = if tooltip.is_empty() { name } else { tooltip };
let label = if label.is_empty() { name } else { label };
// The wash eases from the response, so reserve its place under the icon and focus ring.
let wash = ui.painter().add(egui::Shape::Noop);
let mut painted = None;
+84 -19
View File
@@ -248,21 +248,52 @@ impl PdfCraftApp {
}
/// Reduce File Size: write a compacted copy with images downsampled (the open document is
/// unchanged). It runs in the background with a progress bar, like the PDF Optimizer.
/// unchanged). It asks where to save first, so a heavy file can finish by itself, then runs in
/// the background with a progress bar, like the PDF Optimizer.
pub(crate) fn reduce_file_size(&mut self) {
self.start_optimize(crate::optimize_ui::OptimizeKind::Reduce, &pdfcraft_engine::optimize::Settings::default(), &[]);
use crate::optimize_ui::OptimizeKind::Reduce;
let settings = pdfcraft_engine::optimize::Settings::default();
#[cfg(target_arch = "wasm32")]
self.start_optimize(Reduce, &settings, &[]);
#[cfg(not(target_arch = "wasm32"))]
{
// What can't run says so now, not after asking where to save.
if !self.commit_form_typing() {
return;
}
let Some((_, id)) = self.active_ids() else { return };
if self.optimize_run.is_some() {
self.notify_tr("Optimization is already running");
return;
}
if let Err(e) = self.session.optimize_job(id, &settings, &[]) {
return self.save_optimized(id, Reduce, Err(e), None);
}
if let Some(path) = self.save_override.clone() {
return self.run_optimize(id, Reduce, &settings, &[], Some(path));
}
let Some(doc) = self.session.get(id) else { return };
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(optimized_name(&doc.name, Reduce));
let dialog = crate::editing::in_document_folder(dialog, doc.path.as_deref());
// With the target, a document closed, switched from or edited meanwhile isn't compressed.
self.ask_one(crate::pickers::Ask::Save(dialog), Some(id), move |app, path| {
app.run_optimize(id, Reduce, &settings, &[], Some(path.to_string_lossy().into_owned()));
});
}
}
/// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting
/// the saving. A Reduce File Size copy that isn't smaller is not saved (#490).
/// Save an optimized copy (Reduce File Size, Optimize PDF) to `destination`, or where the user
/// now chooses, reporting the saving. A Reduce File Size copy that isn't smaller is not saved
/// (#490).
pub(crate) fn save_optimized(
&mut self,
id: pdfcraft_engine::DocId,
kind: crate::optimize_ui::OptimizeKind,
result: Result<(Arc<Vec<u8>>, String), pdfcraft_engine::EditError>,
destination: Option<String>,
) {
let Some(doc) = self.session.get(id) else { return };
let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix()));
let (before, name) = (doc.bytes.len(), optimized_name(&doc.name, kind));
#[cfg(not(target_arch = "wasm32"))]
let folder = doc.path.clone();
let (bytes, detail) = match result {
@@ -298,23 +329,57 @@ impl PdfCraftApp {
};
#[cfg(not(target_arch = "wasm32"))]
{
let write = move |app: &mut Self, path: String| match crate::editing::write_atomically(&path, &bytes) {
Ok(()) => saved(app, path),
Err(e) => app.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]),
};
match self.save_override.clone() {
Some(p) => write(self, p),
None => {
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name);
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned()));
}
let chose_first = destination.is_some() && self.save_override.is_none();
match destination.or_else(|| self.save_override.clone()) {
Some(path) => match crate::editing::write_atomically(&path, &bytes) {
Ok(()) => saved(self, path),
Err(e) => {
self.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]);
// The copy can have taken minutes: a place that has gone bad since doesn't lose it.
if chose_first {
self.ask_where_to_save_optimized(name, folder, bytes, saved);
}
}
},
None => self.ask_where_to_save_optimized(name, folder, bytes, saved),
}
}
#[cfg(target_arch = "wasm32")]
match crate::editing::download(&name, &bytes) {
Ok(()) => saved(self, name),
Err(e) => self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
{
let _ = destination;
match crate::editing::download(&name, &bytes) {
Ok(()) => saved(self, name),
Err(e) => self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
}
}
}
/// Ask where an optimized copy goes and write it there, asking again until it is written or
/// the question is cancelled.
#[cfg(not(target_arch = "wasm32"))]
fn ask_where_to_save_optimized(
&mut self,
name: String,
folder: Option<String>,
bytes: Arc<Vec<u8>>,
saved: impl FnOnce(&mut Self, String) + Send + 'static,
) {
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name);
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| {
let path = p.to_string_lossy().into_owned();
match crate::editing::write_atomically(&path, &bytes) {
Ok(()) => saved(app, path),
Err(e) => {
app.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]);
app.ask_where_to_save_optimized(name, folder, bytes, saved);
}
}
});
}
}
/// The suggested name of an optimized copy of `document` ("notes (reduced).pdf").
fn optimized_name(document: &str, kind: crate::optimize_ui::OptimizeKind) -> String {
format!("{} ({}).pdf", stem(document), kind.suffix())
}
+35 -10
View File
@@ -71,6 +71,8 @@ impl OptimizeKind {
pub struct OptimizeRun {
pub doc: DocId,
pub kind: OptimizeKind,
/// Where the copy goes, if the command asked first. Otherwise it asks when the run is done.
pub destination: Option<String>,
pub progress: Arc<Mutex<OptimizeProgress>>,
}
@@ -78,6 +80,11 @@ pub struct OptimizeRun {
fn stage_label(stage: OptimizeStage) -> String {
match stage {
OptimizeStage::Discarding => tl!("Optimizing… removing user data").to_string(),
OptimizeStage::Reading { done: 0, .. } => tl!("Optimizing… reading the pages").to_string(),
OptimizeStage::Reading { done, total } => crate::i18n::fmt(
tl!("Optimizing… reading page {d} of {t}"),
&[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())],
),
OptimizeStage::Fragments => tl!("Optimizing… merging image fragments").to_string(),
OptimizeStage::Images { done, total } => crate::i18n::fmt(
tl!("Optimizing… image {d} of {t}"),
@@ -378,11 +385,16 @@ impl PdfCraftApp {
/// Run an optimization of the active document on a worker thread (inline in tests and on
/// the web), showing its progress; the copy is saved when it is done.
pub(crate) fn start_optimize(&mut self, kind: OptimizeKind, settings: &Settings, discard: &[Hidden]) {
let Some((_, id)) = self.active_ids() else { return };
self.run_optimize(id, kind, settings, discard, None);
}
/// [`Self::start_optimize`] for document `id`, saving to `destination` if that's known already.
pub(crate) fn run_optimize(&mut self, id: DocId, kind: OptimizeKind, settings: &Settings, discard: &[Hidden], destination: Option<String>) {
// What's typed in a form field is part of the document (#166).
if !self.commit_form_typing() {
return;
}
let Some((_, id)) = self.active_ids() else { return };
if self.optimize_run.is_some() {
self.notify_tr("Optimization is already running");
return;
@@ -394,7 +406,7 @@ impl PdfCraftApp {
}
let job = match self.session.optimize_job(id, settings, discard) {
Ok(job) => job,
Err(e) => return self.save_optimized(id, kind, Err(e)),
Err(e) => return self.save_optimized(id, kind, Err(e), destination),
};
let progress = Arc::new(Mutex::new(OptimizeProgress::default()));
let p = progress.clone();
@@ -411,7 +423,10 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
if self.run_inline {
work();
} else if let Err(e) = std::thread::Builder::new().name("pdfcraft-optimize".into()).spawn(work) {
} else if let Err(e) = std::thread::Builder::new().name("pdfcraft-optimize".into()).spawn(move || {
pdfcraft_engine::optimize::give_way_to_other_programs();
work();
}) {
// No worker: report the failure instead of waiting for a result that never comes.
if let Ok(mut s) = progress.lock() {
s.result = Some(Err(EditError::Optimize(e.to_string())));
@@ -419,7 +434,7 @@ impl PdfCraftApp {
}
#[cfg(target_arch = "wasm32")]
work();
self.optimize_run = Some(OptimizeRun { doc: id, kind, progress });
self.optimize_run = Some(OptimizeRun { doc: id, kind, destination, progress });
self.poll_optimize();
}
@@ -435,7 +450,7 @@ impl PdfCraftApp {
/// Show progress; save the copy once the worker is done.
pub(crate) fn poll_optimize(&mut self) {
let Some(run) = self.optimize_run.as_ref() else { return };
let (doc, kind, progress) = (run.doc, run.kind, run.progress.clone());
let (doc, kind, destination, progress) = (run.doc, run.kind, run.destination.clone(), run.progress.clone());
let Ok(mut s) = progress.lock() else {
// The worker panicked while holding the lock: it will never report back.
self.optimize_run = None;
@@ -464,7 +479,7 @@ impl PdfCraftApp {
let detail = if kind == OptimizeKind::Advanced { summary(&r) } else { String::new() };
(b, detail)
});
self.save_optimized(doc, kind, result);
self.save_optimized(doc, kind, result, destination);
}
}
}
@@ -486,7 +501,7 @@ mod tests {
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap();
let (_, doc) = app.active_ids().unwrap();
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Advanced, progress: p });
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Advanced, destination: None, progress: p });
app.notify("Saved notes.pdf");
app
});
@@ -497,7 +512,7 @@ mod tests {
}
h.get_by_label("Optimizing… image 4 of 10");
let shown = h.state().progress_notice.clone().unwrap();
assert!((shown.fraction - 0.275).abs() < 1e-4, "{shown:?}");
assert!((shown.fraction - 0.415).abs() < 1e-4, "{shown:?}");
assert!(shown.cancellable);
h.get_by_label("Cancel").click();
@@ -513,13 +528,23 @@ mod tests {
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("Optimization cancelled"));
}
#[test]
fn the_progress_card_says_what_is_happening() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert_eq!(stage_label(OptimizeStage::default()), "Optimizing… reading the pages");
assert_eq!(stage_label(OptimizeStage::Reading { done: 11, total: 51 }), "Optimizing… reading page 12 of 51");
assert_eq!(stage_label(OptimizeStage::Reading { done: 51, total: 51 }), "Optimizing… reading page 51 of 51");
assert_eq!(stage_label(OptimizeStage::Discarding), "Optimizing… removing user data");
}
#[test]
fn a_second_run_is_refused_while_one_is_going() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap();
let (_, doc) = app.active_ids().unwrap();
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() });
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, destination: None, progress: Arc::default() });
app.optimize_with_draft();
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running"));
}
@@ -536,7 +561,7 @@ mod tests {
assert!(app.optimize_run.is_none());
app.scan_clean_run = None;
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() });
app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, destination: None, progress: Arc::default() });
app.start_scan_clean();
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running"));
assert!(app.scan_clean_run.is_none());
+29 -4
View File
@@ -337,15 +337,13 @@ pub(crate) fn body(
});
}
Handling::Multiple => {
ui.horizontal(|ui| {
ui.label(tl!("Pages per sheet:"));
combo(ui, "per-sheet", tl!("Pages per sheet:"), &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0);
let page_order = |ui: &mut egui::Ui, order: &mut PageOrder| {
ui.label(tl!("Page order:"));
combo(
ui,
"order",
tl!("Page order:"),
&mut d.order,
order,
&[
(PageOrder::Horizontal, tl!("Horizontal")),
(PageOrder::HorizontalReversed, tl!("Horizontal reversed")),
@@ -355,7 +353,34 @@ pub(crate) fn body(
],
150.0,
);
};
ui.horizontal(|ui| {
ui.label(tl!("Pages per sheet:"));
// `None` is Custom: columns by rows, typed in.
let mut per_sheet = (!d.custom_grid).then_some(d.per_sheet);
combo(
ui,
"per-sheet",
tl!("Pages per sheet:"),
&mut per_sheet,
&[(Some(2), "2"), (Some(4), "4"), (Some(6), "6"), (Some(9), "9"), (Some(16), "16"), (None, tl!("Custom"))],
70.0,
);
d.custom_grid = per_sheet.is_none();
if let Some(n) = per_sheet {
d.per_sheet = n;
page_order(ui, &mut d.order);
} else {
let max = print::MAX_GRID;
ui.add(egui::DragValue::new(&mut d.cols).range(1..=max)).accessible_name(tl!("Columns"));
ui.label(tl!("by"));
ui.add(egui::DragValue::new(&mut d.rows).range(1..=max)).accessible_name(tl!("Rows"));
}
});
if d.custom_grid {
// The grid's fields take the room the page order had.
ui.horizontal(|ui| page_order(ui, &mut d.order));
}
ui.horizontal(|ui| {
ui.add(widgets::checkbox(&mut d.border, tl!("Print page border")));
ui.add(widgets::checkbox(&mut d.auto_rotate, tl!("Auto-rotate pages")));
+19 -5
View File
@@ -45,6 +45,10 @@ pub struct PrintDraft {
pub size: SizeMode,
pub custom_scale: f64,
pub per_sheet: usize,
/// Pages per sheet: Custom, `cols` across by `rows` down instead of `per_sheet`.
pub custom_grid: bool,
pub cols: usize,
pub rows: usize,
pub order: PageOrder,
pub border: bool,
pub auto_rotate: bool,
@@ -106,6 +110,9 @@ impl Default for PrintDraft {
size: SizeMode::Fit,
custom_scale: 100.0,
per_sheet: 2,
custom_grid: false,
cols: 2,
rows: 1,
order: PageOrder::Horizontal,
border: false,
auto_rotate: true,
@@ -180,12 +187,19 @@ impl PrintDraft {
SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale),
m => m,
}),
Handling::Multiple => match Layout::multiple(self.per_sheet) {
Layout::Multiple { cols, rows, per_sheet, .. } => {
Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate }
Handling::Multiple => {
let grid = if self.custom_grid {
Layout::grid(self.cols, self.rows).ok_or_else(|| tl!("Columns and rows must each be 1 to 99.").to_string())?
} else {
Layout::multiple(self.per_sheet)
};
match grid {
Layout::Multiple { cols, rows, per_sheet, custom, .. } => {
Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate, custom }
}
other => other,
}
other => other,
},
}
Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding },
Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks },
Handling::Label => Layout::Label { flip: self.label_flip },
+36 -6
View File
@@ -2790,16 +2790,12 @@ fn a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing() {
assert!(!s.views[0].middle_panning(), "releasing the wheel ends the pan");
let moved = s.views[0].page_screen_rect(0).expect("page 1 on screen").top();
if cfg!(target_os = "linux") {
// Linux latches auto-scroll instead of panning with the drag.
assert!(s.views[0].auto_scrolling());
// Linux auto-scrolls while the wheel is held and stops on release.
assert!(!s.views[0].auto_scrolling());
} else {
assert!((moved - (top - 120.0)).abs() < 1.0, "the page follows the pointer: {top} -> {moved}");
}
// The tool still draws with the primary button afterwards.
if cfg!(target_os = "linux") {
h.key_press(egui::Key::Escape);
h.run_steps(2);
}
drag_pt(&mut h, (60.0, 120.0), (200.0, 80.0));
let kinds: Vec<String> = comments(&h).into_iter().map(|a| a.subtype).collect();
assert_eq!(kinds, ["Ink"]);
@@ -3594,6 +3590,40 @@ fn hotbar_picker_adds_and_runs_freehand_highlight() {
assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight));
}
/// #6548: resting on the hotbar's gear showed two stacked tooltips, "Hotbar settings" and
/// "Hotbar settings / add tools". It shows the longer one only and keeps its short name.
#[test]
fn hotbar_settings_button_shows_one_tooltip() {
let mut h = harness(|_| {});
h.run_steps(2);
h.get_by_label("Hotbar settings").hover();
// egui shows a tooltip once the pointer has rested `tooltip_delay` seconds of *input* time,
// and the harness advances that clock by a fixed step every frame, so the wait is counted in
// simulated seconds rather than slept: it cannot pass early or depend on the machine's speed.
let delay = f64::from(h.ctx.global_style().interaction.tooltip_delay);
let start = h.ctx.input(|i| i.time);
let elapsed = |h: &Harness<'static, PdfCraftApp>| h.ctx.input(|i| i.time) - start;
// The button's name; a second tooltip repeating it would make two. Checked after every
// frame, so one that shows only briefly is caught too.
let names = |h: &Harness<'static, PdfCraftApp>| h.query_all_by_label("Hotbar settings").count();
let mut frames = 0;
while h.query_all_by_label("Hotbar settings / add tools").next().is_none() {
assert!(elapsed(&h) < 4.0 * delay && frames < 100, "no tooltip after {:.2}s of rest", elapsed(&h));
h.step();
frames += 1;
assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h));
}
// Rest two more delays, so a second tooltip would have had its chance to open.
let shown = elapsed(&h);
while elapsed(&h) < shown + 2.0 * delay && frames < 100 {
h.step();
frames += 1;
assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h));
}
assert_eq!(h.query_all_by_label("Hotbar settings / add tools").count(), 1, "the tooltip shows");
assert_eq!(names(&h), 1, "the button's name, and no second tooltip");
}
#[test]
fn hotbar_picker_searches_translated_tool_labels() {
if crate::support::skip_without_catalogs("hotbar_picker_searches_translated_tool_labels") {
+12 -3
View File
@@ -665,13 +665,21 @@ fn moving_before_middle_button_release_keeps_scrolling_without_starting_page_too
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 60.0], "steps": 12, "button": "middle" }));
h.run_steps(2);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0);
assert!(h.state().views[0].auto_scrolling(), "release keeps scrolling toggled on even after movement");
assert!(!h.state().views[0].auto_scrolling(), "releasing after holding and scrolling turns auto-scroll off");
let released = h.state().views[0].page_screen_rect(0).unwrap().top();
h.run_steps(8);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held");
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), released, "no drift after releasing a held middle drag");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
assert!(h.state().dialog.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
// Small movement within the dead zone before release still latches auto-scroll on.
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 10.0], "steps": 4, "button": "middle" }));
assert!(h.state().views[0].auto_scrolling(), "release within the dead zone keeps scrolling toggled on");
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 60.0 }));
h.run_steps(8);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y + 60.0, "button": "middle" }));
assert!(!h.state().views[0].auto_scrolling(), "the next middle click toggles scrolling off");
}
@@ -886,7 +894,8 @@ fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() {
assert!(h.state().views[0].selected.is_empty());
assert!(h.state().views[0].org_drag.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert!(h.state().views[0].auto_scrolling(), "releasing the wheel leaves the grid scrolling on");
assert!(!h.state().views[0].auto_scrolling(), "releasing a held wheel stops the grid scrolling");
start_autoscroll(&mut h, &c);
h.state_mut().views[0].organize = false;
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling(), "changing canvas mode cancels the gesture");
+162
View File
@@ -9155,6 +9155,129 @@ fn test_rotated_image_crop_secondary_click_in_outer_bounding_box_clears_selectio
assert!(h.query_by_label("Rotate view clockwise").is_some());
}
/// A turned picture is its shape, not its bounds: a click in a corner of its bounds, off the
/// picture and its crop box, is a click away that applies the crop (#6422 review).
#[test]
fn a_click_beside_a_turned_picture_in_its_bounds_applies_its_crop() {
let mut png = Vec::new();
image::RgbImage::from_pixel(80, 80, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
let edit = |change| pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change };
h.state_mut().apply_edit(edit(pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh])));
h.run_steps(4);
h.state_mut().apply_edit(edit(pdfcraft_engine::ImageEdit::RotateBy(45.0)));
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let c = h.state().views[0].page_screen_rect(0).expect("on screen").center();
click_at(&mut h, c);
assert!(h.state().views[0].image_selection.is_some(), "selected");
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new([mx - 5.0, my - 5.0, mx + 5.0, my + 5.0]));
h.run_steps(2);
let img = h.state().session.get(id).unwrap().page_images(0)[0].clone();
let xf = h.state().views[0].page_xform(0).unwrap();
let b = pdfcraft_ui_egui::canvas::image_screen_rect(&img, &xf, &h.state().session.get(id).unwrap().info, 0);
// The bounds' top-left corner lies outside the 45° diamond.
click_at(&mut h, b.left_top() + egui::vec2(2.0, 2.0));
assert!(h.state().views[0].image_selection.is_none(), "a click off the turned picture lets it go");
assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].visible_rect, img.visible_rect, "the crop is applied");
}
/// A picture alone in the middle of a page, empty page around it (#6422).
fn picture_with_room_around() -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let mut image = Dict::new();
image.set(b"Type".to_vec(), Object::name("XObject"));
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[60, 110, 200])));
let page = pdfcraft_model::pages(&doc).swap_remove(0);
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 100 0 0 80 50 150 cm /Im0 Do Q")));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
resources.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| {
d.set(b"Resources".to_vec(), Object::Dict(resources));
d.set(b"Contents".to_vec(), Object::Ref(contents));
})
.unwrap();
write_incremental(&doc, &Default::default()).unwrap()
}
/// In crop mode a click on the empty page applies the crop and lets the picture go; a click on the
/// part being cropped away keeps crop mode (#6422).
#[test]
fn a_click_away_from_the_picture_applies_its_crop() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("room.pdf", None, picture_with_room_around()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
let doc_id = h.state().views[0].id;
let full = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
click_at(&mut h, at(100.0, 190.0));
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.index == 0), "the picture is selected");
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(full));
h.run_steps(2);
let keep = [full[0], full[1], full[0] + 60.0, full[3]];
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = keep;
h.run_steps(2);
// On the dimmed part of the picture: still cropping, nothing applied.
click_at(&mut h, at(135.0, 190.0));
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a click on the picture keeps crop mode");
assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet");
// On a handle, where it reaches past the picture's frame: still cropping.
let handle = at(50.0, 190.0) - egui::vec2(6.0, 0.0);
click_at(&mut h, handle);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a click on a handle keeps crop mode");
assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet");
// A click pressed on the picture and let go just past its frame: still cropping.
let edge = at(150.0, 190.0);
h.hover_at(edge - egui::vec2(2.0, 0.0));
h.run_steps(1);
h.drag_at(edge - egui::vec2(2.0, 0.0));
h.run_steps(1);
h.hover_at(edge + egui::vec2(2.0, 0.0));
h.run_steps(1);
h.drop_at(edge + egui::vec2(2.0, 0.0));
h.run_steps(4);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a press on the picture keeps crop mode");
assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet");
// On the empty page: applied, and the picture is let go.
click_at(&mut h, at(100.0, 60.0));
assert!(h.state().views[0].image_selection.is_none(), "a click away lets the picture go");
let visible = h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect.unwrap();
assert!(keep.iter().zip(visible).all(|(a, b)| (a - b).abs() < 0.5), "the crop is applied: {visible:?}");
}
#[test]
fn crop_action_pills_interactive_and_apply_cancel_reset() {
let mut png = Vec::new();
@@ -11052,3 +11175,42 @@ fn affine_review_cropped_rotation_preview_matches_saved_corners() {
);
}
}
#[test]
fn a_modified_click_away_from_a_cropped_picture_applies_its_crop() {
// Shift-, Ctrl- or Command-clicking away is still a click away: it applies the crop and lets
// the picture go, rather than taking the picture into a group and dropping its crop (#6422
// review).
for mods in [egui::Modifiers::SHIFT, egui::Modifiers::COMMAND] {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("room.pdf", None, picture_with_room_around()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
h.run_steps(3);
let r = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
let doc_id = h.state().views[0].id;
let full = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
click_at(&mut h, at(100.0, 190.0));
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(full));
h.run_steps(2);
let keep = [full[0], full[1], full[0] + 60.0, full[3]];
h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = keep;
h.run_steps(2);
let p = at(100.0, 60.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: mods });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(4);
assert!(h.state().views[0].image_selection.is_none(), "{mods:?}: the picture is let go");
let visible = h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect.unwrap();
assert!(keep.iter().zip(visible).all(|(a, b)| (a - b).abs() < 0.5), "{mods:?}: the crop is applied: {visible:?}");
}
}
+42
View File
@@ -156,6 +156,48 @@ fn arrow_keys_nudge_the_selected_field_instead_of_turning_the_page() {
assert_eq!(h.state().views[0].current, 0, "the arrows moved the field, not the page");
}
/// Shift keeps a dragged field on the axis it set off along, even when the drag then turns and
/// crosses the diagonal: 40 points right and then 60 down still moves it 40 points right and not
/// at all down, where choosing the axis from the whole drag each frame made it jump to 60 down.
#[test]
fn a_shift_drag_keeps_its_axis_when_it_crosses_the_diagonal() {
let mut h = harness();
h.state_mut().execute("form.add.text");
h.run_steps(1);
let p = at(&h, 40.0, 250.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
let name = h.state().views[0].prepare.selected.clone().expect("the placed field is selected").0;
let before = rect_of(&h, &name);
// The default text field is 144 by 22 points with its top-left at the click.
let c = at(&h, 110.0, 261.0);
let (right, down) = (at(&h, 150.0, 261.0), at(&h, 150.0, 321.0));
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(c + (right - c) * (k as f32 / 4.0));
h.run_steps(1);
}
for k in 1..=4 {
h.hover_at(right + (down - right) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(down);
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
let after = rect_of(&h, &name);
assert!((after[0] - before[0] - 40.0).abs() < 1.5, "40 points right: {before:?} → {after:?}");
assert!((after[1] - before[1]).abs() < 0.01, "not down: {before:?} → {after:?}");
}
/// #3632: Alt+← / Alt+→ go back and forward through the view history with a field selected, and
/// leave the field where it is, even when Alt is let go in the same frame as the arrow, so the
/// frame has no modifiers and only the press carries Alt.
+32
View File
@@ -215,6 +215,38 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() {
let _ = std::fs::remove_file(out);
}
#[test]
fn custom_pages_per_sheet_takes_columns_by_rows() {
use pdfcraft_engine::print::{Layout, PageOrder};
let mut h = harness();
let bytes = h.state().session.create_blank(200.0, 300.0, 4).unwrap();
h.state_mut().open_bytes("narrow.pdf", None, bytes.as_ref().clone()).unwrap();
h.state_mut().execute("print.dialog");
h.run_steps(3);
h.get_by_label("Multiple").click();
h.run_steps(2);
assert!(h.query_by_label("Columns").is_none(), "a predefined count has no grid fields");
h.get_by_role_and_label(Role::ComboBox, "Pages per sheet:").click();
h.run_steps(2);
h.get_by_label("Custom").click();
h.run_steps(2);
// Two number fields, columns by rows, with 2 by 1 to start.
assert_eq!(h.get_by_label("Columns").value().as_deref(), Some("2"));
assert_eq!(h.get_by_label("Rows").value().as_deref(), Some("1"));
h.get_by_label("by");
let d = &h.state().print_draft;
assert!(d.custom_grid);
let layout = d.settings(4, &[]).unwrap().layout;
assert_eq!(
layout,
Layout::Multiple { cols: 2, rows: 1, per_sheet: 2, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: true }
);
h.get_by_label("Sheet 1 of 2");
// A count outside 1 to 99 (set through the control channel) is explained, not drawn.
h.state_mut().print_draft.rows = 0;
assert!(h.state().print_draft.settings(4, &[]).is_err());
}
#[test]
fn a_failed_save_as_pdf_keeps_the_dialog_open() {
let mut h = harness();
+58 -1
View File
@@ -8,7 +8,7 @@ use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_engine::Edit;
use pdfcraft_ui_egui::{PdfCraftApp, RecoveryStore};
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, RecoveryStore};
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
@@ -131,6 +131,63 @@ fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents
let _ = std::fs::remove_file(out);
}
/// Whether any recovery snapshot in `s` holds `text`, in its bytes or in a decoded stream.
fn snapshot_holds(s: &RecoveryStore, text: &[u8]) -> bool {
let found = |data: &[u8]| data.windows(text.len()).any(|w| w == text);
s.list().iter().filter_map(|m| s.read(&m.key).ok()).any(|bytes| {
found(&bytes)
|| pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).is_ok_and(|doc| {
doc.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| found(&d))))
})
})
}
/// Applying redactions replaces the recovery snapshot at once: the one autosaved before still holds
/// what the marks covered, until the next autosave, and for good after a crash.
#[test]
fn applying_redactions_replaces_the_recovery_snapshot_at_once() {
let s = store("redact");
let mut h = harness(s.clone());
h.state_mut().open_bytes("a.pdf", None, fixture(1)).unwrap();
assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 }));
h.state_mut().autosave_now();
assert!(snapshot_holds(&s, b"Confidential"), "the autosave before the redaction holds the page's text");
// Redact pages: the current page, then apply.
assert!(h.state_mut().execute("redact.mark"));
h.run_steps(2);
h.state_mut().execute("redact.pages");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().dialog = Some(Dialog::RedactApply);
h.run_steps(2);
h.get_by_label("Apply").click();
h.run_steps(4);
assert!(!snapshot_holds(&s, b"Confidential"), "a recovery snapshot still holds the redacted text");
assert_eq!(s.list().len(), 1, "the redacted document is autosaved in its place");
s.clear();
}
/// An autosave that could not be written leaves the document due, so the next one writes it.
/// Renewing the snapshot after a redaction removes the old one first, so nothing else is left.
#[test]
fn a_failed_autosave_is_written_by_the_next_one() {
let s = store("retry");
// A file where the folder belongs: the folder can't be made, so nothing can be written.
std::fs::write(s.dir(), b"").unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.enable_recovery(s.clone());
app.open_bytes("a.pdf", None, fixture(1)).unwrap();
assert!(app.apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 }));
app.autosave_now();
assert!(s.list().is_empty(), "the folder can't be made");
std::fs::remove_file(s.dir()).unwrap();
app.autosave_now();
assert_eq!(s.list().len(), 1, "the snapshot that failed is written by the next autosave");
s.clear();
}
#[test]
fn quitting_cleanly_leaves_nothing_behind() {
let s = store("quit");
+8
View File
@@ -87,8 +87,16 @@ fn sign(id: &pdfcraft_engine::sign::pkcs12::DigitalId, file: Vec<u8>, more: impl
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; RESERVE], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(b"D:20261002120000+01'00'".to_vec()));
more(&mut v);
let certifies = v.get(b"Reference").is_some();
let sig = doc.add(Object::Dict(v));
doc.update_dict(ObjRef::new(4, 0), |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
if certifies {
// A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2).
let mut perms = Dict::new();
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap();
}
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
let mut out = pdfcraft_cos::write_incremental(&doc, &save).unwrap();
let br = out.windows(MARK.len()).position(|w| w == MARK.as_bytes()).unwrap();
+42
View File
@@ -532,6 +532,48 @@ fn restart_page_labels(h: &mut Harness<'static, PdfCraftApp>) {
h.run_steps(3);
}
/// End shows the end of the document, the last page's bottom edge, when the page is taller than
/// the window; Home goes back to the first page's top (#6434).
#[test]
fn end_shows_the_bottom_of_the_last_page() {
use egui::Key;
let mut h = harness();
h.state_mut().active = Some(0);
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
h.run_steps(4);
let vp = h.state().views[0].viewport_rect();
assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.height() > vp.height()), "a page is taller than the window");
h.key_press(Key::End);
h.run_steps(4);
let v = &h.state().views[0];
assert_eq!(v.current, 4, "the last page");
let last = v.page_screen_rect(4).expect("the last page is on screen");
assert!(last.bottom() <= vp.bottom() + 1.0 && last.bottom() > vp.bottom() - 40.0, "its bottom edge is at the window's: {last:?} in {vp:?}");
h.key_press(Key::Home);
h.run_steps(4);
let v = &h.state().views[0];
assert_eq!(v.current, 0, "the first page");
let first = v.page_screen_rect(0).expect("the first page is on screen");
assert!(first.top() >= vp.top() - 1.0 && first.top() < vp.top() + 40.0, "its top edge is at the window's: {first:?} in {vp:?}");
// The keys listed with them for Go to first / last page do the same.
let shift_cmd = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
for (last, first) in [(Key::ArrowDown, Key::ArrowUp), (Key::PageDown, Key::PageUp)] {
h.key_press_modifiers(shift_cmd, last);
h.run_steps(4);
let v = &h.state().views[0];
let bottom = v.page_screen_rect(4).map(|r| r.bottom());
assert!(v.current == 4 && bottom.is_some_and(|b| (b - vp.bottom()).abs() < 40.0), "{last:?}: {bottom:?} in {vp:?}");
h.key_press_modifiers(shift_cmd, first);
h.run_steps(4);
let v = &h.state().views[0];
let top = v.page_screen_rect(0).map(|r| r.top());
assert!(v.current == 0 && top.is_some_and(|t| (t - vp.top()).abs() < 40.0), "{first:?}: {top:?} in {vp:?}");
}
}
#[test]
fn page_label_input_keeps_current_page_when_submitted_unchanged() {
let mut h = harness();
+19 -4
View File
@@ -55,6 +55,10 @@ pub struct DataNode {
pub name: String,
pub text: String,
pub children: Vec<DataNode>,
/// Whether the element is a data group even with no children: marked
/// `xfa:dataNode="dataGroup"` (XFA 3.3 p. 150), or a multi-select list's group emptied of its
/// picks here, which the packet writer marks so.
pub group: bool,
}
/// A path into the data: `(name, index among same-named siblings)` per level.
@@ -150,10 +154,21 @@ impl DataNode {
pub fn apply(&mut self, op: &DataOp) {
match op {
DataOp::Value { path, text } => {
if let Some(n) = self.ensure(path)
&& n.children.is_empty()
{
n.text = text.clone();
if let Some(n) = self.ensure(path) {
// A multi-select list's picks, one `value` each (see `value_at`), told apart
// as the packet writer does: `value` leaves only, or a group holding none.
let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) };
if picks {
n.children = text
.split('\n')
.filter(|v| !v.is_empty())
.map(|v| DataNode { name: "value".into(), text: v.to_string(), ..Default::default() })
.collect();
// Emptied, it is still a group, and takes picks again.
n.group = true;
} else if n.children.is_empty() {
n.text = text.clone();
}
}
}
DataOp::Instances { parent, name, count } => {
+76
View File
@@ -271,6 +271,44 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merg
Merged::Changed(out)
}
/// Whether `node` is a multi-select choice list's data group: element children only, every one
/// a `value` holding text alone; or, with no children, an element marked `xfa:dataNode=
/// "dataGroup"`, as a group with no picks is written (XFA 3.3 p. 150).
fn is_value_group(node: roxmltree::Node) -> bool {
let blank = node.children().filter(|c| c.is_text()).all(|c| c.text().is_some_and(|t| t.trim().is_empty()));
let mut elements = node.children().filter(|c| c.is_element()).peekable();
if elements.peek().is_none() {
return blank && marked_data_group(node);
}
blank && elements.all(|c| c.tag_name().name() == "value" && !c.children().any(|g| g.is_element()))
}
/// Whether `node` carries `xfa:dataNode="dataGroup"`.
fn marked_data_group(node: roxmltree::Node) -> bool {
node.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup")
}
/// The edit that marks the data group `node` as one, so that emptied of its picks it still loads
/// as a group and not as a data value (XFA 3.3 p. 150): `xfa:dataNode="dataGroup"` added to its
/// start tag, with the namespace declared there when the packet has no prefix for it.
fn mark_data_group_edit(src: &str, node: roxmltree::Node) -> Option<Edit> {
let range = node.range();
let tag_end = start_tag_end(src, &range)?;
// Before `/>` of an empty tag, else before `>`.
let at = if src.get(..tag_end)?.ends_with('/') { tag_end.checked_sub(1)? } else { tag_end };
let text = match node.lookup_prefix(XFA_DATA_NS) {
Some(prefix) if !prefix.is_empty() => format!(" {prefix}:dataNode=\"dataGroup\""),
_ => {
// A prefix no namespace in scope is bound to, so declaring it rebinds nothing.
let prefix = (0..16)
.map(|i| if i == 0 { "pcxfa".to_string() } else { format!("pcxfa{i}") })
.find(|p| node.lookup_namespace_uri(Some(p)).is_none())?;
format!(" xmlns:{prefix}=\"{XFA_DATA_NS}\" {prefix}:dataNode=\"dataGroup\"")
}
};
Some(Edit { start: at, end: at, text })
}
/// The edits that put `wants` into the `data` element.
fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec<Edit>, warnings: &mut Vec<String>) {
// Element children by local name, per element visited (built once each).
@@ -311,6 +349,44 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
continue;
}
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
// A multi-select choice list is bound to a data group of `value` elements, one per
// pick, and its value is theirs joined by newlines (XFA 3.3 p. 198); a new pick
// replaces them all, as in Acrobat (p. 199).
if is_value_group(node) {
// Every text segment of each `value`, as `current` reads a leaf (a comment may
// split or lead it).
let held: Vec<String> = node
.children()
.filter(|c| c.is_element())
.map(|c| c.children().filter(|t| t.is_text()).filter_map(|t| t.text()).collect())
.collect();
// Pick by pick: one `value` holding a line break is not two picks.
let wanted: Vec<&str> = w.text.split('\n').filter(|v| !v.is_empty()).collect();
if held.iter().map(String::as_str).eq(wanted.iter().copied()) {
continue;
}
let inner: String = wanted.iter().map(|v| format!("<value>{}</value>", escape(v).0)).collect();
// Emptied, the group keeps saying it is one (XFA 3.3 p. 150).
let needs_mark = wanted.is_empty() && !marked_data_group(node);
let mark = if needs_mark { mark_data_group_edit(src, node) } else { None };
if needs_mark && mark.is_none() {
// No marker can be written (no free prefix): emptied, the group would load
// as a data value, so it keeps its picks and the skip is reported.
skipped(warnings, STRUCTURED, &w.som);
continue;
}
match replace_edit(src, node, &inner) {
Some(e) => {
edits.extend(mark);
edits.push(e);
}
None => skipped(warnings, STRUCTURED, &w.som),
}
if dropped {
skipped(warnings, CONTROL, &w.som);
}
continue;
}
if node.children().any(|c| c.is_element()) {
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
// Ignore existing XML indentation, but not whitespace in the accepted value.
+6 -4
View File
@@ -1,12 +1,14 @@
use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template};
use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template, XFA_DATA_NS};
fn to_node(n: roxmltree::Node, depth: usize) -> DataNode {
let name = n.tag_name().name().to_string();
let children: Vec<DataNode> =
if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() };
// The text of a leaf is its value; a container's own text is whitespace.
let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::<String>() } else { String::new() };
DataNode { name, text, children }
// The text of a leaf is its value (its text nodes: a comment is not part of it); a
// container's own text is whitespace.
let text = if children.is_empty() { n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>() } else { String::new() };
let group = n.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup");
DataNode { name, text, children, group }
}
/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a
+123
View File
@@ -5158,3 +5158,126 @@ fn a_template_inside_the_data_is_not_the_form() {
let real = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"/></template></xdp:xdp>";
assert_eq!(parse(real).unwrap().0.root.common.name.as_deref(), Some("form1"));
}
/// A multi-select choice list is bound to a data group of `value` elements, one per pick, and its
/// value is theirs joined by newlines (XFA 3.3 p. 198). New picks replace the group's `value`
/// elements, values off the list included, as Acrobat discards them (p. 199): the group was left
/// as it was, so the old picks came back on reading, in the datasets and in the scripts' data.
#[test]
fn a_multi_select_lists_data_group_takes_its_new_picks() {
let shell =
crate::fixtures::static_shell("<form1><page1><grains><value>rye</value><value>barley</value><value>wheat</value></grains></page1></form1>");
let mut doc = Document::open(Arc::new(shell)).unwrap();
let path = som_to_path("form1[0].page1[0].grains[0]");
assert!(write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.value_at(&path).as_deref(), Some("wheat\nmillet & oats"));
assert_eq!(data.count(&path, "value"), 2, "one value element per pick");
assert!(!write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written, "the same picks change nothing");
assert!(write_data_value(&mut doc, &path, "").unwrap().written);
assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some(""), "nothing picked");
// Emptied, the group is marked as one (XFA 3.3 p. 150), so it is still written as picks.
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("<grains xfa:dataNode=\"dataGroup\"></grains>"), "{xdp}");
assert!(
write_data_value(
&mut doc,
&path,
"rye
millet"
)
.unwrap()
.written
);
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2, "picks again, not text");
// A pick split or led by a comment is read whole: the same pick is no change, and clearing
// a pick that reads as empty through its first segment alone still clears it.
let commented =
crate::fixtures::static_shell("<form1><page1><grains><value><!-- a -->rye</value><value>whe<!-- b -->at</value></grains></page1></form1>");
let mut doc = Document::open(Arc::new(commented)).unwrap();
assert!(
!write_data_value(
&mut doc,
&path,
"rye
wheat"
)
.unwrap()
.written,
"the same picks, read past the comments"
);
assert_eq!(
crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(),
Some(
"rye
wheat"
),
"and read back without them"
);
assert!(write_data_value(&mut doc, &path, "").unwrap().written, "cleared");
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 0);
// Where `xfa` names another namespace and `pcxfa` is taken too, the marker's prefix is a free
// one, declared on the group, so no attribute in scope changes meaning.
let shadowed = crate::fixtures::static_shell(
"<form1><page1><grains xmlns:xfa=\"urn:other\" xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\"><value>rye</value></grains></page1></form1>",
);
let mut doc = Document::open(Arc::new(shadowed)).unwrap();
assert!(write_data_value(&mut doc, &path, "").unwrap().written);
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("xmlns:pcxfa1=\"http://www.xfa.org/schema/xfa-data/1.0/\" pcxfa1:dataNode=\"dataGroup\""), "{xdp}");
assert!(xdp.contains("xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\""), "the taken prefix keeps its namespace: {xdp}");
// With every candidate prefix taken, no marker can be written: the group keeps its pick
// and the skip is reported, rather than emptied into what would load as a data value.
let taken: String = (0..16).map(|i| if i == 0 { " xmlns:pcxfa=\"urn:t\"".to_string() } else { format!(" xmlns:pcxfa{i}=\"urn:t\"") }).collect();
let crowded =
crate::fixtures::static_shell(&format!("<form1><page1><grains xmlns:xfa=\"urn:other\"{taken}><value>rye</value></grains></page1></form1>"));
let mut doc = Document::open(Arc::new(crowded)).unwrap();
let r = write_data_value(&mut doc, &path, "").unwrap();
assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured content")), "{:?}", r.warnings);
assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some("rye"));
// One `value` whose text holds a line break is one pick, not the two it would join to.
let one = crate::fixtures::static_shell(
"<form1><page1><grains><value>one
two</value></grains></page1></form1>",
);
let mut doc = Document::open(Arc::new(one)).unwrap();
assert!(
write_data_value(
&mut doc, &path, "one
two"
)
.unwrap()
.written,
"two picks replace the one"
);
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2);
// The in-memory data, which scripts read before the packet is written, follows the same rule.
let mut tree = crate::script::data_of(
&Document::open(Arc::new(crate::fixtures::static_shell("<form1><page1><grains><value>rye</value></grains></page1></form1>"))).unwrap(),
)
.unwrap();
tree.apply(&DataOp::Value { path: path.clone(), text: "millet\nwheat".into() });
assert_eq!(tree.value_at(&path).as_deref(), Some("millet\nwheat"));
// Emptied in memory, the group still takes picks, not text.
tree.apply(&DataOp::Value { path: path.clone(), text: String::new() });
assert_eq!((tree.count(&path, "value"), tree.value_at(&path).as_deref()), (0, Some("")));
tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
assert_eq!((tree.count(&path, "value"), tree.text_at(&path)), (2, Some("")), "picks again, not text");
assert_eq!(tree.value_at(&path).as_deref(), Some("rye\nmillet"));
let tree_of = |data: &str| crate::script::data_of(&Document::open(Arc::new(crate::fixtures::static_shell(data))).unwrap()).unwrap();
// A group read emptied, as the packet writer marks it, takes picks the same way.
let mut tree = tree_of("<form1><page1><grains xfa:dataNode=\"dataGroup\"/></page1></form1>");
tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
assert_eq!(tree.count(&path, "value"), 2);
// A node holding anything but `value` leaves is not a list's picks: it is left as it was,
// as the packet writer leaves it (and reports the skip).
for structured in [
"<form1><page1><grains><value>rye</value><note>x</note></grains></page1></form1>",
"<form1><page1><grains><value><b>rye</b></value></grains></page1></form1>",
] {
let mut tree = tree_of(structured);
let before = tree.clone();
tree.apply(&DataOp::Value { path: path.clone(), text: "millet".into() });
assert_eq!(tree, before, "{structured}");
}
}
+7
View File
@@ -122,6 +122,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un
breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only
at spaces.
Text pasted from a browser or a word processor often carries an invisible break instead of a
newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH
SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators
(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a
row rather than being looked up in a font, so a paste no longer fails because no face has an
outline for them. Other white space is still written as one space.
Select text within the editor before changing size, colour, font, underline or spacing to
format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the
same properties; invalid or overlapping ranges fail atomically. Untouched source formatting
+1 -1
View File
@@ -160,7 +160,7 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
### Driving it from the page (`window.pdfcraft`)
`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped.
`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. The API is installed after `pdfcraft:ready` is posted, and `window.pdfcraft` is the canvas element before that, so wait for a function rather than for the name: `await new Promise(r => { const t = setInterval(() => typeof pdfcraft.request === "function" && (clearInterval(t), r()), 50); })`.
```js
const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog
+16 -16
View File
@@ -1154,8 +1154,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"]
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it", "crates/ui-egui/tests/it/viewer.rs::end_shows_the_bottom_of_the_last_page"]
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel; Home shows the first page's top and End the end of the document, the last page down to its bottom edge; Shift+Ctrl (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down do the same as Home and End (Shift+Ctrl+Page Up / Page Down used to turn one page, like Ctrl+Page Up / Page Down)"
[[feature]]
id = "view.goto-page-label"
@@ -1406,8 +1406,8 @@ milestone = "M3"
status = "shipped"
commands = ["edit.find"]
tools = ["text_find"]
evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"]
notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic."
evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable", "crates/render/tests/it/search.rs::find_skips_soft_hyphens_and_zero_width_characters", "crates/render/tests/it/search.rs::find_joins_a_word_divided_at_a_line_end_soft_hyphen", "crates/render/tests/it/search.rs::a_zero_width_space_is_a_word_boundary_for_whole_words", "crates/render/tests/it/search.rs::a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines", "crates/render/tests/it/search.rs::a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text", "crates/render/tests/it/search.rs::a_word_divided_between_vertical_columns_is_found_in_both", "crates/render/tests/it/search.rs::each_unspaced_range_joins_and_spaced_scripts_do_not", "crates/render/tests/it/search.rs::a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine"]
notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic. Soft hyphens, zero width spaces and joiners, word joiners and U+FEFF inside a word are skipped, in the page and in the query; a skipped zero width space still ends a word for Whole words only. A line break reads as no word break when the line ends in a soft hyphen, or when it runs on between two Chinese or Japanese characters (East Asian Wide, Fullwidth or Halfwidth, not Hangul and not fullwidth Latin letters or digits), so a divided word is found, in horizontal lines and in vertical columns that continue to the left; the break stays a space beside Latin or Korean text and between text set side by side on one line or in staggered cells."
[[feature]]
id = "view.find-next-previous"
@@ -1895,8 +1895,8 @@ milestone = "M7"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
[[feature]]
id = "edit.text-reflow"
@@ -4590,8 +4590,8 @@ milestone = "M3"
status = "shipped"
commands = ["form.fields"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too"
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/render/src/inspect/tests.rs::a_widget_whose_parent_is_missing_is_listed", "crates/forms/src/tests.rs::a_widget_whose_parent_is_missing_is_a_field_of_its_own", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too, and a widget whose /Parent names a missing object is a field of its own"
[[feature]]
id = "form.highlight-fields"
@@ -4770,8 +4770,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props", "form_fill"]
evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
notes = "SUM, PRD, AVG, MIN, MAX over fields and groups"
evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af/tests.rs::a_calculation_over_fields_without_values_is_0_for_every_operation", "crates/forms/src/af/tests.rs::a_blank_option_of_a_single_space_is_a_value_worth_0", "crates/forms/src/tests.rs::an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows"]
notes = "SUM, PRD, AVG, MIN, MAX over fields and groups; fields without a value are left out, and when none of the listed fields has one, every operation gives 0 (a product too, not 1); a drop-down's blank option of a single space is a value worth 0"
[[feature]]
id = "form.simplified-notation"
@@ -4794,8 +4794,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.prepare"]
tools = ["form_set_script"]
evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"]
notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively."
evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text"]
notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I."
[[feature]]
id = "form.calculation-order"
@@ -7477,8 +7477,8 @@ milestone = "M11"
status = "shipped"
commands = ["optimize.reduce"]
tools = ["doc_reduce"]
evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
evidence = ["crates/ui-egui/src/platform/pickers.rs::reduce_file_size_asks_where_to_save_before_it_compresses", "crates/ui-egui/src/platform/pickers.rs::cancelling_reduce_file_size_compresses_nothing", "crates/ui-egui/src/platform/pickers.rs::a_reduced_copy_that_cant_be_written_where_chosen_asks_again", "crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
notes = "the command asks where to save before it compresses, so a heavy file can finish by itself, and asks again if that place can't be written; images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
[[feature]]
id = "optimize.compatibility-level"
@@ -8273,8 +8273,8 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["doc_print"]
evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"]
notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused."
evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex", "crates/print/src/tests.rs::a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet", "crates/automation/tests/it/automation.rs::custom_pages_per_sheet_prints_the_grid_as_given", "crates/ui-egui/tests/it/print.rs::custom_pages_per_sheet_takes_columns_by_rows"]
notes = "2/4/6/9/16 (the sheet turns to suit the grid with Auto orientation) or Custom columns by rows, 1-99 each, laid out across and down the sheet as oriented (Auto prints portrait; doc_print cols/rows); four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused."
[[feature]]
id = "print.booklet"
+1 -1
View File
@@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup.
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroLine truncated
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, TaLine truncated
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, TaLine truncated
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwLine truncated
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, Line truncated
+18 -3
View File
@@ -88,6 +88,19 @@ pub(crate) struct File<'a> {
/// (D.4.1)
const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A];
/// PdfCraft patch (17): an embedded stream that starts with a file header (D.4), which some
/// producers write into a PDF's `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded
/// organization (D.3) there. Returns the reader past the header and the organization the header
/// declares, or `None` when the data doesn't start with the header's ID string.
pub(crate) fn after_file_header(data: &[u8]) -> Result<Option<(Reader<'_>, FileOrganization)>> {
if !data.starts_with(&FILE_HEADER_ID) {
return Ok(None);
}
let mut reader = Reader::new(data);
let header = parse_file_header(&mut reader)?;
Ok(Some((reader, header.organization)))
}
/// Parse a standalone JBIG2 file.
pub(crate) fn parse_file(data: &[u8]) -> Result<File<'_>> {
let mut reader = Reader::new(data);
@@ -170,7 +183,9 @@ fn parse_segments<'a>(
FileOrganization::Sequential => {
parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?;
}
FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?,
FileOrganization::RandomAccess => {
parse_segments_random_access(reader, &mut segments, &mut SegmentCount::default())?;
}
}
Ok(segments)
@@ -213,12 +228,12 @@ pub(crate) fn parse_segments_sequential<'a>(
/// is followed by a sequence of segments headers; the last segment header is
/// followed by the data for the first segment, then the data for the second
/// segment, and so on." (D.2)
fn parse_segments_random_access<'a>(
pub(crate) fn parse_segments_random_access<'a>(
reader: &mut Reader<'a>,
segments: &mut Vec<Segment<'a>>,
count: &mut SegmentCount,
) -> Result<()> {
let mut headers = Vec::new();
let mut count = SegmentCount::default();
loop {
if reader.at_end() {
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more