M1: resolve main integration for PR #3042

This commit is contained in:
PerchAnton
2026-10-19 18:43:18 +03:00
75 changed files with 3551 additions and 491 deletions
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/filters/fuzz"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
Generated
+4
View File
@@ -4027,6 +4027,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-platform",
"pdfcraft-preflight",
"pdfcraft-render",
"rustix 1.1.5",
"serde_json",
@@ -4136,6 +4137,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
"unicode-segmentation",
"url",
]
@@ -4262,9 +4264,11 @@ dependencies = [
name = "pdfcraft-preflight"
version = "0.9.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"roxmltree",
]
[[package]]
+10
View File
@@ -7,6 +7,7 @@
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -38,6 +39,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
@@ -170,6 +172,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
@@ -178,7 +185,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
+1
View File
@@ -18,6 +18,7 @@ serde_json = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
+10 -1
View File
@@ -57,6 +57,7 @@ macro_rules! mcp_usage {
const USAGE: &str = concat!(
"\
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
(includes any declared PDF/A or PDF/UA level)
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
@@ -315,6 +316,10 @@ fn info(args: &[String]) -> Result<(), CliError> {
})
})
.flatten();
// What the document declares as a standard, read the way the Standards panel reads it, so
// `info` and the app agree. A file that declares nothing leaves these null and empty.
let declared =
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -324,6 +329,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
"pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
"pdfua": declared.pdfua,
"output_intents": declared.output_intents,
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": xfa_layout,
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
@@ -614,7 +622,8 @@ fn render(args: &[String]) -> Result<(), CliError> {
return Err(e.into());
}
let bytes = match format {
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
// With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
None => {
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
+87
View File
@@ -0,0 +1,87 @@
//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
//! mention of PDF/A at all, though the app reads the same declaration and shows it.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
fn fixture(metadata: Option<&str>) -> Vec<u8> {
let content = b"0 0 0 rg 10 10 40 20 re f";
let catalog = match metadata {
Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
};
let mut objs: Vec<Vec<u8>> = vec![
catalog.into_bytes(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
];
objs[3].extend_from_slice(content);
objs[3].extend_from_slice(b"\nendstream");
if let Some(xmp) = metadata {
let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
stream.extend_from_slice(xmp.as_bytes());
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in &offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn xmp(part: &str, conformance: &str) -> String {
format!(
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
<pdfaid:part>{part}</pdfaid:part>\n <pdfaid:conformance>{conformance}</pdfaid:conformance>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>"
)
}
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.pdf");
std::fs::write(&path, bytes).unwrap();
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
let _ = std::fs::remove_dir_all(&dir);
json
}
#[test]
fn info_names_the_declared_pdfa_level() {
let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
assert_eq!(declared["pdfua"], serde_json::Value::Null);
assert_eq!(declared["output_intents"], serde_json::json!([]));
// Lower-case conformance in the file still reads as the level's own spelling.
assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
}
#[test]
fn info_declares_nothing_for_a_plain_pdf() {
let plain = info_of(&fixture(None), "plain");
assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
assert_eq!(plain["pdfua"], serde_json::Value::Null);
// The rest of the summary is unaffected.
assert_eq!(plain["pages"], serde_json::json!(1));
assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
}
+11
View File
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
}
#[test]
fn the_file_records_the_dpi_it_was_rendered_at() {
// The engine's tests check each format; this checks that `--dpi` reaches the file.
let pdf = fixture("dpi");
let jpg = tmp("dpi", "p1.jpg");
assert!(render(&pdf, &jpg).status.success());
let bytes = std::fs::read(&jpg).unwrap();
assert_eq!(&bytes[6..11], b"JFIF\0");
assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
}
#[test]
fn a_pam_name_still_gets_the_netpbm_stream() {
let pdf = fixture("pam");
+3 -2
View File
@@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
# and `advapi` for the session id that names the single-instance socket (#3338).
winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
+50 -14
View File
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option<std::path::PathBuf> {
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
/// The Windows session this process runs in, from its access token: the console, each Remote
/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
#[cfg(windows)]
fn session_id() -> Option<u32> {
use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
Ok(TokenInfo::SessionId(id)) => Some(*id),
_ => None,
}
}
/// The socket's file name for a Windows session. The same user signed in twice (at the console
/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
/// window of the session it runs in (#3338), and never one in another session (review on #3340).
/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
/// digits and `-` kept.
#[cfg_attr(not(windows), allow(dead_code))]
fn socket_name(session: Option<&str>) -> String {
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
fn socket_name(session: Option<u32>, session_name: Option<&str>) -> String {
if let Some(id) = session {
return format!("instance-s{id}.sock");
}
let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
@@ -359,12 +377,20 @@ mod tests {
#[test]
fn each_windows_session_gets_its_own_socket_name() {
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None), "instance.sock");
assert_eq!(socket_name(Some("")), "instance.sock");
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
// console's session finds the window opened from Explorer there...
assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
// ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
// Only without an id does SESSIONNAME name it.
assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None, None), "instance.sock");
assert_eq!(socket_name(None, Some("")), "instance.sock");
}
#[test]
@@ -394,11 +420,21 @@ mod tests {
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
/// The socket is named by the session the process runs in, which Windows always gives.
#[cfg(windows)]
#[test]
fn this_process_has_a_session_id() {
let id = super::session_id();
assert!(id.is_some(), "the access token names the session");
let name = socket_name(id, None);
assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir.join(socket_name(None))
dir.join(socket_name(None, None))
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
+3
View File
@@ -145,3 +145,6 @@ sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.MortenHusted]
sync_github_name = true
+15 -1
View File
@@ -64,6 +64,20 @@ semantics; they never silently act on partial text.
## Shared field properties
For an individual push button, `form_set_props` also edits its visible normal label:
```json
{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
```
`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
icon layout are not edited here; the existing caption font and unsupported-character warning
policy apply.
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
```json
@@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
unlock and change fields together. Only supplied properties change: each field/widget keeps
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
or fill colour. Names, rectangles and rotations require the single-field form. Results keep
or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
## MCP server
+26 -1
View File
@@ -233,6 +233,19 @@ impl Automation {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
o.insert(
"captions".into(),
json!(
f.widgets
.iter()
.enumerate()
.map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
.collect::<Vec<_>>()
),
);
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
@@ -327,6 +340,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
@@ -344,7 +360,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation"] {
for key in ["name", "rect", "rotation", "caption", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -398,6 +414,15 @@ impl Automation {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
.opt_str("caption")?
.map(|caption| -> Result<_> {
let wi = a.opt_int("widget")?.unwrap_or(1);
let wi =
usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((wi, caption.to_owned()))
})
.transpose()?,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
+9 -3
View File
@@ -380,7 +380,8 @@ impl Automation {
}
"page_duplicate" => {
let pages = self.pages(&a, "pages")?;
self.apply(&a, Edit::DuplicatePages { pages })?
let at = self.opt_position(&a, "at")?;
self.apply(&a, Edit::DuplicatePages { pages, at })?
}
"page_set_box" => self.page_set_box(&a)?,
"page_transition_set" => self.page_transition_set(&a)?,
@@ -1793,12 +1794,17 @@ impl Automation {
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
fn position(&self, a: &Args, key: &str) -> Result<usize> {
self.opt_position(a, key)?.ok_or_else(|| Args::missing(key))
}
/// `position`, but `None` when the key is absent.
fn opt_position(&self, a: &Args, key: &str) -> Result<Option<usize>> {
let Some(at) = a.opt_int(key)? else { return Ok(None) };
let n = self.doc(a)?.info.pages.len();
let at = a.int(key)?;
if at < 1 || at as usize > n + 1 {
return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1)));
}
Ok(at as usize - 1)
Ok(Some(at as usize - 1))
}
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
+6 -4
View File
@@ -321,7 +321,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -374,7 +374,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
json!({
@@ -383,6 +383,8 @@ pub fn tools() -> Vec<ToolDef> {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
@@ -825,9 +827,9 @@ pub fn tools() -> Vec<ToolDef> {
t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.")
.cmd("page.replace")
.with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])),
t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.")
t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.")
.cmd("page.duplicate")
.with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])),
.with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])),
t(
"page_transition_set",
"Set page transitions",
+13
View File
@@ -2248,6 +2248,19 @@ fn reversing_pages_through_tools() {
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
}
#[test]
fn page_duplicate_at_inserts_the_copies_there() {
let dir = workdir("duplicate-at");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 }));
assert_eq!(r["pages"], 4);
assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]);
// `at` is 1-based and within 1..=pages+1, as for the other page inserts.
assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based");
assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end");
}
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
+91
View File
@@ -0,0 +1,91 @@
//! Editing a button's visible label through the CLI/MCP tool table (#2892).
use pdfcraft_automation::{Automation, Content};
use pdfcraft_engine::{DocId, Edit, NewField, Session};
use serde_json::{Value, json};
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
#[test]
fn button_caption_can_be_changed_after_creation() {
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut session = Session::new();
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
session
.apply(
id,
Edit::AddField {
page: 0,
rect: [40.0, 300.0, 200.0, 330.0],
kind: NewField::Button { caption: String::new() },
name: Some("clear_button".into()),
},
)
.unwrap();
std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
let before = a.session().save_bytes(DocId(id)).unwrap();
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
let doc = pdfcraft_cos::Document::open(saved).unwrap();
let root = doc.get(doc.root().unwrap());
let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
let button = doc.resolve(&fields.as_array().unwrap()[0]);
let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
assert_eq!(pixels, labelled_pixels);
let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
for invalid in [
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
json!({"doc": id, "field": "clear_button", "caption": 42}),
json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
] {
let before = a.session().save_bytes(DocId(id)).unwrap();
assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
}
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
let before = a.session().save_bytes(DocId(id)).unwrap();
assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "");
let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
assert_eq!(pixels, empty_pixels);
std::fs::remove_dir_all(dir).unwrap();
}
+48 -3
View File
@@ -337,11 +337,14 @@ impl<'a> Lexer<'a> {
}
return Ok(Object::Real(0.0));
}
match s.trim_start_matches('+').parse::<i64>() {
// Tolerate a doubled sign ("--280") like the real branch does.
let t = s.trim_start_matches('+');
let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t);
match cleaned.parse::<i64>() {
Ok(v) => Ok(Object::Int(v)),
Err(_) => {
// Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
s.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
cleaned.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
}
}
}
@@ -401,7 +404,16 @@ fn parse_indirect_in(
if let Object::Dict(dict) = &obj
&& lx.eat_keyword(b"stream")
{
// The keyword is followed by CRLF or LF (a lone CR is tolerated).
// The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting
// Services writes a space first (`stream \r\n`); spaces and tabs count only when a line
// break follows, so they are never taken from the data.
let mut eol = lx.pos;
while matches!(data.get(eol), Some(b' ' | b'\t')) {
eol += 1;
}
if matches!(data.get(eol), Some(b'\r' | b'\n')) {
lx.pos = eol;
}
match (data.get(lx.pos), data.get(lx.pos + 1)) {
(Some(b'\r'), Some(b'\n')) => lx.pos += 2,
(Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
@@ -475,6 +487,20 @@ mod tests {
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
}
#[test]
fn doubled_sign_integer_reads_like_the_real_form() {
// Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read.
assert_eq!(obj("--280.0"), Object::Real(-280.0));
assert_eq!(obj("--280"), Object::Int(-280));
match obj("[(Hello) --280 (World)]") {
Object::Array(items) => {
assert_eq!(items.len(), 3);
assert_eq!(items[1], Object::Int(-280));
}
other => panic!("not an array: {other:?}"),
}
}
#[test]
fn scalars() {
assert_eq!(obj("true"), Object::Bool(true));
@@ -537,6 +563,25 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
// line break, so the /Length is still right and the data keeps no leading space.
for eol in [&b" \r\n"[..], b" \n", b"\t \r"] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec();
data.extend_from_slice(eol);
data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n");
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{eol:?}");
}
// Spaces with no line break after them are data, not a keyword terminator.
let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b" HELLO");
}
#[test]
fn indirect_length_is_resolved() {
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
+153 -177
View File
@@ -4,8 +4,8 @@
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
@@ -30,6 +30,8 @@ pub struct PageImage {
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
.collect()
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
// The graphics state carries over from one stream to the next.
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
for (i, op) in parse(&data).ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
let pieces = joined(&streams(doc, &p.dict));
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: pieces.piece_of(op.span.start),
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
}
}
Ok(out)
@@ -298,15 +304,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
}
fn walk(
doc: &Document,
data: &[u8],
ops: &[Op],
resources: &Dict,
st: &mut ImageState,
stream: usize,
stream: &dyn Fn(&Op) -> usize,
forms: &mut Forms,
out: &mut Vec<PageImage>,
) -> Result<(), EditError> {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (i, op) in parse(data).ops.iter().enumerate() {
for (i, op) in ops.iter().enumerate() {
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
let piece = stream(op);
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
stream: stream(op),
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
@@ -361,9 +368,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut forms = Forms { path: Vec::new(), visits: 0 };
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
}
let pieces = joined(&streams(doc, &p.dict));
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
}
@@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let mut ops = parse(&data).ops;
if ops.get(img.op).is_none() {
let pieces = joined(&all);
let ops = pieces.parse().ops;
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let mut resources = None;
@@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
vec![Op::new("Do", vec![Object::name(&name)])]
}
};
ops.splice(img.op..=img.op, replacement);
let mut dict = match &*doc.resolve(&obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
@@ -599,22 +599,12 @@ fn transform_image(
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let pieces = joined(streams);
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
@@ -677,22 +667,12 @@ fn crop_image(
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
return Err(EditError::Invalid("the crop box has no area".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let pieces = joined(streams);
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
@@ -758,76 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.insert((image.stream, image.op), (movement, placement, offset));
plans.insert(image.op, (movement, placement, offset));
}
let mut data = Vec::with_capacity(all.len());
let pieces = joined(&all);
let ops = pieces.parse().ops;
let mut inside_text = false;
for (stream, (_, bytes)) in all.iter().enumerate() {
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
let ops = pieces.parse().ops;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&(stream, index)) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&index) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert((stream, i), (m, false)),
None => clips.insert((stream, at), (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.remove(&(stream, index));
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if let Some((c, keep)) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
}
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
data.push(changed.into_iter().next().unwrap_or_default());
}
if !plans.is_empty() {
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&index);
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert(i, (m, false)),
None => clips.insert(at, (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.remove(&index);
folds.insert(at, folded);
}
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
return Err(EditError::Invalid("a selected image operation is missing".into()));
}
let data = crate::text::splice(&pieces, &ops, |index| {
if let Some((c, keep)) = clips.remove(&index) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
}
let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
+53 -19
View File
@@ -8,7 +8,8 @@
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
//! so its graphics state can't leak into the mark.
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
//! marked-content sequences before the new mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
@@ -332,12 +333,16 @@ const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
/// content left open, or [`OLD_WRAP_CLOSE`].
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
fn is_wrap_close(bytes: &[u8]) -> bool {
let mut rest = bytes;
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
rest = shorter;
}
let legacy = rest.len() == bytes.len();
loop {
if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE {
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
return true;
}
match rest.strip_prefix(b"\nQ".as_slice()) {
@@ -387,11 +392,13 @@ enum Isolation {
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)> },
/// `marked`: unfinished marked-content sequences to close before a new mark.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
/// The content could not be read that precisely (it doesn't decode, is too large, or has
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
/// the count of `q` left open at the end, when known.
Unknown { unclosed: usize },
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
/// marked-content sequences independently of graphics malformation, or zero when unknown.
Unknown { unclosed: usize, marked: usize },
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
@@ -400,7 +407,7 @@ const ISOLATION_MAX_OPS: usize = 1_000_000;
const ISOLATION_MAX_DEPTH: usize = 10_000;
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let unknown = Isolation::Unknown { unclosed: 0 };
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
if list.len() > ISOLATION_MAX_STREAMS {
return unknown;
}
@@ -422,14 +429,17 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let edges = pieces.comment_edges(&parsed);
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
let mut stack: Vec<usize> = Vec::new();
let (mut marked, mut marked_known) = (0usize, true);
let mut split = 0;
let mut previous_last = None;
let mut wrappers = Vec::new();
for op in &parsed.ops {
let (first, last) = pieces.pieces_of(op);
// A wrapper can only go in before a whole stream, at the top level, between operators,
// and where no comment runs into the stream (it would swallow the wrapper's `q`).
// A wrapper can only go in before a whole stream, outside graphics and marked-content
// scopes, between operators, where no comment would swallow the wrapper's `q`.
if stack.is_empty()
&& marked_known
&& marked == 0
&& !text_open
&& !path_open
&& !dirty
@@ -439,6 +449,26 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
split = first;
}
previous_last = Some(last);
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
let begin_marked = match op.op.as_slice() {
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
_ => false,
};
if marked_known {
if begin_marked {
if marked < ISOLATION_MAX_DEPTH {
marked += 1;
} else {
// Once the budget is exceeded, later EMCs cannot restore a known depth.
marked_known = false;
marked = 0;
}
} else if op.is("EMC") {
marked = marked.saturating_sub(1);
}
}
match op.op.as_slice() {
b"BT" if !text_open => text_open = true,
b"ET" if text_open => text_open = false,
@@ -468,11 +498,11 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
_ => {}
}
}
if malformed || text_open || path_open || !stack.is_empty() {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
@@ -484,21 +514,22 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once.
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let wrap = if behind {
None
} else {
match isolation(doc, &list) {
Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed } => {
Isolation::Unknown { unclosed, marked } => {
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
(split < list.len()).then_some((split, unclosed))
(split < list.len()).then_some((split, unclosed, marked))
}
}
};
@@ -506,8 +537,9 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
if behind {
list.insert(0, mark);
} else {
if let Some((split, unclosed)) = wrap {
let mut close_bytes = b"\nQ".repeat(unclosed);
if let Some((split, unclosed, marked)) = wrap {
let mut close_bytes = b"\nEMC".repeat(marked);
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
close_bytes.extend_from_slice(WRAP_CLOSE);
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
@@ -836,5 +868,7 @@ pub use added::{
update_content,
};
#[cfg(test)]
mod marked_isolation_tests;
#[cfg(test)]
mod tests;
+122
View File
@@ -0,0 +1,122 @@
use super::*;
fn fixture(parts: &[&[u8]]) -> Document {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
page.set(b"Contents".to_vec(), Object::Array(streams));
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
doc
}
fn placed(parts: &[&[u8]]) -> (Document, Vec<Object>) {
let mut doc = fixture(parts);
stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap();
let page = page_list(&doc).remove(0);
let list = contents(&mut doc, &page).unwrap();
(doc, list)
}
fn close(parts: &[&[u8]]) -> Vec<u8> {
let (doc, list) = placed(parts);
let object = &list[list.len() - 2];
assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap"));
let bytes = stream_bytes(&doc, object).unwrap();
assert_eq!(wrapper_role(&doc, object), Some(false));
bytes
}
#[test]
fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() {
for tail in
[b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"]
{
let mut input = b"/OC /L1 BDC\n".to_vec();
input.extend_from_slice(tail);
assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}");
}
}
#[test]
fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() {
let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]);
assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN);
assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q");
assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n");
assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n");
}
#[test]
fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() {
for (input, expected) in [
(b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()),
(b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"),
(b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"),
(b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"),
] {
assert_eq!(close(&[input]), expected, "{input:?}");
}
}
#[test]
fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() {
assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n");
assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE);
assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n");
}
#[test]
fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] {
assert!(is_wrap_close(bytes), "{bytes:?}");
}
for bytes in [
b"\nEMCQ %PdfCraft\n".as_slice(),
b"\nEMC\nQQ %PdfCraft\n",
b"\nQ\nEMC\nQ %PdfCraft\n",
b"EMC\nQ %PdfCraft\n",
b"\nEMC 1 w\nQ %PdfCraft\n",
b"\nEMC\nQ %PdfCraft\nq Q",
] {
assert!(!is_wrap_close(bytes), "{bytes:?}");
}
}
#[test]
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
expected.extend_from_slice(WRAP_CLOSE);
assert_eq!(close(&[&maximum]), expected);
let mut combined = maximum.clone();
combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH));
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH));
expected.extend_from_slice(WRAP_CLOSE);
assert_eq!(close(&[&combined]), expected);
assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal");
let mut doc = fixture(&[&combined]);
let page = page_list(&doc).remove(0);
let original = contents(&mut doc, &page).unwrap();
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
let mut over = maximum.clone();
over.extend_from_slice(suffix);
assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
}
}
+176
View File
@@ -1473,6 +1473,65 @@ fn skia_subset_page() -> Document {
doc
}
/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing
/// words with TJ kerning) makes finding the space width read every font on the page. That ran
/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a
/// second. It runs once per font now, however long the paragraph.
#[test]
fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() {
// Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`,
// whose ToUnicode here has no entry for the space glyph.
let mut doc =
text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> Tj ET");
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar",
)));
let widths: Vec<Object> =
[(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
.into_iter()
.chain([(0x354, 376), (0x3E6, 515)])
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
.collect();
let descriptor = Dict::from_iter([
(b"Type".to_vec(), Object::name("FontDescriptor")),
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Flags".to_vec(), Object::Int(32)),
(b"FontFile2".to_vec(), Object::Ref(program)),
]);
let cid_font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
(b"DW".to_vec(), Object::Int(562)),
(b"W".to_vec(), Object::Array(widths)),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
let draft = "Poland and Germany
"
.repeat(20);
text::SOURCE_SPACE_CALLS.with(|c| c.set(0));
let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap();
let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get);
assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect);
assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len());
}
#[test]
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
let doc = skia_subset_page();
@@ -3377,6 +3436,55 @@ fn latin_ligature_actual_text_stays_on_its_line() {
}
}
/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the
/// page's or the form's `/Properties`, as well as from an inline one.
#[test]
fn actual_text_from_named_property_list() {
let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>";
let page_with = |content: &str| {
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"),
&stream("", content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
])
};
for (content, expected) in [
("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span <</ActualText (Hello World)>> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
] {
let doc = page_with(content);
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected], "{content}");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}");
let reading: Vec<String> = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, [expected], "{content}");
}
}
// A form's named list is looked up in the form's own /Properties, not the page's.
let fm = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>",
"/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC",
);
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>"
),
&stream("", "/Fm1 Do"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
&fm,
]);
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["Form text"]);
}
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
@@ -3820,6 +3928,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() {
}
}
/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600.
fn split_image_page(pieces: &[&[u8]]) -> Document {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec()));
let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f")));
let image = doc.get(pdfcraft_model::pages(&doc)[0].obj);
let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap();
let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap();
xo.set(b"Fm0".to_vec(), Object::Ref(form));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| {
d.set(b"Contents".to_vec(), Object::Array(refs));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
doc
}
#[test]
fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() {
// #3113: an operator's operands end one piece and its keyword starts the next.
let square = [72.0, 500.0, 172.0, 600.0];
let cases: [(&[&[u8]], bool); 4] = [
(&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false),
(&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false),
(&[b"q /Fm0", b"Do Q"], true),
(&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false),
];
for (pieces, is_form) in cases {
let doc = split_image_page(pieces);
let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{label:?}");
assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect);
assert_eq!(images[0].is_form, is_form);
assert!(images[0].contains([122.0, 550.0]), "{label:?}");
if !is_form {
let read = images::reading_images(&doc, 0).unwrap();
assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}");
}
let mut deleted = doc.clone();
images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
let deleted = reopen(&deleted);
assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}");
let (joined, ops) = joined_ops(&deleted);
assert!(!ops.iter().any(|o| o.is("Do")), "{joined}");
let target = [300.0, 500.0, 400.0, 600.0];
let mut moved = doc.clone();
images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap();
let moved = reopen(&moved);
let after = images::page_images(&moved, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0);
let mut shifted = doc.clone();
images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap();
let shifted = reopen(&shifted);
let after = images::page_images(&shifted, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0);
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
+23 -7
View File
@@ -740,7 +740,8 @@ fn interpret(
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
carry: &mut Carry,
mut invoked: Option<&mut Vec<Invocation>>,
logical_text: bool,
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
logical_text: Option<&Dict>,
) -> Vec<Shown> {
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
@@ -757,11 +758,16 @@ fn interpret(
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None });
actual.push(logical.map(|s| (out.len(), i, s)));
}
b"EMC" => {
marked.pop();
@@ -1018,7 +1024,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties));
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1261,7 +1268,8 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties));
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
@@ -1661,7 +1669,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool {
text.split(' ').all(|word| metrics.encode(word).is_some())
}
#[cfg(test)]
thread_local! {
/// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops.
pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 {
#[cfg(test)]
SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::<f64>());
if let Some(w) = width(metrics) {
return w;
@@ -1891,7 +1907,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
@@ -2548,7 +2564,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.
+17 -3
View File
@@ -489,6 +489,20 @@ pub(super) fn rewrite_styled_block(
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
// Each font's space width, worked out once: when a font can't show a space, finding one
// reads every font on the page, and doing that for every character of a paragraph made each
// keystroke in a long paragraph take half a second (#2616).
// Only for the fonts the paragraph uses, when first asked: working out every page font's width
// up front would scan the page once per font for a short edit on a page of many fonts.
let spaces: std::cell::RefCell<HashMap<String, f64>> = std::cell::RefCell::new(HashMap::new());
let space_of = |name: &str, m: &Metrics| {
if let Some(w) = spaces.borrow().get(name) {
return *w;
}
let w = source_space(doc, &fonts, m);
spaces.borrow_mut().insert(name.to_owned(), w);
w
};
let mut calibration = HashMap::new();
for (index, source) in lines.iter().enumerate() {
if let Some(m) = metrics.get(&source.font) {
@@ -496,7 +510,7 @@ pub(super) fn rewrite_styled_block(
m,
&source.text,
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
source_space(doc, &fonts, m),
space_of(&source.font, m),
source.origin.state.char_spacing,
source.origin.state.word_spacing,
) * source.origin.state.scale
@@ -513,7 +527,7 @@ pub(super) fn rewrite_styled_block(
m,
&item.0.to_string(),
size_of(source, item.1),
source_space(doc, &fonts, m),
space_of(&item.2, m),
overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
@@ -778,7 +792,7 @@ pub(super) fn rewrite_styled_block(
m,
&string,
size_of(source, item.1),
source_space(doc, &fonts, m),
spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)),
state.char_spacing,
state.word_spacing,
)?);
+3
View File
@@ -37,6 +37,9 @@ pdfcraft-model = { workspace = true }
pdfcraft-geom = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
# resolution or the compression of an exported page.
tiff = "0.11"
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
# the host of a document's link the way the browser reads it (links::display_host).
url = "2.5"
+31 -15
View File
@@ -93,14 +93,14 @@ impl Exporter {
encode_png(r.width, r.height, &r.rgba)
}
/// Page `page` as an image file of `format`.
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
self.check(page)?;
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
if let Some(e) = r.error {
return Err(format!("page {}: {e}", page + 1));
}
encode_image(r.width, r.height, &r.rgba, format)
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
}
/// The reading-order text of a page.
@@ -127,12 +127,14 @@ impl Exporter {
}
}
/// Premultiplied RGBA → an image file of `format`.
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result<Vec<u8>, String> {
match format {
ImageFormat::Png => encode_png(width, height, premultiplied),
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
}
}
@@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
.collect()
}
/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
let rgb = over_white(premultiplied);
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| e.to_string())?;
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
// The JFIF density is whole dots per inch.
enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result<Vec<u8>, String> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
use tiff::tags::{ResolutionUnit, Tag};
let err = |e: tiff::TiffError| e.to_string();
let rgb = over_white(premultiplied);
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
let mut image = tiff.new_image::<RGB8>(width, height).map_err(err)?;
// In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
let tags = image.encoder();
tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
image.write_data(&rgb).map_err(err)?;
Ok(out.into_inner())
}
+5 -3
View File
@@ -1281,9 +1281,11 @@ pub enum Edit {
width: f64,
height: f64,
},
/// Copies of `pages` inserted after the last of them.
/// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after
/// the last of them when `at` is `None`.
DuplicatePages {
pages: Vec<usize>,
at: Option<usize>,
},
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
/// pages' links, comments and bookmarks stay.
@@ -1948,7 +1950,7 @@ impl Edit {
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
Edit::ReversePages { .. } => Label::of("Reverse pages"),
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()),
Edit::SetPageBox { .. } => Label::of("Set page boxes"),
@@ -2335,7 +2337,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?,
Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
Edit::ReplacePages { pages, name, bytes, src_pages } => {
let src = open_source(name, bytes)?;
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
+44 -2
View File
@@ -870,7 +870,7 @@ fn form_with_field(name: &str) -> Vec<u8> {
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
@@ -2183,7 +2183,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() {
assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]);
assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size");
assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]);
s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page"));
}
@@ -2574,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() {
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
assert_eq!((decoded.width(), decoded.height()), (400, 600));
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
}
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
use tiff::{decoder::ifd::Value, tags::Tag};
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
let mut res = |tag| match d.get_tag(tag).unwrap() {
Value::Rational(n, d) => f64::from(n) / f64::from(d),
v => panic!("{tag:?} is {v:?}"),
};
let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
(d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
}
/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
#[test]
fn exported_pages_record_the_resolution_they_were_drawn_at() {
use crate::export::{Exporter, ImageFormat};
let (s, id) = session_with(1);
let mut ex = Exporter::new(s.get(id).unwrap());
assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
assert_eq!(&jpg[6..11], b"JFIF\0");
assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
// pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
// (doc_create from images) would come out 0.01% small.
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
// A page too large for the renderer at the asked resolution says the one it was drawn at.
let mut s = Session::new().with_clock(|| 1_700_000_000);
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
let id = s.open("wide.pdf", None, bytes, None).unwrap();
let mut ex = Exporter::new(s.get(id).unwrap());
let used = ex.dpi_used(0, 600.0);
let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
}
#[test]
@@ -0,0 +1,274 @@
//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer.
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n";
const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n";
fn fixture(parts: &[String]) -> Arc<Vec<u8>> {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font)));
let mut group = Dict::new();
group.set(b"Type".to_vec(), Object::name("OCG"));
group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer")));
let group = doc.add(group);
let mut properties = Dict::new();
properties.set(b"L1".to_vec(), Object::Ref(group));
let mut resources = Dict::new();
resources.set(b"Font".to_vec(), Object::Dict(fonts));
resources.set(b"Properties".to_vec(), Object::Dict(properties));
let mut config = Dict::new();
config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)]));
let mut oc = Dict::new();
oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)]));
oc.set(b"D".to_vec(), Object::Dict(config));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap();
let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Array(streams));
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())
}
fn render(bytes: Arc<Vec<u8>>) -> (Vec<u8>, String) {
let mut renderer = PageRenderer::new(bytes, Default::default());
let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(pixels.error.is_none(), "{:?}", pixels.error);
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
assert!(text.error.is_none(), "{:?}", text.error);
let text = text.text.unwrap();
(pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len()))
}
fn edit(kind: &str) -> Edit {
match kind {
"HEADER" | "000001" => {
let mut settings = HeaderFooter::default();
if kind == "HEADER" {
settings.text[1] = "HEADER".into();
} else {
settings.text[5] = "<<Bates Number#6#1##>>".into();
}
Edit::AddHeaderFooter { pages: vec![0], settings, replace: true }
}
"WATERMARK" => Edit::AddWatermark {
pages: vec![0],
settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() },
replace: true,
file: None,
},
"ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } },
_ => panic!("unknown mark"),
}
}
fn add_and_save(input: Arc<Vec<u8>>, kind: &str) -> Arc<Vec<u8>> {
let mut session = Session::new();
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
session.apply(id, edit(kind)).unwrap();
let saved = session.save_bytes(id).unwrap();
assert!(saved.starts_with(&input), "incremental save preserves the original file bytes");
assert_original_streams(&input, &saved);
let mut reopened = Session::new();
let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap();
assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice());
saved
}
#[test]
fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() {
let mut failures = Vec::new();
for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] {
let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]);
let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) };
let original = render(input.clone());
assert!(original.1.contains("Visible text"));
assert_eq!(original.1.contains("Hidden note"), !hidden);
for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] {
let saved = add_and_save(input.clone(), kind);
let actual = render(saved);
let expected_bytes = add_and_save(closed.clone(), kind);
let expected = render(expected_bytes);
let mut wanted = vec!["Visible text", kind];
if !hidden {
wanted.push("Hidden note");
}
wanted.sort_unstable();
let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted;
eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0);
if !pass {
failures.push(format!("{case}/{kind}: {:?}", actual.1));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn stream_list(doc: &Document) -> Vec<Object> {
let page = pdfcraft_model::pages(doc).remove(0);
let contents = doc.resolve(page.dict.get(b"Contents").unwrap());
match &*contents {
Object::Array(items) => items.clone(),
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
}
}
fn assert_original_streams(before: &Arc<Vec<u8>>, after: &Arc<Vec<u8>>) {
let original = Document::open(before.clone()).unwrap();
let saved = Document::open(after.clone()).unwrap();
let saved_list = stream_list(&saved);
for reference in stream_list(&original) {
assert!(saved_list.contains(&reference), "the original stream stays on the page");
assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged");
}
}
fn lines(text: &str) -> Vec<&str> {
let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect();
lines.sort_unstable();
lines
}
#[test]
fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() {
let cases: Vec<(&str, Vec<String>, usize)> = vec![
("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2),
("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1),
("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0),
("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1),
("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1),
("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1),
("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1),
("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1),
("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2),
("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0),
("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <</Text (BMC BDC EMC)>> DP")], 1),
("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1),
];
for (case, parts, close_count) in cases {
let input = fixture(&parts);
let mut closed = parts.clone();
closed.push(format!("\n{}", "EMC\n".repeat(close_count)));
let actual = render(add_and_save(input, "HEADER"));
let expected = render(add_and_save(fixture(&closed), "HEADER"));
assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}");
assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control");
}
}
#[test]
fn replacing_and_removing_marks_restores_the_original_stream_list() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let original = Document::open(input.clone()).unwrap();
for kind in ["HEADER", "000001", "WATERMARK"] {
let mut session = Session::new();
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
for _ in 0..3 {
session.apply(id, edit(kind)).unwrap();
let saved = session.save_bytes(id).unwrap();
let page = Document::open(saved.clone()).unwrap();
assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers");
assert_original_streams(&input, &saved);
assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}")));
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
}
let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter };
session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap();
let saved = session.save_bytes(id).unwrap();
let page = Document::open(saved.clone()).unwrap();
assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references");
assert_original_streams(&input, &saved);
assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text");
session.undo(id).unwrap();
assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}")));
session.redo(id).unwrap();
assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering");
}
}
#[test]
fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let mut session = Session::new();
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
for _ in 0..3 {
session.apply(id, edit("ADDED")).unwrap();
assert_eq!(session.get(id).unwrap().added().len(), 1);
let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() };
session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED"));
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4);
assert_original_streams(&input, &saved);
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers");
assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering");
assert_original_streams(&input, &saved);
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
}
}
#[test]
fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let mut session = Session::new();
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
session
.apply(
id,
Edit::AddWatermark {
pages: vec![0],
settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() },
replace: false,
file: None,
},
)
.unwrap();
session.apply(id, edit("HEADER")).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER"));
assert_original_streams(&input, &saved);
}
#[test]
fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() {
let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]);
let mut doc = Document::open(bytes).unwrap();
let list = stream_list(&doc);
for index in [0, 2] {
let reference = list[index].as_ref().unwrap();
let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") };
stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap"));
doc.set(reference, Object::Stream(stream));
}
let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap());
let saved = add_and_save(input, "HEADER");
assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER"));
}
+7
View File
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
that label, independently of the field name. The index is 0-based. A label-only edit redraws
just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing.
Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only
layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi
limits and reports unavailable characters as above.
## Not yet
+33 -2
View File
@@ -63,6 +63,9 @@ impl NewField {
pub struct FieldProps {
pub name: Option<String>,
pub tooltip: Option<String>,
/// Normal push-button label (`/MK /CA`) of one widget (0-based index).
/// An empty string clears the label; other widgets and icon layouts are preserved.
pub caption: Option<(usize, String)>,
pub read_only: Option<bool>,
pub required: Option<bool>,
pub multiline: Option<bool>,
@@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
locked: false,
hidden: false,
rotation: 0,
caption: None,
list: None,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
@@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see
/// `ask_viewers_to_redraw`), as when a value is set.
pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
redraw_widgets(doc, name, None)
}
fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let mut undrawn = false;
for w in &f.widgets {
for (index, w) in f.widgets.iter().enumerate() {
if only.is_some_and(|only| only != index) {
continue;
}
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
@@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// Refuse a wrong type/index before any other requested property is written.
if let Some((wi, _)) = &props.caption {
if f.kind != FieldKind::PushButton {
return invalid("only push buttons have a caption");
}
if f.widgets.get(*wi).is_none() {
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
}
}
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
@@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
})?;
}
redraw_field(doc, &new_name)?;
if let Some((wi, caption)) = &props.caption
&& let Some(w) = f.widgets.get(*wi)
{
// Copy an indirect/shared MK before patching it; colours, icons, rotation and
// unknown entries belong to this widget, and other owners keep their old label.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(caption));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
// A label-only edit must not replace another widget's custom appearances.
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
redraw_widgets(doc, &new_name, only)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
+9
View File
@@ -103,6 +103,8 @@ pub struct Widget {
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
pub caption: Option<String>,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
}
@@ -802,6 +804,13 @@ fn walk(
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
caption: (kind == FieldKind::PushButton)
.then(|| {
let mk = doc.resolve(wd.get(b"MK")?);
let value = doc.resolve(mk.as_dict()?.get(b"CA")?);
value.as_string().map(|s| s.to_text())
})
.flatten(),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
})
})
+102
View File
@@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String {
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
#[test]
fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
for indirect_caption in [false, true] {
let mut doc = one_page();
let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap();
let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap();
let all = fields(&doc);
let (first, second) = (field(&all, &a).obj, field(&all, &b).obj);
let mut parent = Dict::new();
parent.set(b"FT".to_vec(), Object::name("Btn"));
parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64));
parent.set(b"T".to_vec(), PdfString::text("clear_button"));
parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
let mut action = Dict::new();
action.set(b"S".to_vec(), Object::name("ResetForm"));
parent.set(b"A".to_vec(), Object::Dict(action.clone()));
let parent = doc.add(parent);
let mut mk = Dict::new();
let ca = Object::String(PdfString::text("Original"));
let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca };
mk.set(b"CA".to_vec(), ca);
mk.set(b"TP".to_vec(), Object::Int(0));
mk.set(b"R".to_vec(), Object::Int(90));
mk.set(b"VendorNote".to_vec(), PdfString::text("kept"));
let mk = doc.add(mk);
let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
let normal = shared_ap.get(b"N").unwrap().clone();
shared_ap.set(b"R".to_vec(), normal.clone());
shared_ap.set(b"D".to_vec(), normal);
shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept"));
let shared_ap = doc.add(shared_ap);
for widget in [first, second] {
doc.update_dict(widget, |d| {
for key in [b"FT".as_slice(), b"Ff", b"T"] {
d.remove(key);
}
d.set(b"Parent".to_vec(), Object::Ref(parent));
d.set(b"MK".to_vec(), Object::Ref(mk));
d.set(b"AP".to_vec(), Object::Ref(shared_ap));
})
.unwrap();
}
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let original = Document::open(Arc::new(original)).unwrap();
// Full saves can renumber objects: reacquire references from the reopened model.
let f = field(&fields(&original), "clear_button").clone();
let first = f.widgets[0].obj;
let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap();
for full in [false, true] {
let mut doc = original.clone();
let untouched = doc.get(first).clone();
let shared_mk = doc.get(mk).clone();
assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original"));
set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap();
assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched");
assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place");
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.starts_with(original.bytes().as_slice()));
assert!(bytes.len() > original.bytes().len());
bytes
};
let doc = Document::open(Arc::new(bytes)).unwrap();
let f = field(&fields(&doc), "clear_button").clone();
assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>(), [Some("Original"), Some("Clear Form")]);
assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false }));
assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone())));
let w = doc.get(f.widgets[1].obj);
let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap();
assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept");
assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90));
assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0));
let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap();
assert!(apd.contains(b"VendorAP"));
assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive");
assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj"));
}
}
}
#[test]
fn button_caption_rejects_wrong_type_and_index_before_mutation() {
let mut doc = fixture();
for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] {
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() };
assert!(set_props(&mut doc, name, &props).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap();
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交"));
assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]);
set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap();
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some(""));
assert!(undrawn_fields(&doc).is_empty());
assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj"));
}
/// Read the drawn strings together with the appearance's actual font after saving and reopening.
fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec<Vec<u8>>) {
let widget = doc.get(w.obj);
+15
View File
@@ -1933,6 +1933,8 @@ fn days_in(y: i64, m: u32) -> u32 {
}
}
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
let pic: Vec<char> = picture.chars().collect();
let mut out = String::new();
@@ -1955,6 +1957,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
('D', 1) => out.push_str(&d.to_string()),
(_, _) => out.push_str(&format!("{d:02}")),
}
} else if pic[i] == 'E' {
let mut run = 1;
while i + run < pic.len() && pic[i + run] == 'E' {
run += 1;
}
i += run;
// Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday.
let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize;
match run {
1 | 2 => out.push_str(&(weekday + 1).to_string()),
3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])),
_ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")),
}
} else {
out.push(pic[i]);
i += 1;
+15
View File
@@ -636,6 +636,21 @@ mod formcalc {
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
}
#[test]
fn date_pictures_render_the_weekday_letters() {
assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024");
assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29");
assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday");
assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5");
assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday");
assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday");
assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday");
assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday");
assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29");
// Other letters are still literal text.
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
}
#[test]
fn str_and_format_round_exact_ties_away_from_zero() {
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
+4 -3
View File
@@ -499,9 +499,10 @@ pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organi
rebuild(doc, &all)
}
/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order
/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing
/// fonts and images with the originals.
pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option<usize>) -> Result<(), OrganizeError> {
let n = page_count(doc)?;
check(indices, n)?;
let Some(&last) = indices.iter().max() else { return Ok(()) };
@@ -509,7 +510,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga
order.sort_unstable();
order.dedup();
let src = doc.clone();
import_pages(doc, &src, &order, last + 1).map(|_| ())
import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ())
}
/// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of
+15 -2
View File
@@ -1971,11 +1971,24 @@ fn page_boxes_default_inherit_and_set() {
#[test]
fn duplicate_pages_inserts_copies_after_the_last() {
let mut doc = open(fixture());
duplicate_pages(&mut doc, &[0, 1]).unwrap();
duplicate_pages(&mut doc, &[0, 1], None).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
}
#[test]
fn duplicate_pages_at_inserts_the_copies_there() {
let mut doc = open(fixture());
duplicate_pages(&mut doc, &[2], Some(0)).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]);
// An index past the end lands at the end.
let mut doc = open(fixture());
duplicate_pages(&mut doc, &[0], Some(99)).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]);
}
#[test]
fn replace_pages_swaps_content_but_keeps_annotations() {
// Page 2 of the target gets an annotation; replacing its content keeps it.
@@ -2582,7 +2595,7 @@ fn import_can_merge_into_same_named_fields_instead() {
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut doc = doc_with_field("Text1");
crate::duplicate_pages(&mut doc, &[0]).unwrap();
crate::duplicate_pages(&mut doc, &[0], None).unwrap();
assert_eq!(crate::page_count(&doc).unwrap(), 2);
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
}
+1 -1
View File
@@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref
On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested.
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. This does not preserve permission bits, ACLs, hard-link identity or creation dates; other parts of #2658 need separate work.
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
+57 -1
View File
@@ -99,7 +99,8 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
/// a folder at the target is refused by the rename. Flushing is the staging file's own
/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
/// that is replaced keeps its permissions, so a private file stays private; a new file gets the
/// usual mode for a new file.
/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
/// the volume stores one.
/// On macOS, accessible extended attributes of an existing regular destination are copied
/// before flushing; a copy failure refuses the save rather than losing metadata (#2658).
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
@@ -130,6 +131,9 @@ pub fn write_atomically_with(
let keep = std::fs::metadata(path).ok().map(|m| m.permissions());
#[cfg(not(unix))]
let keep = None;
// A saved file is the same document: it keeps its creation date (#2658).
#[cfg(any(windows, target_os = "macos"))]
let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok());
let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?;
let result = (|| {
// Closed at the end of the block, before the rename (required on Windows).
@@ -140,6 +144,8 @@ pub fn write_atomically_with(
preserve_target_xattrs(path, &file).map_err(|e| {
std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}"))
})?;
#[cfg(any(windows, target_os = "macos"))]
keep_created(&file, created);
sync_staged(&file, sync_all)?;
}
std::fs::rename(&tmp, path)
@@ -217,6 +223,20 @@ fn read_xattr_value(size: usize, mut read: impl FnMut(&mut [u8]) -> std::io::Res
Ok(value)
}
/// Give the staging file the creation date of the file it replaces, where the system keeps one
/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the
/// new file's own date, as before.
#[cfg(any(windows, target_os = "macos"))]
fn keep_created(file: &std::fs::File, created: Option<std::time::SystemTime>) {
if let Some(created) = created {
#[cfg(target_os = "macos")]
use std::os::macos::fs::FileTimesExt as _;
#[cfg(windows)]
use std::os::windows::fs::FileTimesExt as _;
let _ = file.set_times(std::fs::FileTimes::new().set_created(created));
}
}
/// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks
/// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator
/// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers.
@@ -361,6 +381,7 @@ mod tests {
#[test]
fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() {
use rustix::fs::{XattrFlags, fgetxattr, setxattr};
use std::os::macos::fs::FileTimesExt as _;
let dir = test_dir("xattrs");
let target = dir.join("out.pdf");
let attributes: &[(&str, &[u8])] = &[
@@ -372,9 +393,15 @@ mod tests {
for (name, value) in attributes {
setxattr(&target, *name, value, XattrFlags::empty()).unwrap();
}
let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap();
assert_eq!(original.metadata().unwrap().created().unwrap(), created);
drop(original);
// Both desktop and automation name forms, including a second save of the replacement.
for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] {
write_atomically_with(&target, b"NEW", form, [7], |staged| {
assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush");
for (name, value) in attributes {
let mut bytes = [0u8; 256];
let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap();
@@ -384,6 +411,7 @@ mod tests {
})
.unwrap();
let saved = std::fs::File::open(&target).unwrap();
assert_eq!(saved.metadata().unwrap().created().unwrap(), created);
for (name, value) in attributes {
let mut bytes = [0u8; 256];
let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap();
@@ -554,4 +582,32 @@ mod tests {
assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private");
let _ = std::fs::remove_dir_all(&dir);
}
/// #2658: a save replaces the file, but the document is the same one: it keeps its creation
/// date where the system lets a program set it.
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn a_replaced_file_keeps_its_creation_date() {
#[cfg(target_os = "macos")]
use std::os::macos::fs::FileTimesExt as _;
#[cfg(windows)]
use std::os::windows::fs::FileTimesExt as _;
let dir = test_dir("created");
let target = dir.join("doc.pdf");
std::fs::write(&target, b"OLD").unwrap();
// 2020-01-02 03:04:05 UTC.
let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap();
drop(file);
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set");
write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date");
// A new file gets a date of its own.
let fresh = dir.join("new.pdf");
write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap();
assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
let _ = std::fs::remove_dir_all(&dir);
}
}
+4
View File
@@ -14,3 +14,7 @@ workspace = true
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
roxmltree = "0.20"
[dev-dependencies]
flate2 = { workspace = true }
+6 -6
View File
@@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option<String> {
let cat = catalog(doc);
let m = doc.resolve(cat.get(b"Metadata")?);
match &*m {
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
@@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option<String> {
pub fn declared(doc: &Document) -> Declared {
let mut d = Declared::default();
if let Some(x) = metadata(doc) {
let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok());
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default();
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
@@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
Some(x) => {
let part = xmp::value(&x, "pdfaid:part");
let conf = xmp::value(&x, "pdfaid:conformance");
let part = xmp::identifier(&x, xmp::PDFA_ID, "part");
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance");
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
}
+46
View File
@@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() {
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
}
}
#[test]
fn standards_identification_uses_namespace_not_prefix() {
fn with_xmp(xmp: &str) -> Document {
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
}
let attribute = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:a="http://www.aiim.org/pdfa/ns/id/" xmlns:u="http://www.aiim.org/pdfua/ns/id/" a:part="2" a:conformance="b" u:part="1"/></rdf:RDF>"#;
let element = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description><part xmlns="http://www.aiim.org/pdfa/ns/id/">2</part><x:conformance xmlns:x="http://www.aiim.org/pdfa/ns/id/" >B</x:conformance></rdf:Description></rdf:RDF>"#;
for xml in [attribute, element] {
let doc = with_xmp(xml);
assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
}
assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
let wrong = r#"<root xmlns:pdfaid="urn:wrong" pdfaid:part="2" pdfaid:conformance="B"/>"#;
let doc = with_xmp(wrong);
assert_eq!(declared(&doc).pdfa, None);
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
let nested = format!("{}{}", "<n>".repeat(1100), "</n>".repeat(1100));
assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
}
#[test]
fn oversized_xmp_is_refused_while_decoding() {
let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
assert!(metadata(&doc).is_none());
assert_eq!(declared(&doc).pdfa, None);
use std::io::Write as _;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(xmp.as_bytes()).unwrap();
let compressed = z.finish().unwrap();
let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
let data = format!("{hex}>");
let stream =
format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
let cat = catalog(&doc);
let raw = doc.resolve(cat.get(b"Metadata").unwrap());
let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
}
+135
View File
@@ -3,6 +3,116 @@
use pdfcraft_cos::Document;
/// The largest XMP packet read; a larger one is treated as missing.
pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
/// Namespace URIs of the PDF/A and PDF/UA identification schemas.
pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/";
pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/";
/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet
/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide
/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before.
pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option<String> {
if xmp.len() > MAX_XMP_BYTES {
return None;
}
match parsed_identifier(xmp, namespace, local) {
Ok(found) => found,
Err(()) => {
let prefix = match namespace {
PDFA_ID => "pdfaid",
PDFUA_ID => "pdfuaid",
_ => return None,
};
value(xmp, &format!("{prefix}:{local}"))
}
}
}
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
if !xml_limits_ok(xmp) {
return Err(());
}
let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 };
let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
for node in doc.descendants().filter(roxmltree::Node::is_element) {
if let Some(value) = node.attribute((namespace, local)) {
return Ok(Some(value.trim().to_owned()));
}
if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local {
return Ok(node.text().map(|value| value.trim().to_owned()));
}
}
Ok(None)
}
/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates.
/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings.
/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`.
fn xml_limits_ok(text: &str) -> bool {
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
(from..hay.len()).find(|&p| hay[p..].starts_with(needle))
}
let b = text.as_bytes();
let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize);
while i < b.len() {
if b[i] != b'<' {
i += 1;
} else if b[i..].starts_with(b"<!--") {
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<![CDATA[") {
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<?") {
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
} else if b[i..].starts_with(b"<!") {
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else if b[i..].starts_with(b"</") {
depth = depth.saturating_sub(1);
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else {
depth += 1;
if depth > 1024 {
return false;
}
// Skip the rest of the start tag, over any quoted attribute value, and remember its last
// significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
// is read rather than a level left open.
i += 1;
let mut quote = 0u8;
let mut last = 0u8;
let mut attributes = 0usize;
while i < b.len() {
let c = b[i];
if quote != 0 {
if c == quote {
quote = 0;
}
} else if c == b'"' || c == b'\'' {
quote = c;
} else if c == b'>' {
i += 1;
break;
} else if c == b'=' {
attributes += 1;
total_attributes += 1;
if attributes > 128 || total_attributes > 4096 {
return false;
}
last = c;
} else if !c.is_ascii_whitespace() {
last = c;
}
i += 1;
}
if last == b'/' {
depth -= 1;
}
}
}
true
}
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
/// as an element (`<pdfaid:part>2</pdfaid:part>`, or inside an `rdf:Alt`/`rdf:Seq`).
pub fn value(xmp: &str, key: &str) -> Option<String> {
@@ -133,3 +243,28 @@ mod tests {
assert_eq!(iso_date("junk"), None);
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn attribute_and_namespace_budgets_bound_parser_work() {
let prefix = r#"<root xmlns:a="http://www.aiim.org/pdfa/ns/id/" a:part="2" "#;
let attrs = |count| (0..count).map(|i| format!("x{i}=\"v=1\" ")).collect::<String>();
let at_limit = format!("{prefix}{}/>", attrs(126));
assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2"));
let over_limit = format!("{prefix}{}/>", attrs(127));
assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None);
let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::<String>();
assert_eq!(identifier(&format!("<root {namespaces}/>"), PDFA_ID, "part"), None);
let many_elements = format!("<root>{}</root>", r#"<n a="v"/>"#.repeat(4097));
assert!(!xml_limits_ok(&many_elements));
// Not well-formed (the prefix is never declared): read by the conventional prefix, as before.
let undeclared = r#"<rdf:Description pdfaid:part="3" pdfaid:conformance="u"/>"#;
assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3"));
assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u"));
let over_bytes = format!("<root>{}</root>", " ".repeat(8 * 1024 * 1024));
assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None);
}
}
+13 -11
View File
@@ -1,5 +1,5 @@
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
//! whose centre maps into a region is set to zero (no ink for image masks), and the result is
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
//! written as a new Flate image; the original object is untouched (other pages may use it).
//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the
//! caller removes the whole image instead; that is fail-closed.
@@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
})
}
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered,
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result<Option<Stream>, ()> {
@@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
};
let mut cleared = 0usize;
for j in 0..h {
// Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
let v = 1.0 - (j as f64 + 0.5) / h as f64;
let (ax, ay) = ctm.apply(0.0, v);
let (bx, by) = ctm.apply(1.0, v);
let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)];
if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) {
// Image space has its origin at the bottom left; row 0 is at the top.
let v0 = 1.0 - (j + 1) as f64 / h as f64;
let v1 = 1.0 - j as f64 / h as f64;
let row_box = ctm.bbox([0.0, v0, 1.0, v1]);
if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) {
continue;
}
for i in 0..w {
let u = (i as f64 + 0.5) / w as f64;
let (x, y) = ctm.apply(u, v);
if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) {
let u0 = i as f64 / w as f64;
let u1 = (i + 1) as f64 / w as f64;
// The bounding box may cover extra pixels for a rotated or skewed image; that
// conservative over-clear prevents a touched sample surviving in the file.
let pixel_box = ctm.bbox([u0, v0, u1, v1]);
if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) {
continue;
}
cleared += 1;
+19
View File
@@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
}
}
#[test]
fn redaction_clears_pixels_intersected_without_covering_their_centres() {
for (content, rect) in
[(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])]
{
let mut doc = one_page(
content,
"/XObject << /Im 7 0 R >>",
vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])],
);
mark(&mut doc, 0, &[rect], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.images_cleared, 1);
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(named_image(&saved, 0, b"Im"), None);
assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234]));
}
}
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
/// before (inherited from the /Pages node, or shared with another page) still named the removed
/// image and form, so a full-rewrite save kept them.
+256
View File
@@ -2881,6 +2881,33 @@ trailer << /Root 1 0 R >>
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
}
/// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space
/// before the line break. The content stream was then taken to start at the space and the
/// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax
/// patch: spaces and tabs before the line break are skipped, with the right `/Length` and
/// with a wrong one. Spaces with no line break after them never start stream data.
#[test]
fn space_before_the_line_break_after_stream_still_renders() {
let pdf = |eol: &str, length: u32| {
format!(
"%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\
4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\
trailer << /Root 1 0 R >>\r\n%%EOF"
)
.into_bytes()
};
for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] {
let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{eol:?}: {:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square");
assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white");
}
}
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
@@ -3462,6 +3489,141 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell
/// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels.
fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec<u8> {
let cell = "0 0 1 rg 0 0 10 10 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
page.rgba.to_vec()
}
/// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with
/// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into
/// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn
/// at full scale, once, filled or stroked: where it is it shows, and around it (left, right,
/// above, below) nothing repeats or smears.
#[test]
fn a_tiling_pattern_drawn_once_shows_its_cell() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]);
// The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30.
let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&once, 40, 15, 25), blue, "the cell");
for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] {
assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it");
}
// Stroked: a wide line across the cell shows it too.
let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern");
assert_eq!(at(&stroked, 40, 25, 25), white);
// An ordinary step still tiles.
let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0");
assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell");
}
/// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40
/// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN
/// geometry reaches the cell renderer, the page renders and what follows still draws.
#[test]
fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() {
assert!(hayro::usable_cell_scale(2.0, 1.0));
for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] {
assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}");
}
let thin = "0.0000000000000000000000000000000000000001";
let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
let cell = "0 0 1 rg 0 0 10 10 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws");
assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing");
}
/// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area
/// can't reach another: a page wide (or tall) enough to show the next cell, or the one before,
/// still tiles them.
#[test]
fn a_large_step_still_tiles_where_the_next_cell_shows() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let blue = vec![0, 0, 255];
// Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit,
// by 3000/3100; a pixel 4 points into a cell is blue either way.
let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f";
let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0");
assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right");
let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0");
assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left");
let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f";
let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0");
assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above");
let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100");
assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below");
// A stroke counts with its width: this line ends at x 3080, short of the next cell at
// 3100, but its square cap reaches 3110.
let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0");
assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap");
// A miter reaches further than the line width (review on #3356): this join's vertex is
// at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long
// under the default limit of 10, reaches 3130.
let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0");
// The tip narrows to a point, so this pixel is partly covered: blue, not white.
let tip = at(&mitered, 3200, 3104, 10);
assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}");
// Under a skew, a direction between the axes stretches more than either axis (review on
// #3356). This join's vertex is at device x 2980 and its miter tip, along that direction,
// at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell;
// the most any direction stretches (1.461) puts it at 3126.
let skewed = tiling_page(
3200,
20,
"/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S",
"3100",
"1 0 0 1 0 0",
);
// The tip narrows to a point, so its pixels are partly covered.
let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y));
assert!(
under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240),
"the next cell, under the skewed miter's tip: {:?}",
under_tip.collect::<Vec<_>>()
);
}
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
@@ -3503,6 +3665,60 @@ trailer << /Root 1 0 R >>
pdf
}
/// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header
/// has a colour specification naming enumerated colour space `enumerated`.
fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec<u8> {
let raw = jpx_midpoint_pdf(8, components);
let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap();
let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2;
let codestream = &raw[start..end];
let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height
ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width
ihdr.extend_from_slice(&components.to_be_bytes());
ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR
let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat();
let jp2 = [
vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a],
jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "),
jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()),
jp2_box(b"jp2c", codestream),
]
.concat();
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /Filter /JPXDecode /Length {} >> stream\n",
content.len(),
jp2.len()
)
.into_bytes();
pdf.extend_from_slice(&jp2);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
pdf
}
/// #3306: a JP2 image whose colour specification names an enumerated colour space the
/// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is
/// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the
/// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is
/// drawn as before.
#[test]
fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() {
for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] {
let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{enumerated}: {:?}", page.error);
let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3];
assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}");
}
}
#[test]
fn jpx_stream_bit_depth_boundaries_do_not_panic() {
use hayro::hayro_syntax::bit_reader::BitReader;
@@ -3631,6 +3847,46 @@ endstream endobj
}
}
/// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote
/// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were
/// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture
/// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the
/// filter's other output path.
#[test]
fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() {
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] {
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream
",
content.len(),
ALL_BLACK_8X4.len()
)
.into_bytes();
pdf.extend_from_slice(&ALL_BLACK_8X4);
pdf.extend_from_slice(
b"
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
);
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error);
for y in [17, 22, 27, 32] {
assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded");
}
}
}
#[test]
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
use hayro_syntax::object::{FromBytes, Object};
+8 -1
View File
@@ -2,7 +2,7 @@
//! Private keys remain in CNG; Windows may ask permission to use a key.
//!
//! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't
//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out
//! sign with, a key usage or extended key usage that doesn't allow signing documents) are reported in [`Listing::unusable`] with the reason rather than left out
//! silently, so a missing identity can be explained (issue #179).
use std::sync::Arc;
@@ -161,6 +161,13 @@ fn entries() -> Result<Entries, SignError> {
continue;
}
}
// A smart card holds encryption and logon certificates beside the signing one, all under
// the same name; a signature made with them doesn't validate, so they aren't offered.
// Checked after the key, so a certificate without one stays out of the list entirely.
if let Some(reason) = certificate.signing_problem_clause() {
skip(certificate.display_name(), reason, false);
continue;
}
let public = certificate.public_key.clone();
let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public }));
let friendly_name = Some(certificate.display_name());
+9
View File
@@ -384,6 +384,15 @@ impl Certificate {
}
}
/// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's
/// name ("it is not issued for signing documents (…)"), for lists of digital IDs.
pub fn signing_problem_clause(&self) -> Option<String> {
self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") {
Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)),
None => d.message,
})
}
/// Issued by itself (subject = issuer and its own key verifies it).
pub fn is_self_signed(&self) -> bool {
self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key)
+15
View File
@@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() {
assert_eq!(status, Status::Valid, "{details:?}");
}
#[test]
fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() {
// A DoD CAC's four certificates, all under the same name; only the signing one may sign.
let r = root();
let clause = |exts: &[Vec<u8>]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause();
let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]);
assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)"));
let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]);
assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)"));
let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]);
assert_eq!(card_auth, logon);
let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]);
assert_eq!(signing, None);
}
#[test]
fn unreadable_constraint_extensions_fail_closed() {
// keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign.
+159 -55
View File
@@ -482,6 +482,9 @@ pub struct DocView {
/// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as
/// it decays, instead of adding on top of the 1:1 motion.
precise_scroll_until: f64,
/// Per-axis line-wheel state; horizontal input cannot extend a vertical burst.
wheel_boost_x: crate::wheel_boost::WheelBoost,
wheel_boost_y: crate::wheel_boost::WheelBoost,
/// Touchpad momentum after the fingers lift (#531).
fling: crate::fling::Fling,
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
@@ -756,6 +759,8 @@ impl DocView {
shown: false,
wheel: Default::default(),
precise_scroll_until: 0.0,
wheel_boost_x: Default::default(),
wheel_boost_y: Default::default(),
fling: Default::default(),
auto_scroll: Default::default(),
selected: BTreeSet::new(),
@@ -835,6 +840,7 @@ impl DocView {
self.flash = None;
// A thumbnail drag holds page indexes from before the change.
self.panel_drag = None;
self.reset_coarse_state();
}
/// The document was replaced by a newer version of its file (#431). The reader's place
@@ -1153,6 +1159,7 @@ impl DocView {
.unwrap_or(0.0);
self.goto = Some((page, frac));
self.current = page;
self.reset_coarse_state();
}
/// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its
@@ -1174,6 +1181,14 @@ impl DocView {
self.thumbs.get(&page).map(|t| &t.tex)
}
/// Drops wheel state after a discontinuous jump so the destination cannot
/// inherit momentum from the previous viewport.
fn reset_coarse_state(&mut self) {
self.wheel.clear_motion();
self.wheel_boost_x.reset();
self.wheel_boost_y.reset();
}
pub fn go_to_page(&mut self, page: usize) {
let page = page.min(self.page_count.saturating_sub(1));
if page != self.current {
@@ -1187,7 +1202,7 @@ impl DocView {
self.forward.clear();
}
// Partial wheel motion belongs to the old place.
self.wheel.clear_motion();
self.reset_coarse_state();
self.goto = Some((page, 0.0));
self.current = page;
}
@@ -1322,6 +1337,7 @@ impl DocView {
let page = page.min(self.page_count.saturating_sub(1));
self.goto = Some((page, 0.0));
self.current = page;
self.reset_coarse_state();
true
}
@@ -1346,12 +1362,14 @@ impl DocView {
pub fn rotate_view(&mut self, clockwise: bool) {
self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Switch the zoom mode, keeping the current page in view.
pub fn set_fit(&mut self, fit: Fit) {
self.fit = fit;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is.
@@ -1363,6 +1381,7 @@ impl DocView {
}
self.layout = layout;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Whether the view scrolls continuously (as opposed to one page or spread at a time).
@@ -1376,6 +1395,7 @@ impl DocView {
if self.continuous != continuous {
self.continuous = continuous;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
}
@@ -1391,6 +1411,7 @@ impl DocView {
if self.cover != cover {
self.cover = cover;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
}
@@ -2037,6 +2058,14 @@ fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within
/// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed
/// the page misplaced for a frame and then jumped (zooming out beside a page that fits).
fn anchored_scroll(point: Pos2, rel: Vec2, content: Vec2, viewport: Vec2) -> Vec2 {
let max = vec2((content.x - viewport.x).max(0.0), (content.y - viewport.y).max(0.0));
vec2((point.x - rel.x).clamp(0.0, max.x), (point.y - rel.y).clamp(0.0, max.y))
}
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
fn scale_tag(scale: f32) -> u64 {
(f64::from(scale) * 65536.0).round() as u64
@@ -2318,6 +2347,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
/// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up
/// keep this much of the previous screen in view.
const KEY_SCROLL_LINE: f32 = 40.0;
/// Suppression window for egui's eased copy of point input.
///
/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s.
const SUPPRESS_SECS: f64 = 0.25;
/// Short rolling extension while egui still has an unprocessed wheel tail.
const SUPPRESS_TAIL_SECS: f64 = 0.05;
// The viewer moves between docking and the central panel as modes change; keep its scroll
// identity tied to the document instead of either parent UI.
fn page_scroll<R>(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
id: egui::Id,
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
) -> egui::scroll_area::ScrollAreaOutput<R> {
ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner
}
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -2507,36 +2553,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
}
// Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel`
// events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing
// path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply
// the precise delta ourselves this frame through the un-animated scroll path, and suppress
// egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse
// mouse wheel keeps egui's easing (pleasant for chunky notches).
// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured,
// smoothed base and adds only the accelerated surplus. While point suppression is active,
// reconstruct line and page input here so current coarse events survive without exposing an
// older eased tail.
let now = ui.input(|i| i.time);
let mut precise_scroll = Vec2::ZERO;
if ui.rect_contains_pointer(avail) {
let mut coarse_wheel = false;
ui.input(|i| {
for e in &i.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e {
if modifiers.command || modifiers.ctrl {
continue; // Zooming, not scrolling.
}
match unit {
// Shift scrolls sideways, matching egui's own wheel handling.
egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y,
egui::MouseWheelUnit::Point => precise_scroll += *delta,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true,
}
}
}
});
if coarse_wheel {
view.precise_scroll_until = 0.0;
} else if precise_scroll != Vec2::ZERO {
view.precise_scroll_until = now + 0.25;
}
let input = if ui.rect_contains_pointer(avail) {
// Match the line distance used by egui's other scroll areas.
let live = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS };
ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging))
} else {
crate::wheel_boost::FrameInput::default()
};
let mut precise_scroll = input.precise;
let mut line_base = input.line;
let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now);
let has_page = input.page != Vec2::ZERO;
if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page {
view.precise_scroll_until = now + SUPPRESS_SECS;
}
if edge_turned {
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
@@ -2549,11 +2584,40 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned());
if paging_owns_vertical {
precise_scroll.y = 0.0;
line_base.y = 0.0;
boost_extra.y = 0.0;
}
if now < view.precise_scroll_until {
if ui.input(|i| i.is_scrolling()) {
// Keep hiding egui's unprocessed copy until it has drained. The short rolling grace
// avoids a delayed duplicate without prolonging suppression after the tail is gone.
view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS);
}
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
// The point path suppresses every eased wheel delta. Restore the line base through the
// same un-eased path as its surplus; paging-owned axes were cleared above.
boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base);
if has_page {
// Page units normally stay on egui's viewport-sized path. During suppression only,
// reproduce the current event directly so an older hidden tail stays hidden.
let page_scale = ui.input(|i| i.viewport_rect().height());
let mut page_delta =
if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO };
if paging_owns_vertical {
page_delta.y = 0.0;
}
boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta);
}
} else {
precise_scroll = Vec2::ZERO;
if line_base != Vec2::ZERO || has_page {
// Selection handling below uses zero to distinguish an inactive window.
view.precise_scroll_until = 0.0;
}
}
if has_page && paging_owns_vertical {
// Page input belongs to egui on the free axis; the pager still owns vertical motion.
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a
// stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or
@@ -2631,7 +2695,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
} else if let Some((page, frac)) = view.goto.take() {
let page = page.min(rects.len() - 1);
let r = rects[page];
@@ -2766,7 +2830,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
}
}
let out = scroll.show_viewport(ui, |ui, viewport| {
let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail);
let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| {
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
// selecting text, drawing a mark, or panning with the Hand tool.
if middle_gesture {
@@ -2776,10 +2841,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.set_opacity(opacity);
}
// Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide
// (#531), and a drawing's edge scroll; positive y moves the content down. All scroll
// without easing, unlike egui's wheel default.
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + precise_scroll + glide;
if delta != Vec2::ZERO {
// (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y
// moves the content down. All scroll without easing, unlike egui's wheel default.
// ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
// until after the text widgets when a selection owns that drag.
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra };
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
@@ -3512,17 +3580,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
// pointer leaves the selection's page for the gap or the next page; over the whole
// document area, like #759. A Line/Page event clears precise_scroll_until; a Point
// gesture's eased tail after its suppression deadline is not picked up.
if view.selection_drag
&& navigation_enabled
&& unobstructed
&& !single_paging
&& view.precise_scroll_until == 0.0
&& ui.rect_contains_pointer(avail)
{
let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta));
if delta != Vec2::ZERO {
// document area, like #759. Combine direct and smoothed motion in one request so mixed
// Point and Line events retain their full contribution.
if selection_wheel {
let smooth =
if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO };
let delta = precise_scroll + boost_extra + smooth;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
}
@@ -4862,6 +4926,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
Some(if p.x < r.center().x { *i } else { i + 1 })
}
/// Ctrl (Option on macOS) held when a page drag drops copies the dragged pages into the gap
/// instead of moving them, as Acrobat's Organize Pages does.
///
/// The OS comes from the context, not `cfg!`: the wasm build runs on macOS too, where
/// `cfg!(target_os = "macos")` is false, so Option has to copy there as well (#3092 review).
fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool {
if crate::commands::mac_shortcuts(ctx) { m.alt } else { m.ctrl }
}
/// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked.
fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool {
let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click());
@@ -5093,6 +5166,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
}
// While dragging: the gap the pages would go to, drawn as a bar.
if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) {
// Ctrl (Option on macOS) held over the drop copies the pages; see #2997.
let copying = copy_modifier(ui.ctx(), &ui.input(|i| i.modifiers));
if let Some(gap) = drop_gap(&cells, p) {
let x = match cells.iter().find(|(i, _)| *i == gap) {
Some((_, r)) => r.left() + 3.0,
@@ -5100,8 +5175,18 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
};
let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0));
ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent));
let mut text_at = p + vec2(14.0, 14.0);
if copying {
// A "+" by the pointer: this drop copies the pages, it doesn't move them.
let badge = p + vec2(12.0, 12.0);
ui.painter().circle(badge, 9.0, t.accent, Stroke::new(1.0, t.accent));
let ink = Stroke::new(1.5, Color32::WHITE);
ui.painter().line_segment([badge - vec2(4.0, 0.0), badge + vec2(4.0, 0.0)], ink);
ui.painter().line_segment([badge - vec2(0.0, 4.0), badge + vec2(0.0, 4.0)], ink);
text_at.x += 18.0;
}
ui.painter().text(
p + vec2(14.0, 14.0),
text_at,
Align2::LEFT_TOP,
if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) },
theme::medium(12.0),
@@ -5111,13 +5196,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
if drop {
view.org_drag = None;
if let Some(gap) = drop_gap(&cells, p) {
// `to` counts positions without the moving pages.
let to = gap - pages.iter().filter(|x| **x < gap).count();
let first = pages[0];
let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
if !(contiguous && to == first) {
view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
view.selected = (to..to + pages.len()).collect();
if copying {
// The copies land at the gap as it is: nothing leaves its place.
view.pending_edit = Some(Edit::DuplicatePages { pages: pages.clone(), at: Some(gap) });
view.selected = (gap..gap + pages.len()).collect();
} else {
// `to` counts positions without the moving pages.
let to = gap - pages.iter().filter(|x| **x < gap).count();
let first = pages[0];
let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
if !(contiguous && to == first) {
view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
view.selected = (to..to + pages.len()).collect();
}
}
}
}
@@ -5213,6 +5304,19 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage {
#[cfg(test)]
mod tests {
#[test]
fn zoom_anchor_scroll_stays_within_the_content() {
let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0));
// Past the end: clamped to the maximum (200, 1400), not left for egui to snap back.
assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0));
// Before the start.
assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO);
// Inside: unchanged.
assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0));
// Content smaller than the viewport: no scrolling.
assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0));
}
#[test]
fn page_size_hint_zone_is_the_bottom_left_quadrant() {
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0));
+88 -76
View File
@@ -533,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
/// Keep menus usable when their contents are taller than the window.
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
}
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File"));
ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit"));
ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages"));
ui.menu_button(tl!("View"), |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
scroll_menu(ui, |ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
ui.menu_button(tl!("View"), |ui| {
scroll_menu(ui, |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
scroll_menu(ui, |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
}
}
})
});
})
});
});
ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help"));
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
})
});
}
+12 -10
View File
@@ -627,7 +627,7 @@ impl PdfCraftApp {
"create.clipboard" => self.create_from_clipboard(),
"optimize.reduce" => self.reduce_file_size(),
"page.duplicate" => {
self.apply_edit(Edit::DuplicatePages { pages: targets });
self.apply_edit(Edit::DuplicatePages { pages: targets, at: None });
}
// Two or more selected pages reverse among themselves; otherwise the whole document.
"page.reverse" => {
@@ -779,17 +779,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
let mut open: Option<String> = None;
let mut clear = false;
ui.menu_button(label, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
crate::chrome::scroll_menu(ui, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
})
});
if let Some(p) = open {
app.open_recent(&p);
+14 -3
View File
@@ -410,12 +410,23 @@ impl Control {
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
let modifiers = modifiers(p.get("modifiers"))?;
let which = pointer_button(p, "ui.drag")?;
// The events carry the modifiers and so does the input state: gestures that read
// `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them.
let held = |mut events: Vec<egui::Event>| {
if modifiers != egui::Modifiers::NONE {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
}
events
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers };
let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]];
let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])];
for k in 1..=steps {
frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]);
frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]));
}
frames.push(held(vec![button(to, false)]));
if modifiers != egui::Modifiers::NONE {
frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
frames.push(vec![button(to, false)]);
let n = self.inject(frames);
Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] })))
}
+5
View File
@@ -133,6 +133,11 @@ impl PdfCraftApp {
let to = to.min(info.pages.len() - n);
view.select_pages(&(to..to + n).collect::<Vec<_>>());
}
Edit::DuplicatePages { pages, at: Some(at) } => {
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
let at = at.min(info.pages.len().saturating_sub(n));
view.select_pages(&(at..at + n).collect::<Vec<_>>());
}
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
// A stroke drawn with the pen stays unselected, so the selection box and
+1
View File
@@ -139,6 +139,7 @@ mod tab_context;
pub mod theme;
pub mod ui_scale;
pub mod updates;
mod wheel_boost;
mod wheel_pager;
mod widgets;
pub mod window_controls;
+10 -1
View File
@@ -734,6 +734,7 @@ pub struct FieldDraft {
pub tab: FieldTab,
pub name: String,
pub tooltip: String,
pub caption: String,
pub read_only: bool,
pub required: bool,
pub multiline: bool,
@@ -805,6 +806,7 @@ impl FieldDraft {
tab: FieldTab::General,
name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(),
tooltip: f.tooltip.clone().unwrap_or_default(),
caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(),
read_only: f.read_only(),
required: f.has(pdfcraft_engine::field_flags::REQUIRED),
multiline: f.has(pdfcraft_engine::field_flags::MULTILINE),
@@ -848,7 +850,7 @@ impl FieldDraft {
fn tabs(&self) -> Vec<(FieldTab, &'static str)> {
let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")];
if !matches!(self.kind, FormFieldKind::PushButton | FormFieldKind::Signature) {
if self.kind != FormFieldKind::Signature {
t.push((FieldTab::Options, "Options"));
}
if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) {
@@ -865,6 +867,7 @@ impl FieldDraft {
let p = FieldProps {
name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()),
tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()),
caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())),
read_only: ch(self.read_only, o.read_only),
required: ch(self.required, o.required),
multiline: ch(self.multiline, o.multiline),
@@ -1087,6 +1090,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
FieldTab::Calculate => calculate_tab(ui, d, t),
FieldTab::Actions => actions_tab(ui, d, t),
FieldTab::Options => match d.kind {
FormFieldKind::PushButton => {
ui.horizontal(|ui| {
let l = ui.label(tl!("Label"));
ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id);
});
}
FormFieldKind::Text => {
use pdfcraft_engine::field_flags as ff;
egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
+24 -2
View File
@@ -9,6 +9,9 @@
//! Encrypted documents are stored encrypted (the working file is), so recovery never writes
//! plaintext of a protected document to disk. The web build has no recovery store yet.
//!
//! On Unix the folder is owner-only (`0o700`), so other accounts on the machine can't read the
//! snapshots of documents that aren't encrypted.
//!
//! A running instance holds an exclusively locked `instance-<pid>-<n>.lock` in the folder and
//! names it in the snapshots it writes, so a second instance can tell live autosaves from a
//! crash's leftovers and neither offers nor discards them (#2166). The OS releases the lock
@@ -75,12 +78,31 @@ impl RecoveryStore {
Self { dir: dir.into(), lock: Arc::default() }
}
/// Create the folder if it is missing. On Unix only the owner may open it: snapshots are
/// unsaved copies of open documents, and with the default mode other accounts on the
/// machine could read them. A folder an earlier version made is tightened too. A volume
/// that can't change modes (FAT, some network shares) works as before, with one warning in
/// the log.
fn create_dir(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let open_to_others = std::fs::metadata(&self.dir).is_ok_and(|m| m.permissions().mode() & 0o077 != 0);
if open_to_others && let Err(e) = std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700)) {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| log::warn!("recovery folder {} can't be made owner-only: {e}", self.dir.display()));
}
}
Ok(())
}
/// Mark the folder as in use by a running instance (see the module doc). Without a lock
/// (the folder can't be written, or the platform has no file locks) snapshots are written
/// and offered as before.
pub fn claim(&self) {
self.lock.get_or_init(|| {
if let Err(e) = std::fs::create_dir_all(&self.dir) {
if let Err(e) = self.create_dir() {
log::warn!("recovery folder {} can't be made: {e}", self.dir.display());
return None;
}
@@ -166,7 +188,7 @@ impl RecoveryStore {
/// Write (or replace) an entry. The PDF is written before its metadata, both atomically, so
/// a listed entry always has complete bytes.
pub fn write(&self, meta: &RecoveryMeta, bytes: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
self.create_dir()?;
crate::editing::write_atomically(&self.dir.join(format!("{}.pdf", meta.key)).to_string_lossy(), bytes)?;
let json = serde_json::to_vec_pretty(meta).map_err(std::io::Error::other)?;
crate::editing::write_atomically(&self.dir.join(format!("{}.json", meta.key)).to_string_lossy(), &json)
+1 -1
View File
@@ -18,7 +18,7 @@ pub enum ScrollSpeed {
}
/// egui's own `line_scroll_speed`, which its wheel zoom rate is tuned against.
const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 };
pub(crate) const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 };
/// Points one wheel line scrolls at Normal speed.
const NORMAL_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") {
+395
View File
@@ -0,0 +1,395 @@
//! Bounded acceleration for coarse wheel input in the document viewport.
//!
//! Line-unit events describe notches, not motion over time. A fixed distance per
//! notch makes a fast spin feel slow, so this module derives a bounded multiplier
//! from arrival time and direction. The state is constant-size and leaves isolated
//! notches unchanged.
use egui::Vec2;
/// Idle time after which a burst is forgotten, in seconds.
const RESET_GAP_SECS: f64 = 0.3;
/// Notch spacing at or below which input counts as a continuous spin, in seconds.
const FAST_GAP_SECS: f64 = 0.08;
/// Multiplier gained per fast consecutive notch.
const BOOST_PER_TICK: f32 = 0.4;
/// Hard limit for accelerated motion.
const MAX_BOOST: f32 = 6.0;
/// Ticks that affect the output.
const MAX_TICKS: u32 = 13;
/// Extra ticks retained beyond the ceiling. They absorb a few slow events
/// before the visible multiplier starts to fall.
const HYSTERESIS_TICKS: u32 = 4;
/// Maximum scroll applied from one frame of coarse input, in points per axis.
/// Each event is clamped first; this caps the sum.
pub(crate) const MAX_FRAME_POINTS: f32 = 8192.0;
/// Per-event clamp, in native units (lines or pages). Keeps one hostile event
/// bounded before points conversion.
pub(crate) const MAX_EVENT_UNITS: f32 = 10.0;
/// Constant-size arrival-rate state for line wheel events.
#[derive(Debug, Default)]
pub(crate) struct WheelBoost {
last: f64,
burst: u32,
dir: i8,
}
impl WheelBoost {
/// Returns the multiplier for one bucket of motion carrying `motion`.
///
/// `motion` is signed in native units; only its sign matters. Zero means
/// no motion. Non-finite time returns 1.0 and leaves state unchanged;
/// backwards time resets the burst and returns 1.0.
pub(crate) fn multiplier(&mut self, now: f64, motion: f32) -> f32 {
if !now.is_finite() || !motion.is_finite() || motion == 0.0 {
return 1.0;
}
let dir = if motion > 0.0 { 1 } else { -1 };
let idle = now - self.last;
if !idle.is_finite() || idle < 0.0 {
self.last = now;
self.burst = 0;
self.dir = dir;
return 1.0;
}
if idle > RESET_GAP_SECS || dir != self.dir {
self.burst = 0;
} else if idle <= FAST_GAP_SECS {
self.burst = self.burst.saturating_add(1).min(MAX_TICKS + HYSTERESIS_TICKS);
} else {
// Slow input in the same direction decays rather than extending the burst.
self.burst = self.burst.saturating_sub(1);
}
self.last = now;
self.dir = dir;
let ticks = self.burst.min(MAX_TICKS);
// MAX_TICKS is well within f32's exact integer range.
(1.0 + ticks as f32 * BOOST_PER_TICK).min(MAX_BOOST)
}
/// Drops burst state. Call after a programmatic scroll jump.
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
}
/// Caps one frame of accumulated coarse scroll, per axis. A non-finite axis
/// maps to zero; the other axis is preserved.
pub(crate) fn cap_frame(delta: Vec2) -> Vec2 {
Vec2::new(
if delta.x.is_finite() { delta.x.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 },
if delta.y.is_finite() { delta.y.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 },
)
}
/// One frame of wheel input, normalized in a single pass. Line motion is in
/// points; page motion remains in native units until the viewport is known.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct FrameInput {
pub precise: Vec2,
pub line: Vec2,
pub page: Vec2,
}
/// Folds raw wheel events into frame totals without consulting time or UI state.
/// Invalid scales contribute no line motion.
pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging: bool) -> FrameInput {
let line_points = sanitize_scale(line_points);
let mut out = FrameInput::default();
for event in events {
let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event else { continue };
if modifiers.command || modifiers.ctrl {
continue; // Zooming, not scrolling.
}
if !delta.is_finite() {
continue;
}
match unit {
egui::MouseWheelUnit::Point if modifiers.shift => {
// Shift scrolls sideways, matching egui's own wheel handling.
let sum = delta.x + delta.y;
if sum.is_finite() {
out.precise.x += sum;
}
}
egui::MouseWheelUnit::Point => out.precise += *delta,
egui::MouseWheelUnit::Line if modifiers.shift => {
let sum = delta.x + delta.y;
if sum.is_finite() {
out.line.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
}
}
egui::MouseWheelUnit::Line => {
out.line.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
if !single_paging {
out.line.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
}
}
egui::MouseWheelUnit::Page if modifiers.shift => {
let sum = delta.x + delta.y;
if sum.is_finite() {
out.page.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
}
}
egui::MouseWheelUnit::Page => {
out.page.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
out.page.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
}
}
}
// Bound batched events before acceleration is applied.
out.precise = cap_frame(out.precise);
out.line = cap_frame(out.line);
out.page = cap_frame(out.page);
out
}
/// Converts the frame total to the extra scroll delta beyond egui's share.
///
/// egui applies the base distance through its own smoothing. This function
/// returns only the accelerated surplus, so an isolated notch adds nothing.
/// Each axis advances once per frame; events batched into one frame cannot
/// accelerate one another. Output is frame-capped.
pub(crate) fn boost_extra(input: FrameInput, boost_x: &mut WheelBoost, boost_y: &mut WheelBoost, now: f64) -> Vec2 {
let mut extra = Vec2::ZERO;
if input.line.x != 0.0 && input.line.x.is_finite() {
extra.x += input.line.x * (boost_x.multiplier(now, input.line.x) - 1.0);
}
if input.line.y != 0.0 && input.line.y.is_finite() {
extra.y += input.line.y * (boost_y.multiplier(now, input.line.y) - 1.0);
}
cap_frame(extra)
}
/// Maps a non-finite or non-positive points-per-unit scale to zero.
fn sanitize_scale(points: f32) -> f32 {
if points.is_finite() && points > 0.0 { points } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_and_slow_notches_stay_unboosted() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(0.0, -1.0), 1.0);
assert_eq!(b.multiplier(0.5, -1.0), 1.0);
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
}
#[test]
fn fast_spin_accelerates_then_caps() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
let mut last = 1.0;
for (i, t) in (1..=20).map(|i| 1.0 + f64::from(i) * 0.02).enumerate() {
last = b.multiplier(t, -1.0);
if i < 2 {
assert!(last >= 1.0, "early burst must not jump, got {last}");
}
}
assert!(last > 2.0, "a sustained fast spin must accelerate, got {last}");
assert!(last <= MAX_BOOST, "boost must be capped, got {last}");
assert_eq!(last, MAX_BOOST, "a long spin should reach the ceiling");
for i in 1..=20 {
let m = b.multiplier(1.5 + f64::from(i) * 0.02, -1.0);
assert!(m <= MAX_BOOST, "boost must stay capped, got {m}");
}
}
#[test]
fn reversal_and_idle_reset_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=6 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
let boosted = b.multiplier(1.0 + 7.0 * 0.02, -1.0);
assert!(boosted > 1.0, "precondition: burst is boosted, got {boosted}");
assert_eq!(b.multiplier(1.0 + 8.0 * 0.02, 1.0), 1.0);
for i in 9..=14 {
b.multiplier(1.0 + f64::from(i) * 0.02, 1.0);
}
assert_eq!(b.multiplier(5.0, 1.0), 1.0);
}
#[test]
fn invalid_input_never_boosts_or_corrupts() {
let mut b = WheelBoost::default();
for bad_now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(b.multiplier(bad_now, -1.0), 1.0);
}
for bad_motion in [0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(b.multiplier(1.0, bad_motion), 1.0);
}
assert_eq!(b.multiplier(2.0, -1.0), 1.0);
b.reset();
assert_eq!(b.multiplier(3.0, -1.0), 1.0);
}
#[test]
fn same_timestamp_events_count_as_a_burst() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
for _ in 0..6 {
b.multiplier(1.0, -1.0);
}
assert!(b.multiplier(1.0, -1.0) > 1.0);
}
#[test]
fn fractional_notches_share_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -0.25);
for i in 1..=8 {
b.multiplier(1.0 + f64::from(i) * 0.02, -0.25);
}
assert!(b.multiplier(1.0 + 9.0 * 0.02, -0.25) > 1.0);
}
#[test]
fn sleep_wake_resets_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=6 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
assert_eq!(b.multiplier(3600.0, -1.0), 1.0);
}
#[test]
fn backwards_time_resets_without_corrupting() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
b.multiplier(1.02, -1.0);
assert_eq!(b.multiplier(0.5, -1.0), 1.0);
assert_eq!(b.multiplier(0.52, -1.0), 1.4);
}
#[test]
fn unhurried_spin_decays() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=8 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
let peak = b.multiplier(1.0 + 9.0 * 0.02, -1.0);
assert!(peak > 1.0);
// Same direction but slower than the fast gap: each event sheds one tick.
let mut prev = peak;
for i in 10..=13 {
let m = b.multiplier(1.0 + f64::from(i) * 0.15, -1.0);
assert!(m <= prev, "decay must not grow: {m} > {prev}");
prev = m;
}
}
#[test]
fn hysteresis_holds_the_ceiling_briefly() {
let mut b = WheelBoost::default();
let mut t = 1.0;
b.multiplier(t, -1.0);
for _ in 0..20 {
t += 0.02;
b.multiplier(t, -1.0);
}
assert_eq!(b.multiplier(t + 0.02, -1.0), MAX_BOOST);
// A couple of unhurried events consume hysteresis before output falls.
t += 0.15;
assert_eq!(b.multiplier(t, -1.0), MAX_BOOST);
t += 0.15;
assert_eq!(b.multiplier(t, -1.0), MAX_BOOST);
}
#[test]
fn frame_cap_bounds_each_axis() {
use egui::vec2;
assert_eq!(cap_frame(vec2(40.0, -40.0)), vec2(40.0, -40.0));
assert_eq!(cap_frame(vec2(1e9, -1e9)), vec2(MAX_FRAME_POINTS, -MAX_FRAME_POINTS));
assert_eq!(cap_frame(vec2(f32::NAN, 40.0)), vec2(0.0, 40.0));
assert_eq!(cap_frame(vec2(40.0, f32::INFINITY)), vec2(40.0, 0.0));
}
fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }
}
fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT }
}
#[test]
fn isolated_notch_adds_no_extra() {
use egui::MouseWheelUnit::Line;
for base in [50.0, 100.0, 200.0, 40.0, 8.0] {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], base, false);
assert_eq!(input.line, egui::vec2(0.0, -base));
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO);
}
}
#[test]
fn fast_spin_adds_a_bounded_extra() {
use egui::MouseWheelUnit::Line;
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
let mut peak = 0.0f32;
for i in 0..20 {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], 100.0, false);
let extra = boost_extra(input, &mut bx, &mut by, 1.0 + f64::from(i) * 0.02);
assert!(extra.y <= 0.0, "extra follows the spin direction, got {extra:?}");
peak = peak.min(extra.y);
}
assert!(peak < 0.0, "a sustained spin must add an extra");
assert!(peak >= -MAX_FRAME_POINTS, "extra stays frame-capped, got {peak}");
}
#[test]
fn point_and_page_events_stay_out_of_the_line_boost() {
use egui::{MouseWheelUnit::Line, MouseWheelUnit::Page, MouseWheelUnit::Point};
let input = accumulate(&[wheel(Page, 0.0, -1.0), wheel(Point, 0.0, -30.0)], 100.0, false);
assert_eq!(input.precise, egui::vec2(0.0, -30.0));
assert_eq!(input.page, egui::vec2(0.0, -1.0));
assert_eq!(input.line, egui::Vec2::ZERO);
let input = accumulate(&[wheel(Line, 0.0, -1.0), wheel(Page, 0.0, -1.0)], 100.0, false);
assert_eq!(input.line, egui::vec2(0.0, -100.0));
assert_eq!(input.page, egui::vec2(0.0, -1.0));
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO);
}
#[test]
fn single_paging_drops_vertical_keeps_horizontal() {
use egui::MouseWheelUnit::Line;
let input = accumulate(&[wheel(Line, 5.0, -1.0)], 100.0, true);
assert_eq!(input.line, egui::vec2(500.0, 0.0));
}
#[test]
fn shift_merges_into_horizontal() {
use egui::MouseWheelUnit::{Line, Page};
let input = accumulate(&[shifted(Line, 1.0, -2.0)], 100.0, false);
assert_eq!(input.line, egui::vec2(-100.0, 0.0));
let input = accumulate(&[shifted(Page, 1.0, -2.0)], 100.0, false);
assert_eq!(input.page, egui::vec2(-1.0, 0.0));
}
#[test]
fn invalid_scales_and_events_contribute_nothing() {
use egui::MouseWheelUnit::Line;
for bad in [f32::NAN, 0.0, -10.0, f32::INFINITY] {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false);
assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}");
}
let zoom =
egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND };
let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false);
assert_eq!(input, FrameInput::default());
}
}
+52
View File
@@ -5,10 +5,23 @@ use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::control::{ControlClient, Reply};
use serde_json::{Value, json};
/// The text each page shows, in order, as the app's session holds it.
fn page_texts(app: &PdfCraftApp) -> Vec<String> {
let doc = app.session.get(app.views[0].id).unwrap();
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
(0..r.page_count())
.map(|p| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
})
.collect()
}
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
@@ -1034,6 +1047,45 @@ fn state_reports_the_selected_pages() {
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([]));
}
#[test]
fn ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page() {
let (mut h, c) = harness_pages(3);
ok(&mut h, &c, "ui.set", json!({ "key": "tool", "value": "organize" }));
h.run_steps(3);
// The grid cell of a page: the node labelled exactly "Page N" (the checkbox says "Select page N").
let cell = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, page: usize| -> [f64; 4] {
let nodes = ok(h, c, "ui.inspect", json!({ "query": &format!("page {page}") }));
let want = format!("Page {page}");
let r = nodes["widgets"]
.as_array()
.unwrap()
.iter()
.find(|w| w["label"].as_str() == Some(want.as_str()))
.and_then(|w| w["rect"].as_array().cloned())
.expect("the page's grid cell");
let f = |i: usize| r[i].as_f64().unwrap();
[f(0), f(1), f(2), f(3)]
};
let (a, b) = (cell(&mut h, &c, 1), cell(&mut h, &c, 3));
// The gesture reads the OS the context reports: Option on macOS, Ctrl elsewhere.
let copy = if h.ctx.os() == egui::os::OperatingSystem::Mac { "alt" } else { "ctrl" };
ok(
&mut h,
&c,
"ui.drag",
json!({
"from": [(a[0] + a[2]) / 2.0, (a[1] + a[3]) / 2.0],
"to": [b[2] - 4.0, (b[1] + b[3]) / 2.0],
"modifiers": [copy],
}),
);
h.run_steps(3);
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["documents"][0]["pages"], 4, "the drag copied the page instead of moving it: {st}");
assert_eq!(st["active"]["selected_pages"], json!([4]), "{st}");
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 1"], "the copy landed at the drop gap, after page 3");
}
#[test]
fn organize_overview_is_reachable_through_the_control_channel() {
let (mut h, c) = harness();
+75
View File
@@ -2390,6 +2390,81 @@ fn dragging_thumbnails_reorders_pages() {
assert_eq!(sel, vec![2], "the moved page stays selected");
}
/// A page drag with the copy modifier held — Ctrl (Option on macOS) — instead of none.
/// Modifiers are input state: `ModifiersChanged` once, as the column-select drag does.
fn copy_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) {
page_drag(h, from, to, copy_modifiers(h));
}
/// The copy modifier of the OS the app reports: Option on macOS, Ctrl elsewhere. That is the OS
/// the gesture itself reads, so a Mac browser — a wasm build, where `cfg!(target_os = "macos")` is
/// false — is covered by asking the context rather than the target.
fn copy_modifiers(h: &Harness<'static, PdfCraftApp>) -> Modifiers {
if h.ctx.os() == egui::os::OperatingSystem::Mac { Modifiers::ALT } else { Modifiers::CTRL }
}
/// Drag the page under `from` to `to` with `modifiers` held for the whole gesture.
fn page_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.run_steps(1);
h.hover_at(from);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
for k in 1..=5 {
h.hover_at(from + (to - from) * (k as f32 / 5.0));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(4);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
#[test]
fn dragging_with_the_copy_modifier_copies_the_pages_into_the_gap() {
let mut h = organize(3);
let before = page_texts(h.state());
let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect();
let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
copy_drag(&mut h, from, to);
// The original stays first; a copy of page 1 lands after page 3 (#2997).
let after = page_texts(h.state());
assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the copy is selected");
}
/// macOS is not only `cfg!(target_os = "macos")`: the wasm build runs in a Mac browser, where the
/// cfg is false but the context says Mac. The gesture must follow the context, or Option-drag
/// there moves the pages instead of copying them (#3092 review).
#[test]
fn option_copies_and_ctrl_moves_when_the_context_reports_macos() {
let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect();
let points = |h: &Harness<'static, PdfCraftApp>| (grab(h, "Page 1").center(), grab(h, "Page 3").right_center() - egui::vec2(10.0, 0.0));
// Ctrl is not the copy modifier on macOS, so that drag moves the page.
let mut h = organize(3);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
let before = page_texts(h.state());
let (from, to) = points(&h);
page_drag(&mut h, from, to, Modifiers::CTRL);
assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone()], "Ctrl moves on macOS");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
// Option is, so the same drag drops a copy at the gap.
let mut h = organize(3);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
let before = page_texts(h.state());
let (from, to) = points(&h);
page_drag(&mut h, from, to, Modifiers::ALT);
assert_eq!(page_texts(h.state()), vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "Option copies on macOS");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page"));
let sel: Vec<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![3], "the copy is selected");
}
#[test]
fn copying_cutting_and_pasting_pages() {
let mut h = organize(4);
+56
View File
@@ -250,6 +250,62 @@ fn options_tab_sets_the_check_box_style() {
assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross));
}
#[test]
fn button_label_is_editable_without_renaming_the_field() {
use pdfcraft_engine::{Edit, NewField};
let mut h = harness();
// The byte comparisons below must not vary with the save's /ModDate.
let session = &mut h.state_mut().session;
*session = std::mem::take(session).with_clock(|| 1_700_000_000);
h.state_mut().apply_edit(Edit::AddField {
page: 0,
rect: [40.0, 300.0, 190.0, 330.0],
kind: NewField::Button { caption: String::new() },
name: Some("clear_button".into()),
});
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Clear Form");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let app = h.state();
let id = app.views[0].id;
let field = app.session.get(id).unwrap().form.iter().find(|f| f.name == "clear_button").unwrap();
let bytes = app.session.save_bytes(id).unwrap();
let doc = pdfcraft_cos::Document::open(bytes).unwrap();
let widget = doc.get(field.widgets[0].obj);
let mk = doc.resolve(widget.as_dict().unwrap().get(b"MK").unwrap());
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties"));
// Read the existing label back, and leave both Cancel and an unchanged OK harmless.
let before = app.session.save_bytes(id).unwrap();
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().caption, "Clear Form");
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Discard this");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
}
#[test]
fn dropdown_alignment_is_editable_and_preserved() {
choice_alignment(pdfcraft_engine::NewField::Combo { options: vec!["One".into(), "Two".into()], editable: false }, "Center", 1);
+20
View File
@@ -411,3 +411,23 @@ fn a_running_instances_autosaves_are_not_offered_to_a_second_instance() {
let fourth = harness(instance());
assert_eq!(fourth.state().recoverable.len(), 1, "a name outside the folder is not looked at, and the snapshot is offered");
}
/// Snapshots are unsaved copies of open documents, so on Unix only the owner may open the
/// folder: a new one is made that way, and one an earlier version left with the default mode is
/// tightened when an instance starts.
#[cfg(unix)]
#[test]
fn the_recovery_folder_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let mode = |s: &RecoveryStore| std::fs::metadata(s.dir()).unwrap().permissions().mode() & 0o777;
let fresh = store("owner-only");
crashed_session(&fresh, fixture(2), None, None);
assert_eq!(files_in(&fresh), 2);
assert_eq!(mode(&fresh), 0o700);
let earlier = store("owner-only-earlier");
std::fs::create_dir_all(earlier.dir()).unwrap();
std::fs::set_permissions(earlier.dir(), std::fs::Permissions::from_mode(0o755)).unwrap();
earlier.claim();
assert_eq!(mode(&earlier), 0o700);
}
+149 -2
View File
@@ -392,10 +392,78 @@ fn precise_touchpad_wheel_scrolls_one_to_one() {
assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}");
}
#[test]
fn rapid_wheel_notches_accelerate_and_follow_touchpad_input() {
use pdfcraft_ui_egui::scroll_speed::ScrollSpeed;
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]);
h.state_mut().set_option("animation_effects", "off").unwrap();
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(2);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let single = scroll_over(&mut h, 2);
let base = ScrollSpeed::default().line_points();
assert!((single - base).abs() < 1.0, "one notch keeps the configured base: moved {single}, base {base}");
// Let the isolated notch expire, then spin five notches one frame apart. The burst must move
// farther than five base steps without adding work on idle frames.
h.run_steps(30);
let mut burst = 0.0;
for _ in 0..5 {
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
burst += scroll_over(&mut h, 2);
}
assert!(burst > 7.0 * base, "a rapid five-notch spin accelerates: moved {burst}, base {base}");
// A line notch inside the precise path's suppression window keeps its base share (#1072):
// suppressing egui's touchpad tail must not swallow mixed-device input.
h.run_steps(30);
h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move));
scroll_over(&mut h, 2);
// Arrive near the old suppression deadline: the line's new easing tail must extend it.
h.run_steps(11);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let after_touchpad = scroll_over(&mut h, 2);
assert!(after_touchpad >= base, "a notch after point input is not dropped: moved {after_touchpad}, base {base}");
let delayed = scroll_over(&mut h, 20);
assert!(delayed.abs() < 2.0, "the reconstructed notch has no delayed easing copy: moved {delayed}");
h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move));
scroll_over(&mut h, 2);
h.event(wheel(MouseWheelUnit::Page, -1.0, TouchPhase::Move));
let page_after_touchpad = scroll_over(&mut h, 2);
assert!(page_after_touchpad > base, "a page event after point input is preserved: moved {page_after_touchpad}");
let delayed_page = scroll_over(&mut h, 20);
assert!(delayed_page.abs() < 2.0, "the reconstructed page has no delayed easing copy: moved {delayed_page}");
}
#[test]
fn changing_continuous_layout_resets_wheel_acceleration() {
use pdfcraft_ui_egui::scroll_speed::ScrollSpeed;
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]);
h.state_mut().set_option("animation_effects", "off").unwrap();
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(2);
for _ in 0..5 {
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
scroll_over(&mut h, 2);
}
h.state_mut().views[0].set_continuous(false);
h.run_steps(3);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let moved = scroll_over(&mut h, 2);
let base = ScrollSpeed::default().line_points();
assert!((moved - base).abs() < 1.0, "a layout jump clears the old burst: moved {moved}, base {base}");
}
/// Steps `frames` frames and returns how far the document scrolled down over them, from the
/// pages on screen in each pair of consecutive frames (a page can leave the screen on the way).
/// Only the non-macOS glide tests below use it, hence the matching gate.
#[cfg(not(target_os = "macos"))]
/// Used by wheel and non-macOS glide tests below.
fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 {
let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::<Vec<_>>();
let mut moved = 0.0;
@@ -1498,3 +1566,82 @@ fn two_up_scroll_end_reaches_an_unpaired_final_page() {
fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() {
assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true);
}
#[test]
fn switching_modes_keeps_the_current_page_at_fixed_zoom() {
use egui_kittest::kittest::Queryable;
let kids = (3..33).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count 30 /MediaBox [0 0 300 400] >> endobj\n"
);
for i in 3..33 {
pdf.push_str(&format!("{i} 0 obj << /Type /Page /Parent 2 0 R >> endobj\n"));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
let other = pdf.clone().into_bytes();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, pdf.into_bytes()).unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(8);
h.state_mut().views[0].go_to_page(17);
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17);
h.hover_at(h.state().views[0].viewport_rect().center());
h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move));
h.run_steps(8);
let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
h.get_by_label("Read").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page");
let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
assert!((before - after).abs() < 1.0, "the position within the page moved: {before} to {after}");
h.state_mut().views[0].go_to_page(5);
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5);
h.get_by_label("All tools").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5, "switching to All tools should keep the current page");
h.state_mut().open_bytes("other.pdf", None, other).unwrap();
h.state_mut().set_option("zoom", "100").unwrap();
h.run_steps(8);
h.state_mut().views[1].go_to_page(9);
h.run_steps(8);
assert_eq!(h.state().views[1].current, 9);
h.get_by_label("pages.pdf").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5, "the first tab keeps its own position");
h.get_by_label("other.pdf").click();
h.run_steps(8);
assert_eq!(h.state().views[1].current, 9, "the second tab keeps its own position");
}
#[test]
fn long_view_menu_fits_a_short_window() {
use egui_kittest::kittest::Queryable;
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap();
app
});
h.run_steps(8);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View ⏵").hover();
h.run_steps(4);
let before = h.get_by_label("Side panels ⏵").rect().bottom();
h.get_by_label("Fit to height ").hover();
h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move));
h.run_steps(6);
let after = h.get_by_label("Side panels ⏵").rect().bottom();
assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}");
h.get_by_label("Side panels ⏵").hover();
h.run_steps(4);
h.get_all_by_label("Bookmarks").last().unwrap().click();
h.run_steps(4);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
+2
View File
@@ -202,6 +202,8 @@ fn esc(s: &str) -> String {
'\n' => o.push_str("&#10;"),
'\r' => o.push_str("&#13;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
// XML 1.0 noncharacters are not allowed even as raw text.
'\u{FFFE}' | '\u{FFFF}' => {}
c => o.push(c),
}
}
+16
View File
@@ -168,6 +168,22 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() {
assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive");
}
#[test]
fn xml_exports_drop_xml_noncharacters() {
let mut src = filled();
set_value(&mut src, "Name", &FieldValue::Text("a\u{FFFE}b\u{FFFF}c".into())).unwrap();
let xfdf = export_xfdf(&src, false, true, "f.pdf");
let doc = roxmltree::Document::parse(&xfdf).unwrap_or_else(|e| panic!("XFDF is not well-formed: {e}: {xfdf:?}"));
let value = doc
.descendants()
.find(|n| n.has_tag_name("field") && n.attribute("name") == Some("Name"))
.and_then(|n| n.children().find(|c| c.has_tag_name("value")))
.and_then(|n| n.text());
assert_eq!(value, Some("abc"));
let data = export_data(&src, Format::Xml);
roxmltree::Document::parse(&data).unwrap_or_else(|e| panic!("XML is not well-formed: {e}: {data:?}"));
}
#[test]
fn xml_exports_preserve_whitespace_in_field_names() {
let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"];
+9 -4
View File
@@ -6,6 +6,7 @@
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -43,6 +44,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind
@@ -108,7 +110,7 @@ total matches the parity estimate.
| M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. (#2658). Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
@@ -121,6 +123,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
@@ -130,13 +133,13 @@ total matches the parity estimate.
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
@@ -151,7 +154,7 @@ total matches the parity estimate.
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
## Upcoming milestones, ranked
@@ -198,8 +201,10 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
+13 -13
View File
@@ -854,8 +854,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.continuous"]
tools = []
evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"]
notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/view.rs::changing_continuous_layout_resets_wheel_acceleration"]
notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
[[feature]]
id = "view.layout-single-page"
@@ -1094,8 +1094,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.read_mode"]
tools = []
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
notes = ""
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
[[feature]]
id = "view.full-screen"
@@ -2824,8 +2824,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.duplicate"]
tools = ["page_duplicate"]
evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"]
notes = "copies share fonts and images with the originals"
evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/ui-egui/tests/editing.rs::dragging_with_the_copy_modifier_copies_the_pages_into_the_gap", "crates/ui-egui/tests/control.rs::ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last", "crates/organize/src/tests.rs::duplicate_pages_at_inserts_the_copies_there", "crates/automation/tests/automation.rs::page_duplicate_at_inserts_the_copies_there"]
notes = "copies share fonts and images with the originals; Ctrl-drag (Option on macOS) in the page grid drops the copies at the gap instead of moving the pages, with a \"+\" badge and the copies selected (#2997); page_duplicate takes `at` for where the first copy goes (left out: after the last of them)"
[[feature]]
id = "organize.reverse-pages"
@@ -4920,8 +4920,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/ui-egui/tests/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen; icon-only layout and existing Helvetica limits retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
[[feature]]
id = "form.props-actions"
@@ -5704,8 +5704,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres"]
notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
[[feature]]
id = "protect.redact-vectors"
@@ -5912,8 +5912,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.digital", "sign.certify"]
tools = ["sign_windows_ids", "sign_document"]
evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"]
notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows:<fingerprint|common name> for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"]
notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows:<fingerprint|common name> for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open, or a key usage or extended key usage that doesn't allow signing documents, like a CAC's encryption and logon certificates) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
[[feature]]
id = "sign.encrypted-documents"
@@ -7776,7 +7776,7 @@ milestone = "M11"
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_verify"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
[[feature]]
+4 -7
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -17,10 +17,13 @@ pub(crate) fn decode(
// PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and
// the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank.
// PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a
// plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black
// (#3308). The decoder still stops where the data ends.
let rows = params
.get::<u32>(ROWS)
.filter(|&rows| rows != 0)
.unwrap_or(image_params.height);
.map_or(image_params.height, |rows| rows.max(image_params.height));
let end_of_block = params.get::<bool>(END_OF_BLOCK).unwrap_or(true);
let settings = DecodeSettings {
+172 -2
View File
@@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
target_resolution: params.target_dimension,
};
let image = hayro_jpeg2000::Image::new(data, &settings).ok()?;
// PdfCraft patch (21): a JP2 colour specification naming an enumerated colour space the
// decoder refuses (CIEJab, enum 19, in a veraPDF fixture; YCbCr, PhotoYCC, …) made the whole
// image fail, so the page drew blank (#3306). Then it is decoded as if it had no colour
// specification, as MuPDF and Poppler draw it: grey, RGB or CMYK by its component count.
let repaired;
let image = match hayro_jpeg2000::Image::new(data, &settings) {
Ok(image) => image,
Err(_) => {
repaired = without_refused_enumerated_colour(data)?;
hayro_jpeg2000::Image::new(&repaired, &settings).ok()?
}
};
let width = image.width();
let height = image.height();
@@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
})
}
/// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder
/// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB.
const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21];
/// PdfCraft patch (21): a copy of JP2 file `data` whose colour specification boxes (`colr`, in
/// the `jp2h` header box) that name an enumerated colour space outside [`DECODED_ENUMERATED`]
/// name the space the decoder assumes without one instead: greyscale, sRGB or CMYK for 1, 3 or
/// 4 components (`ihdr`). `None` when there is nothing to change or the boxes can't be read.
pub(crate) fn without_refused_enumerated_colour(data: &[u8]) -> Option<Vec<u8>> {
let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?;
let (_, start, end) = header;
let inner = data.get(start..end)?;
let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?;
// ihdr: height (4), width (4), number of components (2).
let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?);
let replacement: u32 = match components {
1 => 17,
3 => 16,
4 => 12,
_ => return None,
};
// Where each refused enum is, found before anything is copied: a stream the decoder refused
// for another reason isn't copied for nothing.
let mut refused = Vec::new();
for (kind, body, body_end) in boxes(inner) {
if kind != *b"colr" {
continue;
}
// colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1.
let colr = inner.get(body..body_end)?;
if colr.first() != Some(&1) {
continue;
}
let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?);
if DECODED_ENUMERATED.contains(&enumerated) {
continue;
}
refused.push(start.checked_add(body)?.checked_add(3)?);
}
if refused.is_empty() {
return None;
}
let mut out = data.to_vec();
for at in refused {
out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes());
}
Some(out)
}
/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents.
/// Stops at the first box whose length doesn't fit.
fn boxes(data: &[u8]) -> impl Iterator<Item = ([u8; 4], usize, usize)> + '_ {
let mut at = 0usize;
core::iter::from_fn(move || {
let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?);
let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?;
let (header, size) = match length {
// The rest of the data.
0 => (8, data.len().checked_sub(at)?),
// An 8-byte length follows the type.
1 => {
let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?);
(16, usize::try_from(large).ok()?)
}
n => (8, usize::try_from(n).ok()?),
};
let end = at.checked_add(size)?;
if size < header || end > data.len() {
return None;
}
let item = (kind, at + header, end);
at = end;
Some(item)
})
}
fn scale(
data: &[u8],
bit_per_component: u8,
@@ -134,7 +221,7 @@ fn scale(
#[cfg(test)]
mod tests {
use super::scale;
use super::{scale, without_refused_enumerated_colour};
use crate::bit_reader::{BitReader, bit_mask};
use alloc::vec::Vec;
@@ -188,4 +275,87 @@ mod tests {
}
}
}
/// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with
/// a codestream box after it.
fn jp2(components: u16, colrs: &[&[u8]]) -> Vec<u8> {
let jp2_box = |kind: &[u8], body: &[u8]| {
let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec();
b.extend_from_slice(kind);
b.extend_from_slice(body);
b
};
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]);
ihdr.extend_from_slice(&components.to_be_bytes());
ihdr.extend_from_slice(&[7, 7, 0, 0]);
let mut header = jp2_box(b"ihdr", &ihdr);
for c in colrs {
header.extend(jp2_box(b"colr", c));
}
let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 ");
out.extend(jp2_box(b"jp2h", &header));
out.extend(jp2_box(b"jp2c", &[0xff, 0x4f]));
out
}
fn enumerated(e: u32) -> Vec<u8> {
let mut c = alloc::vec![1, 0, 0];
c.extend_from_slice(&e.to_be_bytes());
c
}
/// The enumerated colour spaces found in the copy's `colr` boxes, in order.
fn enums_in(data: &[u8]) -> Vec<u32> {
data.windows(4)
.enumerate()
.filter(|(_, w)| *w == b"colr")
.filter(|(i, _)| data.get(i + 4) == Some(&1))
.map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap()))
.collect()
}
#[test]
fn refused_enumerated_colour_spaces_become_the_component_counts_default() {
// CIEJab (19) and other refused spaces, by component count.
for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] {
let data = jp2(components, &[&enumerated(refused)]);
let out = without_refused_enumerated_colour(&data).unwrap();
assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)");
assert_eq!(out.len(), data.len(), "only the enum changes");
}
// Several colour specifications: each refused one, and only those.
let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap();
assert_eq!(enums_in(&out), [16, 14, 16]);
// An 8-byte box length (LBox 1) is read too.
let mut long = jp2(3, &[&enumerated(19)]);
let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4;
let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8;
long.splice(at..at + 4, [0, 0, 0, 1]);
long.splice(at + 8..at + 8, (len).to_be_bytes());
assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]);
}
#[test]
fn what_the_decoder_handles_or_cant_be_read_is_left_alone() {
for known in [12, 14, 16, 17, 18, 20, 21] {
assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}");
}
// An ICC profile (METH 2), a component count with no default, no header at all.
// (Its bytes where an enum would be read say 19: only METH 1 is an enum.)
assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None);
assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None);
assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None);
// Damaged lengths: none of these may panic, and none is changed.
let good = jp2(3, &[&enumerated(19)]);
for cut in 0..good.len() {
let _ = without_refused_enumerated_colour(&good[..cut]);
}
for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] {
let mut data = good.clone();
let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4;
data[at..at + 4].copy_from_slice(&bad.to_be_bytes());
assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}");
}
}
}
+64 -14
View File
@@ -534,23 +534,27 @@ pub(crate) mod flate {
/// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how
/// many are still to copy. A copy whose source does not exist writes nothing.
fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize {
while left > 0 && self.output.len() < self.limit {
if valid {
let byte = self
.output
.len()
.checked_sub(distance)
.and_then(|index| self.output.get(index))
.copied();
if let Some(byte) = byte {
self.output.push(byte);
}
fn copy_back(&mut self, valid: bool, distance: usize, left: usize) -> usize {
let len = self.output.len();
// Without a source the copy writes nothing, and it runs out only at the limit.
let Some(mut start) = len.checked_sub(distance).filter(|_| valid && distance > 0) else {
return if len < self.limit { 0 } else { left };
};
// PdfCraft patch: copy in runs of up to `distance` bytes. Each run reads only bytes
// already in the output, so the bytes are the ones a byte-at-a-time copy writes.
let copy = left.min(self.limit.saturating_sub(len));
let mut done = 0;
while done < copy {
let end = start.saturating_add((copy - done).min(distance));
if end > self.output.len() {
break;
}
left -= 1;
self.output.extend_from_within(start..end);
done += end - start;
start = end;
}
left
left - done
}
fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> {
@@ -1292,6 +1296,52 @@ mod tests {
);
}
/// PdfCraft patch: 300 zero bytes, zlib-compressed: one literal, then copies of distance 1 that
/// are longer than their distance.
const ZEROS_300: [u8; 13] = [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0xc4, 0x02, 0x00, 0x01, 0x2c, 0x00, 0x01];
#[test]
fn back_reference_longer_than_its_distance_repeats_its_source() {
let mut state = flate::FlateState::new(&ZEROS_300);
let mut out = Vec::new();
state.decode_into(&ZEROS_300, &mut out, 1000);
assert_eq!(out, vec![0; 300]);
}
#[test]
fn back_reference_cut_by_the_limit_resumes_in_the_next_call() {
let mut state = flate::FlateState::new(&ZEROS_300);
let mut out = Vec::new();
state.decode_into(&ZEROS_300, &mut out, 100);
assert_eq!(out, vec![0; 100]);
state.decode_into(&ZEROS_300, &mut out, 1000);
assert_eq!(out, vec![0; 300]);
}
#[test]
fn back_reference_reads_bytes_written_by_an_earlier_call() {
// "abc" repeated 2000 times, zlib-compressed: copies reach 3 bytes back, and the calls below
// stop every 7 bytes, so most copies start in an earlier call's output.
const ABC_2000: [u8; 32] = [
0x78, 0xda, 0xed, 0xc2, 0x41, 0x11, 0x00, 0x00, 0x0c, 0x02, 0xa0, 0xac, 0x6a, 0xff, 0x0e, 0x0b,
0xb1, 0x2f, 0x1c, 0xe9, 0xa2, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xfe, 0x1e, 0x2f, 0x01, 0xf9, 0x59,
];
let expected = b"abc".repeat(2000);
let mut state = flate::FlateState::new(&ABC_2000);
let mut out = Vec::new();
loop {
let before = out.len();
state.decode_into(&ABC_2000, &mut out, before + 7);
if out.len() == before || out.len() >= expected.len() {
break;
}
}
assert_eq!(out, expected);
}
#[test]
fn predictor_paeth() {
predictor_test(
+15 -8
View File
@@ -353,14 +353,25 @@ impl FilterResult<'_> {
}
}
/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services
/// writes `stream \r\n`, a space before the line break, which the specification does not allow.
/// Without this the content stream was taken to start at the space, failed to inflate, and the
/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and
/// tabs are skipped, and only when a line break follows, so stream data is never swallowed.
fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> {
r.forward_while(|b| b == b' ' || b == b'\t');
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))
}
fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>> {
let length = dict.get::<u32>(LENGTH)?;
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let data = r.read_bytes(length as usize)?;
r.skip_white_spaces();
r.forward_tag(b"endstream")?;
@@ -376,11 +387,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>>
// A wrong `/Length` still recovers, because that keyword sits directly after the dict.
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let tail = r.tail()?;
let endstream_offset = find_needle(tail, b"endstream")?;
+1 -1
View File
@@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode};
mod renderer;
/// PdfCraft patch: exported for its regression test.
pub use renderer::{image_resampling_size, tiling_cell_scale};
pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale};
/// PdfCraft patch: exported for its regression test.
#[doc(hidden)]
pub use renderer::set_skip_offscreen_paths;
+100 -8
View File
@@ -526,14 +526,7 @@ impl Renderer {
},
};
// A miter limit that isn't finite leaves miters unbounded: never skipped.
let miter = stroke.miter_limit.abs();
let reach = stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
};
let reach = stroke_reach(&stroke);
self.ctx.set_stroke(stroke);
reach
}
@@ -1142,6 +1135,65 @@ impl Renderer {
};
xs = xs.max(min_x_scale).min(max_x_scale);
ys = ys.max(min_y_scale).min(max_y_scale);
// PdfCraft patch (20): a step too large for a cell pixmap, where what is
// painted can only show the first cell (PowerPoint writes `/XStep
// 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is
// drawn at full scale with a transparent border and padded, not tiled.
// Shrinking the cell to fit the step left a picture under one pixel.
// How far a stroke's paint reaches past the path, miters included, in
// device pixels: the most the transform stretches any direction (a skew
// stretches some further than either axis), times the reach.
let reach = if is_stroke {
path_transform.spectral_norm() * stroke_reach(self.ctx.stroke())
} else {
0.0
};
if usable_cell_scale(xs, ys)
&& (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE)
&& shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step)
{
const BORDER: f64 = 1.0;
let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2);
let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2);
let mut renderer = Self {
ctx: RenderContext::new_with(
pix_width,
pix_height,
derive_settings(self.ctx.render_settings()),
),
color_replacement: None,
in_annotations: false,
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
in_type3_glyph: false,
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
};
let initial_transform = Affine::translate((BORDER, BORDER))
* Affine::scale_non_uniform(xs as f64, ys as f64)
* Affine::translate((-bbox.x0, -bbox.y0));
t.interpret(&mut renderer, initial_transform, is_stroke);
let mut pix = Pixmap::new(pix_width, pix_height);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse();
let image = Image {
image: ImageSource::Pixmap(Arc::new(pix)),
sampler: ImageSampler {
x_extend: peniko::Extend::Pad,
y_extend: peniko::Extend::Pad,
quality: ImageQuality::Medium,
alpha: 1.0,
},
};
self.ctx.set_paint_transform(paint_transform);
self.ctx.set_paint(PaintType::Image(image));
return clip_path;
}
// PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the
// u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells).
xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE);
@@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
peniko::BlendMode::new(mix, Compose::SrcOver)
}
/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width
/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that
/// isn't finite leaves miters unbounded, so the reach is too.
fn stroke_reach(stroke: &kurbo::Stroke) -> f64 {
let miter = stroke.miter_limit.abs();
stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
}
}
/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A
/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale,
/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path.
pub fn usable_cell_scale(xs: f32, ys: f32) -> bool {
xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0
}
/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach`
/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can
/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more
/// away. False when the matrix can't be inverted or the bounds aren't finite.
pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool {
if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() {
return false;
}
let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach);
let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box();
let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs()));
let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite());
// Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other.
finite
&& bounds.x1 < bbox.x0 + sx
&& bounds.x0 > bbox.x1 - sx
&& bounds.y1 < bbox.y0 + sy
&& bounds.y0 > bbox.y1 - sy
}
/// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| ×
/// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform
/// is derived from the scale); an over-large step only renders at lower resolution.