From 32a9af4d08327f3aaa557307c537b886e602d975 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 16:01:29 -0400 Subject: [PATCH 001/175] proposal(redact): unlink unused Type 3 glyph programs and fonts on redaction (F-011) --- crates/redact/src/interp.rs | 7 +- crates/redact/src/lib.rs | 3 + crates/redact/src/sanitize.rs | 7 +- .../redact/src/tests/surviving_references.rs | 193 +++++ crates/redact/src/type3.rs | 692 ++++++++++++++++ crates/redact/src/type3_tables.rs | 775 ++++++++++++++++++ 6 files changed, 1675 insertions(+), 2 deletions(-) create mode 100644 crates/redact/src/type3.rs create mode 100644 crates/redact/src/type3_tables.rs diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index bd9da5215f..cef566042a 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -477,6 +477,7 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// left as it is for them. Retired properties are likewise unlinked only when none of the /// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + crate::type3::unlink_type3(doc)?; if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } @@ -677,7 +678,7 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option]) -> Result, CosError> { +pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); @@ -1359,6 +1360,10 @@ fn xobject( out.error = Some(error); return None; } + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } } if cleaned_res != res { dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index bdf1db873e..6f69151d89 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -27,6 +27,8 @@ pub mod sanitize; mod tags; #[cfg(test)] mod tests; +pub(crate) mod type3; +pub(crate) mod type3_tables; use interp::{Mode, Scope, process}; @@ -493,6 +495,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -443,7 +444,11 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + // Only a removal rewrites the document; a count on a clone has nothing to unlink, and a + // cleanup error there would be mistaken for "nothing to remove". + if write { + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + } Ok((total, retained_off_layers)) } diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index a297ded2a4..477f03f9a5 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -105,3 +105,196 @@ fn an_image_another_page_draws_through_a_form_without_resources_is_kept() { assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); assert!(full_save_holds(&doc, b"IMAGESECRET"), "the second page still draws the image through its form"); } + +/// A Type 3 font whose displayed glyphs are redacted has its unused glyph programs unlinked from +/// /CharProcs and exclusive graphical resources pruned, so they do not survive in a full save (F-011). +#[test] +fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + // Text showing AB with Type 3 font /T3 + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Type 3 font with /CharProcs << /A 7 0 R /B 8 0 R >> and /Resources with images for both + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_A_PAYLOAD"), + stream("", b"/ImB Do % SURVIVING_GLYPH_B_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + + // Redaction mark covers only 'A' (x: 10..15, y: 95..110) + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + + assert!(full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD")); + assert!(full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A")); + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD")); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B")); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // After redaction, unreferenced glyph A and its exclusive image A must not survive full save + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD"), "redacted glyph A program survived"); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "exclusive image for redacted glyph A survived"); + + // Surviving glyph B and its image B must survive + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD"), "surviving glyph B was lost"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "surviving glyph B image was lost"); + + // Reopen and inspect the Type 3 font dictionary + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A"), "CharProcs still contains unreferenced glyph A"); + assert!(procs.contains(b"B"), "CharProcs lost surviving glyph B"); + + let t3_res = saved.resolve(t3.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let t3_xo = saved.resolve(t3_res.get(b"XObject").unwrap()).as_dict().unwrap().clone(); + assert!(!t3_xo.contains(b"ImA"), "Type 3 font resources still contains exclusive image ImA"); + assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); +} + +/// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). +#[test] +fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> /Resources << /XObject << /ImA 8 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_ONLY_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_ONLY"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_ONLY_PAYLOAD")); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_ONLY")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + if let Some(res_val) = page.dict.get(b"Resources") + && let Some(res) = saved.resolve(res_val).as_dict() + && let Some(fonts_val) = res.get(b"Font") + { + let fonts = saved.resolve(fonts_val).as_dict().unwrap().clone(); + assert!(!fonts.contains(b"T3"), "Font /T3 was not removed from Resources"); + } +} + +/// In shared Type 3 fonts across multiple pages, glyphs used on another page are preserved (F-011). +#[test] +fn shared_type3_font_preserves_glyphs_used_by_another_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + // Page 1: draws 'A' (redacted) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), + // Page 2: draws 'B' (survives) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + stream("", b"BT /T3 10 Tf 10 100 Td (B) Tj ET"), + // Shared Resources 7 0 R + b"<< /Font << /T3 8 0 R >> >>".to_vec(), + // Shared Type 3 font 8 0 R + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 9 0 R /B 10 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAGE1_GLYPH_A"), + stream("", b"% PUBLIC_PAGE2_GLYPH_B"), + ]); + + // Redact only Page 1's 'A' + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // Glyph A was removed from Page 1 and used nowhere else -> unlinked! + assert!(!full_save_holds(&doc, b"SECRET_PAGE1_GLYPH_A")); + + // Glyph B is still used on Page 2 -> kept! + assert!(full_save_holds(&doc, b"PUBLIC_PAGE2_GLYPH_B")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let pages = pdfcraft_model::pages(&saved); + let p2_res = saved.resolve(pages[1].dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(p2_res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A")); + assert!(procs.contains(b"B")); +} + +/// Fails closed when Type 3 font cleanup cannot be safely established (F-011). +#[test] +fn unparseable_type3_font_encoding_fails_closed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Unsupported encoding /UnknownCustomEncoding without Differences + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding /UnknownCustomEncoding /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAYLOAD_UNPARSEABLE"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let res = apply(&mut doc, None); + assert!(res.is_err(), "redaction must fail closed when Type 3 cleanup cannot be established: {res:?}"); +} + +/// A Type 3 font selected through an ExtGState /Font entry is a use of the font for pruning: +/// its redacted glyph program does not survive a full save (F-011). +#[test] +fn type3_font_set_through_extgstate_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/GS1 gs BT 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /ExtGState << /GS1 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_EXTGSTATE_GLYPH_PAYLOAD"), + b"<< /Type /ExtGState /Font [6 0 R 10] >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_EXTGSTATE_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} + +/// A Type 3 font used only inside a Form XObject is found by the scan and its redacted glyph +/// program does not survive a full save (F-011). +#[test] +fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/Fm Do"), + b"<< /XObject << /Fm 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_FORM_GLYPH_PAYLOAD"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 9 0 R", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs new file mode 100644 index 0000000000..f431dab902 --- /dev/null +++ b/crates/redact/src/type3.rs @@ -0,0 +1,692 @@ +//! Unlink unused Type 3 font glyph programs and prune exclusive graphical resources (F-011). + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_content::Pieces; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::{RedactError, page_streams}; + +const MAX_DEPTH: usize = 64; +const MAX_HOLDERS: usize = 1_000_000; + +fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict { + res.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()).unwrap_or_default() +} + +/// Identifies a font in the document. Only fonts that are indirect objects are cleaned: a Type 3 +/// font held directly in a resource dictionary fails closed (see [`unlink_type3`]), because its +/// glyph programs could not be told apart or rewritten reliably. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum FontKey { + Ref(ObjRef), +} + +/// Build the mapping from character code (0..=255) to glyph name in a Type 3 font. +/// Fails closed if the encoding cannot be safely resolved or parsed. +pub(crate) fn type3_code_to_glyph(doc: &Document, font_dict: &Dict) -> Result<[Option>; 256], CosError> { + let Some(enc_obj) = font_dict.get(b"Encoding") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing Encoding".into() }); + }; + let resolved_enc = doc.resolve(enc_obj); + + let mut differences = HashMap::new(); + let base_name: Option>; + + match &*resolved_enc { + Object::Dict(enc_dict) => { + if let Some(diffs) = enc_dict.get(b"Differences").and_then(|d| doc.resolve(d).as_array().cloned()) { + let mut cur_code = 0i64; + for item in &diffs { + match &*doc.resolve(item) { + Object::Int(n) => cur_code = *n, + Object::Name(name) => { + if let Ok(c) = u8::try_from(cur_code) { + differences.insert(c, name.clone()); + } + cur_code = cur_code.saturating_add(1); + } + _ => {} + } + } + } + if let Some(base) = enc_dict.name(b"BaseEncoding") { + base_name = Some(base.to_vec()); + } else { + // ISO 32000 §9.6.6.1: default base encoding for Type 3 fonts is StandardEncoding. + base_name = Some(b"StandardEncoding".to_vec()); + } + } + Object::Name(name) => { + base_name = Some(name.clone()); + } + _ => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: invalid Encoding object".into(), + }); + } + } + + let base_table: Option<&'static [Option<&'static [u8]>; 256]> = match base_name.as_deref() { + Some(b"StandardEncoding") => Some(&crate::type3_tables::STANDARD), + Some(b"WinAnsiEncoding") => Some(&crate::type3_tables::WIN_ANSI), + Some(b"MacRomanEncoding") => Some(&crate::type3_tables::MAC_ROMAN), + Some(b"MacExpertEncoding") => Some(&crate::type3_tables::MAC_EXPERT), + Some(other) => { + return Err(CosError::Syntax { + offset: 0, + detail: format!( + "redaction could not establish safe Type 3 font cleanup: unsupported BaseEncoding: {}", + String::from_utf8_lossy(other) + ), + }); + } + None => None, + }; + + let mut map: [Option>; 256] = std::array::from_fn(|_| None); + for (code, entry) in map.iter_mut().enumerate() { + let c = code as u8; + if let Some(diff_glyph) = differences.get(&c) { + *entry = Some(diff_glyph.clone()); + } else if let Some(table) = base_table + && let Some(base_glyph) = table[code] + { + *entry = Some(base_glyph.to_vec()); + } + } + + Ok(map) +} + +fn resolve_font_in_res(doc: &Document, res: &Dict, font_name: &[u8]) -> Option<(FontKey, Dict)> { + let fonts = res_dict(doc, res, b"Font"); + let font_obj = fonts.get(font_name)?; + // A direct Type 3 font is refused during discovery, so none reaches the scan. + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +fn resolve_extgstate_font(doc: &Document, res: &Dict, gs_name: &[u8]) -> Option<(FontKey, Dict)> { + let extg = res_dict(doc, res, b"ExtGState"); + let state = doc.resolve(extg.get(gs_name)?).as_dict().cloned()?; + let font_entry = state.get(b"Font").and_then(|f| doc.resolve(f).as_array().cloned())?; + let font_obj = font_entry.first()?; + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +#[derive(Default)] +struct UsedInGlyphs { + xobjects: HashSet>, + colorspaces: HashSet>, + patterns: HashSet>, + shadings: HashSet>, + extgstates: HashSet>, + fonts: HashSet>, + properties: HashSet>, +} + +fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { + let parsed = pdfcraft_content::parse(data); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); + } + for op in parsed.ops { + match op.op.as_slice() { + b"Do" => { + if let Some(n) = op.last_name() { + used.xobjects.insert(n.to_vec()); + } + } + b"cs" | b"CS" => { + if let Some(n) = op.name(0) { + used.colorspaces.insert(n.to_vec()); + } + } + b"scn" | b"SCN" => { + if let Some(n) = op.last_name() { + used.patterns.insert(n.to_vec()); + } + } + b"sh" => { + if let Some(n) = op.last_name() { + used.shadings.insert(n.to_vec()); + } + } + b"gs" => { + if let Some(n) = op.name(0) { + used.extgstates.insert(n.to_vec()); + } + } + b"Tf" => { + if let Some(n) = op.name(0) { + used.fonts.insert(n.to_vec()); + } + } + b"BDC" | b"DP" => { + if let Some(n) = op.name(1) { + used.properties.insert(n.to_vec()); + } + } + _ => {} + } + } + Ok(()) +} + +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let mut changed = false; + + macro_rules! prune_category { + ($cat:expr, $used_set:expr) => { + if let Some(sub_obj) = font_res.get($cat) { + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); + let mut cat_changed = false; + for n in names { + if !$used_set.contains(&n) { + sub.remove(&n); + cat_changed = true; + } + } + if cat_changed { + changed = true; + if sub.is_empty() { + font_res.remove($cat); + } else { + font_res.set($cat.to_vec(), Object::Dict(sub)); + } + } + } + }; + } + + prune_category!(b"XObject", used.xobjects); + prune_category!(b"ColorSpace", used.colorspaces); + prune_category!(b"Pattern", used.patterns); + prune_category!(b"Shading", used.shadings); + prune_category!(b"ExtGState", used.extgstates); + prune_category!(b"Font", used.fonts); + prune_category!(b"Properties", used.properties); + + changed +} + +struct ScanContext<'a> { + doc: &'a Document, + font_maps: &'a HashMap>; 256]>, + used_glyphs: &'a mut HashMap>>, + /// A form scanned with its inherited font and the resources it draws through: the same form + /// drawn in another context is scanned again. + seen_forms: &'a mut HashSet<(ObjRef, Option, ObjRef)>, +} + +fn scan_content_stream( + ctx: &mut ScanContext<'_>, + content: &[Vec], + holder_ref: ObjRef, + res: &Dict, + initial_font: Option, + depth: usize, +) -> Result<(), CosError> { + if depth > MAX_DEPTH { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); + } + let joined = Pieces::join(content); + let parsed = joined.parse(); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } + + let mut stack: Vec> = Vec::new(); + let mut current_font = initial_font; + + for op in parsed.ops { + match op.op.as_slice() { + b"q" => stack.push(current_font), + b"Q" => { + if let Some(saved) = stack.pop() { + current_font = saved; + } + } + b"Do" => { + if let Some(name) = op.last_name() { + scan_xobject(ctx, res, holder_ref, name, current_font, depth)?; + } + } + _ => apply_text_op(ctx, res, &op, &mut current_font), + } + } + Ok(()) +} + +/// Apply an operator that changes or uses the current font: `Tf`, `gs`, and the text-showing +/// operators, which record the glyphs they draw. +fn apply_text_op(ctx: &mut ScanContext<'_>, res: &Dict, op: &pdfcraft_content::Op, current_font: &mut Option) { + match op.op.as_slice() { + b"Tf" => { + if let Some(name) = op.name(0) { + *current_font = resolve_font_in_res(ctx.doc, res, name).map(|(k, _)| k); + } + } + b"gs" => { + if let Some(name) = op.name(0) + && let Some((k, _)) = resolve_extgstate_font(ctx.doc, res, name) + { + *current_font = Some(k); + } + } + b"Tj" | b"'" | b"\"" => { + if let Some(s) = op.operands.last().and_then(Object::as_string) { + mark_shown(ctx, *current_font, &s.bytes); + } + } + b"TJ" => { + if let Some(arr) = op.operands.last().and_then(Object::as_array) { + for item in arr { + if let Some(s) = item.as_string() { + mark_shown(ctx, *current_font, &s.bytes); + } + } + } + } + _ => {} + } +} + +/// Record every glyph of `font` that the shown string `bytes` uses. +fn mark_shown(ctx: &mut ScanContext<'_>, font: Option, bytes: &[u8]) { + let Some(fk) = font else { return }; + let Some(map) = ctx.font_maps.get(&fk) else { return }; + for b in bytes { + if let Some(g) = &map[*b as usize] { + ctx.used_glyphs.entry(fk).or_default().insert(g.clone()); + } + } +} + +/// Scan the Form XObject `name` drawn by a `Do` operator, once per (form, font, drawing holder). +fn scan_xobject( + ctx: &mut ScanContext<'_>, + res: &Dict, + holder_ref: ObjRef, + name: &[u8], + current_font: Option, + depth: usize, +) -> Result<(), CosError> { + let xo = res_dict(ctx.doc, res, b"XObject"); + let Some(r) = xo.get(name).and_then(Object::as_ref) else { return Ok(()) }; + let obj = ctx.doc.get(r); + let Object::Stream(s) = &*obj else { return Ok(()) }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return Ok(()); + } + let form_own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()); + // A form with resources of its own draws through them; one without draws through its caller's. + let through = if form_own_res.is_some() { r } else { holder_ref }; + if !ctx.seen_forms.insert((r, current_font, through)) { + return Ok(()); + } + let data = s.decoded()?; + match form_own_res { + Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1), + None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), + } +} + +/// The font object an ExtGState's `/Font` entry names (its first element), if any. +fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { + let state = doc.resolve(state_obj).as_dict()?.clone(); + let font_entry = doc.resolve(state.get(b"Font")?).as_array()?.clone(); + font_entry.into_iter().next() +} + +/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten +/// content no longer draws any text using that font name. +pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let mut fonts = res_dict(doc, res, b"Font"); + if fonts.is_empty() { + return Ok(()); + } + + // The operators of forms that draw through these resources count too: dropping a font a child + // form still shows would leave its text without one. + let ops = crate::interp::resource_ops(doc, res, content)?; + + let mut drawn_font_names = HashSet::new(); + for op in &ops { + if op.is("Tf") { + if let Some(name) = op.name(0) { + drawn_font_names.insert(name.to_vec()); + } + } else if op.is("gs") + && let Some(name) = op.name(0) + { + let extg = res_dict(doc, res, b"ExtGState"); + if let Some(font_obj) = extg.get(name).and_then(|state_obj| gs_font_object(doc, state_obj)) { + drawn_font_names.extend( + fonts + .iter() + .filter(|(_, fn_obj)| fn_obj.as_ref().is_some() && fn_obj.as_ref() == font_obj.as_ref()) + .map(|(fn_name, _)| fn_name.to_vec()), + ); + } + } + } + + let mut changed = false; + let font_names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for name in font_names { + if let Some(font_obj) = fonts.get(&name) { + let resolved = doc.resolve(font_obj); + if let Some(d) = resolved.as_dict() + && d.name(b"Subtype") == Some(b"Type3") + && !drawn_font_names.contains(&name) + { + fonts.remove(&name); + changed = true; + } + } + } + + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + + Ok(()) +} + +/// Unlink unused Type 3 glyph programs from `CharProcs`, prune their exclusive resources from the +/// font's `/Resources`, and remove completely unused Type 3 fonts across all reachable resource +/// holders in the document. Fails closed if safe cleanup cannot be established. +pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { + return Ok(()); + }; + + // Find all page-tree nodes with /Resources of their own, and the pages that draw through them. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > MAX_HOLDERS { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + + let form_holders = reachable_form_holders(doc, &holders)?; + + // Discover all Type 3 fonts in holders and forms. + let mut type3_fonts: HashMap = HashMap::new(); + for (node, _) in &holders { + if let Some(res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) { + collect_type3_fonts(doc, &res, &mut type3_fonts)?; + } + } + for (_, form_res) in &form_holders { + collect_type3_fonts(doc, form_res, &mut type3_fonts)?; + } + + if type3_fonts.is_empty() { + return Ok(()); + } + + // Build code-to-glyph mappings for each Type 3 font. Fails closed on error. + let mut font_maps: HashMap>; 256]> = HashMap::new(); + for (key, font_dict) in &type3_fonts { + let map = type3_code_to_glyph(doc, font_dict)?; + font_maps.insert(*key, map); + } + + // Scan all page contents and reachable forms to discover surviving glyph usage. + let page_list = pdfcraft_model::pages(doc); + let mut used_glyphs: HashMap>> = HashMap::new(); + let mut seen_drawn_forms = HashSet::new(); + + let mut ctx = ScanContext { doc, font_maps: &font_maps, used_glyphs: &mut used_glyphs, seen_forms: &mut seen_drawn_forms }; + + for (pi, p) in page_list.iter().enumerate() { + let (_, content) = page_streams(ctx.doc, &p.dict, pi)?; + let page_res = p + .dict + .get(b"Resources") + .and_then(|r| ctx.doc.resolve(r).as_dict().cloned()) + .or_else(|| { + holders.iter().find(|(_, users)| users.contains(&p.obj)).and_then(|(h, _)| { + ctx.doc.get(*h).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()) + }) + }) + .unwrap_or_default(); + + scan_content_stream(&mut ctx, &content, p.obj, &page_res, None, 0)?; + } + + // Also scan any surviving form that wasn't reached by page streams directly. + for (form_ref, form_res) in &form_holders { + if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { + let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; + let data = s.decoded()?; + scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; + } + } + + // For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources. + for (font_key, font_dict) in &type3_fonts { + let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default(); + clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?; + } + + Ok(()) +} + +/// Every reachable Form XObject with resources of its own, with those resources, found by +/// walking the resources of the holders and of the forms they draw. +fn reachable_form_holders(doc: &Document, holders: &[(ObjRef, Vec)]) -> Result, CosError> { + let mut form_holders: Vec<(ObjRef, Dict)> = Vec::new(); + let mut pending_res: Vec = holders + .iter() + .filter_map(|(r, _)| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| doc.resolve(ro).as_dict().cloned())) + .collect(); + let mut seen_forms = HashSet::new(); + while let Some(res) = pending_res.pop() { + for (_, value) in res_dict(doc, &res, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen_forms.insert(reference) { + continue; + } + if seen_forms.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + if let Some(r) = form.dict.get(b"Resources").and_then(|ro| doc.resolve(ro).as_dict().cloned()) { + form_holders.push((reference, r.clone())); + pending_res.push(r); + } + } + } + Ok(form_holders) +} + +/// Record every Type 3 font named by a resource dictionary's `/Font` and `/ExtGState` entries. +/// A Type 3 font held directly in a resource dictionary can neither be named reliably nor have its +/// glyph programs rewritten, so redaction fails closed rather than leaving its glyphs unchecked. +fn collect_type3_fonts(doc: &Document, res: &Dict, t3_fonts: &mut HashMap) -> Result<(), CosError> { + let direct = || CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a Type 3 font is not an indirect object".into(), + }; + let mut consider = |font_obj: &Object| -> Result<(), CosError> { + if let Some(dict) = doc.resolve(font_obj).as_dict().cloned() + && dict.name(b"Subtype") == Some(b"Type3") + { + let font_ref = font_obj.as_ref().ok_or_else(direct)?; + t3_fonts.entry(FontKey::Ref(font_ref)).or_insert(dict); + } + Ok(()) + }; + for (_, font_obj) in res_dict(doc, res, b"Font").iter() { + consider(font_obj)?; + } + for (_, state_obj) in res_dict(doc, res, b"ExtGState").iter() { + if let Some(font_obj) = gs_font_object(doc, state_obj) { + consider(&font_obj)?; + } + } + Ok(()) +} + +/// Unlink the glyph programs of one Type 3 font that no longer draw, then either remove the font +/// (when no glyph survives) or prune the resources only its surviving glyphs used. +fn clean_type3_font( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + font_key: &FontKey, + font_dict: &Dict, + surviving: &HashSet>, +) -> Result<(), RedactError> { + let Some(char_procs_val) = font_dict.get(b"CharProcs") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into()); + }; + let mut char_procs = doc.resolve(char_procs_val).as_dict().cloned().ok_or_else(|| CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProcs is not a dict".into(), + })?; + + let mut procs_changed = false; + let glyph_names: Vec> = char_procs.iter().map(|(n, _)| n.to_vec()).collect(); + for g in glyph_names { + if !surviving.contains(&g) { + char_procs.remove(&g); + procs_changed = true; + } + } + + let FontKey::Ref(fr) = font_key; + if char_procs.is_empty() { + // The font has no surviving glyphs: clear its own glyph programs and resources, then + // remove font from /Font across all holders. + doc.update_dict(*fr, |d| { + d.remove(b"CharProcs"); + d.remove(b"Resources"); + })?; + return remove_font_from_holders(doc, holders, form_holders, font_key); + } + + // A CharProcs object can be shared with another Type 3 font, so never rewrite it in place: + // give this font its own filtered dictionary instead. + if procs_changed { + doc.update_dict(*fr, |d| { + d.set(b"CharProcs".to_vec(), Object::Dict(char_procs.clone())); + })?; + } + + // Clean exclusive resources from the Type 3 font's /Resources. + let Some(f_res_val) = font_dict.get(b"Resources") else { return Ok(()) }; + let mut f_res = doc.resolve(f_res_val).as_dict().cloned().unwrap_or_default(); + + let mut used_in_glyphs = UsedInGlyphs::default(); + for (_, proc_val) in char_procs.iter() { + let resolved = doc.resolve(proc_val); + let Object::Stream(s) = &*resolved else { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProc is not a stream".into(), + } + .into()); + }; + let data = s.decoded()?; + collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; + } + + if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) { + // The pruned dictionary goes into the font object: a font's /Resources object is + // often the page's own, and rewriting that would strip the page of its resources. + doc.update_dict(*fr, |d| { + if f_res.is_empty() { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Dict(f_res.clone())); + } + })?; + } + Ok(()) +} + +fn remove_font_from_holders( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + target_key: &FontKey, +) -> Result<(), RedactError> { + let clean_res = |doc: &Document, res: &mut Dict| -> bool { + let mut fonts = res_dict(doc, res, b"Font"); + let mut changed = false; + let names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for n in names { + if let Some(val) = fonts.get(&n) + && val.as_ref().map(FontKey::Ref) == Some(*target_key) + { + fonts.remove(&n); + changed = true; + } + } + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + changed + }; + + for (node, _) in holders { + if let Some(mut res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) + && clean_res(doc, &mut res) + { + doc.update_dict(*node, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + for (form_ref, _) in form_holders { + let obj = doc.get(*form_ref); + if let Object::Stream(s) = &*obj + && let Some(mut res) = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) + && clean_res(doc, &mut res) + { + doc.update_dict(*form_ref, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + Ok(()) +} diff --git a/crates/redact/src/type3_tables.rs b/crates/redact/src/type3_tables.rs new file mode 100644 index 0000000000..62128772de --- /dev/null +++ b/crates/redact/src/type3_tables.rs @@ -0,0 +1,775 @@ +//! Predefined font encodings (ISO 32000 Annex D) mapping character codes to glyph names. + +//! Each encoding lists only the codes it maps; every other code is unmapped. + +type Entries = &'static [(u8, &'static [u8])]; + +const fn build(entries: Entries) -> [Option<&'static [u8]>; 256] { + let mut out: [Option<&'static [u8]>; 256] = [None; 256]; + let mut i = 0; + while i < entries.len() { + out[entries[i].0 as usize] = Some(entries[i].1); + i += 1; + } + out +} + +const STANDARD_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quoteright"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"quoteleft"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"fraction"), + (165, b"yen"), + (166, b"florin"), + (167, b"section"), + (168, b"currency"), + (169, b"quotesingle"), + (170, b"quotedblleft"), + (171, b"guillemotleft"), + (172, b"guilsinglleft"), + (173, b"guilsinglright"), + (174, b"fi"), + (175, b"fl"), + (177, b"endash"), + (178, b"dagger"), + (179, b"daggerdbl"), + (180, b"periodcentered"), + (182, b"paragraph"), + (183, b"bullet"), + (184, b"quotesinglbase"), + (185, b"quotedblbase"), + (186, b"quotedblright"), + (187, b"guillemotright"), + (188, b"ellipsis"), + (189, b"perthousand"), + (191, b"questiondown"), + (193, b"grave"), + (194, b"acute"), + (195, b"circumflex"), + (196, b"tilde"), + (197, b"macron"), + (198, b"breve"), + (199, b"dotaccent"), + (200, b"dieresis"), + (202, b"ring"), + (203, b"cedilla"), + (205, b"hungarumlaut"), + (206, b"ogonek"), + (207, b"caron"), + (208, b"emdash"), + (225, b"AE"), + (227, b"ordfeminine"), + (232, b"Lslash"), + (233, b"Oslash"), + (234, b"OE"), + (235, b"ordmasculine"), + (241, b"ae"), + (245, b"dotlessi"), + (248, b"lslash"), + (249, b"oslash"), + (250, b"oe"), + (251, b"germandbls"), +]; + +pub(crate) static STANDARD: [Option<&[u8]>; 256] = build(STANDARD_ENTRIES); + +const WIN_ANSI_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Euro"), + (130, b"quotesinglbase"), + (131, b"florin"), + (132, b"quotedblbase"), + (133, b"ellipsis"), + (134, b"dagger"), + (135, b"daggerdbl"), + (136, b"circumflex"), + (137, b"perthousand"), + (138, b"Scaron"), + (139, b"guilsinglleft"), + (140, b"OE"), + (142, b"Zcaron"), + (145, b"quoteleft"), + (146, b"quoteright"), + (147, b"quotedblleft"), + (148, b"quotedblright"), + (149, b"bullet"), + (150, b"endash"), + (151, b"emdash"), + (152, b"tilde"), + (153, b"trademark"), + (154, b"scaron"), + (155, b"guilsinglright"), + (156, b"oe"), + (158, b"zcaron"), + (159, b"Ydieresis"), + (160, b"space"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"currency"), + (165, b"yen"), + (166, b"brokenbar"), + (167, b"section"), + (168, b"dieresis"), + (169, b"copyright"), + (170, b"ordfeminine"), + (171, b"guillemotleft"), + (172, b"logicalnot"), + (173, b"hyphen"), + (174, b"registered"), + (175, b"macron"), + (176, b"degree"), + (177, b"plusminus"), + (178, b"twosuperior"), + (179, b"threesuperior"), + (180, b"acute"), + (181, b"mu"), + (182, b"paragraph"), + (183, b"periodcentered"), + (184, b"cedilla"), + (185, b"onesuperior"), + (186, b"ordmasculine"), + (187, b"guillemotright"), + (188, b"onequarter"), + (189, b"onehalf"), + (190, b"threequarters"), + (191, b"questiondown"), + (192, b"Agrave"), + (193, b"Aacute"), + (194, b"Acircumflex"), + (195, b"Atilde"), + (196, b"Adieresis"), + (197, b"Aring"), + (198, b"AE"), + (199, b"Ccedilla"), + (200, b"Egrave"), + (201, b"Eacute"), + (202, b"Ecircumflex"), + (203, b"Edieresis"), + (204, b"Igrave"), + (205, b"Iacute"), + (206, b"Icircumflex"), + (207, b"Idieresis"), + (208, b"Eth"), + (209, b"Ntilde"), + (210, b"Ograve"), + (211, b"Oacute"), + (212, b"Ocircumflex"), + (213, b"Otilde"), + (214, b"Odieresis"), + (215, b"multiply"), + (216, b"Oslash"), + (217, b"Ugrave"), + (218, b"Uacute"), + (219, b"Ucircumflex"), + (220, b"Udieresis"), + (221, b"Yacute"), + (222, b"Thorn"), + (223, b"germandbls"), + (224, b"agrave"), + (225, b"aacute"), + (226, b"acircumflex"), + (227, b"atilde"), + (228, b"adieresis"), + (229, b"aring"), + (230, b"ae"), + (231, b"ccedilla"), + (232, b"egrave"), + (233, b"eacute"), + (234, b"ecircumflex"), + (235, b"edieresis"), + (236, b"igrave"), + (237, b"iacute"), + (238, b"icircumflex"), + (239, b"idieresis"), + (240, b"eth"), + (241, b"ntilde"), + (242, b"ograve"), + (243, b"oacute"), + (244, b"ocircumflex"), + (245, b"otilde"), + (246, b"odieresis"), + (247, b"divide"), + (248, b"oslash"), + (249, b"ugrave"), + (250, b"uacute"), + (251, b"ucircumflex"), + (252, b"udieresis"), + (253, b"yacute"), + (254, b"thorn"), + (255, b"ydieresis"), +]; + +pub(crate) static WIN_ANSI: [Option<&[u8]>; 256] = build(WIN_ANSI_ENTRIES); + +const MAC_ROMAN_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Adieresis"), + (129, b"Aring"), + (130, b"Ccedilla"), + (131, b"Eacute"), + (132, b"Ntilde"), + (133, b"Odieresis"), + (134, b"Udieresis"), + (135, b"aacute"), + (136, b"agrave"), + (137, b"acircumflex"), + (138, b"adieresis"), + (139, b"atilde"), + (140, b"aring"), + (141, b"ccedilla"), + (142, b"eacute"), + (143, b"egrave"), + (144, b"ecircumflex"), + (145, b"edieresis"), + (146, b"iacute"), + (147, b"igrave"), + (148, b"icircumflex"), + (149, b"idieresis"), + (150, b"ntilde"), + (151, b"oacute"), + (152, b"ograve"), + (153, b"ocircumflex"), + (154, b"odieresis"), + (155, b"otilde"), + (156, b"uacute"), + (157, b"ugrave"), + (158, b"ucircumflex"), + (159, b"udieresis"), + (160, b"dagger"), + (161, b"degree"), + (162, b"cent"), + (163, b"sterling"), + (164, b"section"), + (165, b"bullet"), + (166, b"paragraph"), + (167, b"germandbls"), + (168, b"registered"), + (169, b"copyright"), + (170, b"trademark"), + (171, b"acute"), + (172, b"dieresis"), + (174, b"AE"), + (175, b"Oslash"), + (177, b"plusminus"), + (180, b"yen"), + (181, b"mu"), + (187, b"ordfeminine"), + (188, b"ordmasculine"), + (190, b"ae"), + (191, b"oslash"), + (192, b"questiondown"), + (193, b"exclamdown"), + (194, b"logicalnot"), + (196, b"florin"), + (199, b"guillemotleft"), + (200, b"guillemotright"), + (201, b"ellipsis"), + (202, b"space"), + (203, b"Agrave"), + (204, b"Atilde"), + (205, b"Otilde"), + (206, b"OE"), + (207, b"oe"), + (208, b"endash"), + (209, b"emdash"), + (210, b"quotedblleft"), + (211, b"quotedblright"), + (212, b"quoteleft"), + (213, b"quoteright"), + (214, b"divide"), + (216, b"ydieresis"), + (217, b"Ydieresis"), + (218, b"fraction"), + (219, b"currency"), + (220, b"guilsinglleft"), + (221, b"guilsinglright"), + (222, b"fi"), + (223, b"fl"), + (224, b"daggerdbl"), + (225, b"periodcentered"), + (226, b"quotesinglbase"), + (227, b"quotedblbase"), + (228, b"perthousand"), + (229, b"Acircumflex"), + (230, b"Ecircumflex"), + (231, b"Aacute"), + (232, b"Edieresis"), + (233, b"Egrave"), + (234, b"Iacute"), + (235, b"Icircumflex"), + (236, b"Idieresis"), + (237, b"Igrave"), + (238, b"Oacute"), + (239, b"Ocircumflex"), + (241, b"Ograve"), + (242, b"Uacute"), + (243, b"Ucircumflex"), + (244, b"Ugrave"), + (245, b"dotlessi"), + (246, b"circumflex"), + (247, b"tilde"), + (248, b"macron"), + (249, b"breve"), + (250, b"dotaccent"), + (251, b"ring"), + (252, b"cedilla"), + (253, b"hungarumlaut"), + (254, b"ogonek"), + (255, b"caron"), +]; + +pub(crate) static MAC_ROMAN: [Option<&[u8]>; 256] = build(MAC_ROMAN_ENTRIES); + +const MAC_EXPERT_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclamsmall"), + (34, b"Hungarumlautsmall"), + (35, b"centoldstyle"), + (36, b"dollaroldstyle"), + (37, b"dollarsuperior"), + (38, b"ampersandsmall"), + (39, b"Acutesmall"), + (40, b"parenleftsuperior"), + (41, b"parenrightsuperior"), + (42, b"twodotenleader"), + (43, b"onedotenleader"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"fraction"), + (48, b"zerooldstyle"), + (49, b"oneoldstyle"), + (50, b"twooldstyle"), + (51, b"threeoldstyle"), + (52, b"fouroldstyle"), + (53, b"fiveoldstyle"), + (54, b"sixoldstyle"), + (55, b"sevenoldstyle"), + (56, b"eightoldstyle"), + (57, b"nineoldstyle"), + (58, b"colon"), + (59, b"semicolon"), + (61, b"threequartersemdash"), + (63, b"questionsmall"), + (68, b"Ethsmall"), + (71, b"onequarter"), + (72, b"onehalf"), + (73, b"threequarters"), + (74, b"oneeighth"), + (75, b"threeeighths"), + (76, b"fiveeighths"), + (77, b"seveneighths"), + (78, b"onethird"), + (79, b"twothirds"), + (86, b"ff"), + (87, b"fi"), + (88, b"fl"), + (89, b"ffi"), + (90, b"ffl"), + (91, b"parenleftinferior"), + (93, b"parenrightinferior"), + (94, b"Circumflexsmall"), + (95, b"hypheninferior"), + (96, b"Gravesmall"), + (97, b"Asmall"), + (98, b"Bsmall"), + (99, b"Csmall"), + (100, b"Dsmall"), + (101, b"Esmall"), + (102, b"Fsmall"), + (103, b"Gsmall"), + (104, b"Hsmall"), + (105, b"Ismall"), + (106, b"Jsmall"), + (107, b"Ksmall"), + (108, b"Lsmall"), + (109, b"Msmall"), + (110, b"Nsmall"), + (111, b"Osmall"), + (112, b"Psmall"), + (113, b"Qsmall"), + (114, b"Rsmall"), + (115, b"Ssmall"), + (116, b"Tsmall"), + (117, b"Usmall"), + (118, b"Vsmall"), + (119, b"Wsmall"), + (120, b"Xsmall"), + (121, b"Ysmall"), + (122, b"Zsmall"), + (123, b"colonmonetary"), + (124, b"onefitted"), + (125, b"rupiah"), + (126, b"Tildesmall"), + (129, b"asuperior"), + (130, b"centsuperior"), + (135, b"Aacutesmall"), + (136, b"Agravesmall"), + (137, b"Acircumflexsmall"), + (138, b"Adieresissmall"), + (139, b"Atildesmall"), + (140, b"Aringsmall"), + (141, b"Ccedillasmall"), + (142, b"Eacutesmall"), + (143, b"Egravesmall"), + (144, b"Ecircumflexsmall"), + (145, b"Edieresissmall"), + (146, b"Iacutesmall"), + (147, b"Igravesmall"), + (148, b"Icircumflexsmall"), + (149, b"Idieresissmall"), + (150, b"Ntildesmall"), + (151, b"Oacutesmall"), + (152, b"Ogravesmall"), + (153, b"Ocircumflexsmall"), + (154, b"Odieresissmall"), + (155, b"Otildesmall"), + (156, b"Uacutesmall"), + (157, b"Ugravesmall"), + (158, b"Ucircumflexsmall"), + (159, b"Udieresissmall"), + (161, b"eightsuperior"), + (162, b"fourinferior"), + (163, b"threeinferior"), + (164, b"sixinferior"), + (165, b"eightinferior"), + (166, b"seveninferior"), + (167, b"Scaronsmall"), + (169, b"centinferior"), + (170, b"twoinferior"), + (172, b"Dieresissmall"), + (174, b"Caronsmall"), + (175, b"osuperior"), + (176, b"fiveinferior"), + (178, b"commainferior"), + (179, b"periodinferior"), + (180, b"Yacutesmall"), + (182, b"dollarinferior"), + (185, b"Thornsmall"), + (187, b"nineinferior"), + (188, b"zeroinferior"), + (189, b"Zcaronsmall"), + (190, b"AEsmall"), + (191, b"Oslashsmall"), + (192, b"questiondownsmall"), + (193, b"oneinferior"), + (194, b"Lslashsmall"), + (201, b"Cedillasmall"), + (207, b"OEsmall"), + (208, b"figuredash"), + (209, b"hyphensuperior"), + (214, b"exclamdownsmall"), + (216, b"Ydieresissmall"), + (218, b"onesuperior"), + (219, b"twosuperior"), + (220, b"threesuperior"), + (221, b"foursuperior"), + (222, b"fivesuperior"), + (223, b"sixsuperior"), + (224, b"sevensuperior"), + (225, b"ninesuperior"), + (226, b"zerosuperior"), + (228, b"esuperior"), + (229, b"rsuperior"), + (230, b"tsuperior"), + (233, b"isuperior"), + (234, b"ssuperior"), + (235, b"dsuperior"), + (241, b"lsuperior"), + (242, b"Ogoneksmall"), + (243, b"Brevesmall"), + (244, b"Macronsmall"), + (245, b"bsuperior"), + (246, b"nsuperior"), + (247, b"msuperior"), + (248, b"commasuperior"), + (249, b"periodsuperior"), + (250, b"Dotaccentsmall"), + (251, b"Ringsmall"), +]; + +pub(crate) static MAC_EXPERT: [Option<&[u8]>; 256] = build(MAC_EXPERT_ENTRIES); From f94668f1c8c78c8cf024a2b29375336607a1f76d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 16:01:32 -0400 Subject: [PATCH 002/175] fix(sonar): clear css:S9441 and S3776 in forms actions --- assets/demo/showcase.html | 42 +++++++++++------------ crates/forms/src/actions.rs | 67 +++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index b26f24565a..3b14ea3b47 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -14,27 +14,27 @@ (highlight, sticky note, stamp, ...), so annotation geometry follows the layout. --> \n\n\n", + esc(title) + ); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + match lvl { + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), + } + } + Item::Table(t) => { + s.push_str("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let mut body = esc(&c.text); + if c.italic { + body = format!("{body}"); + } + if c.bold { + body = format!("{body}"); + } + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + } + Item::Img(im) => { + let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; + s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); + } + Item::PageBreak => s.push_str("
\n"), + } + } + s.push_str("\n\n"); + s +} diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs new file mode 100644 index 0000000000..d7495df135 --- /dev/null +++ b/crates/export/src/as_rtf.rs @@ -0,0 +1,127 @@ +use super::as_docx::rgb8; +use super::as_html::allowed; +use super::layout::{Block, Item, Page, count_lookup_steps, items}; +use super::*; + +fn rtf_text(s: &str) -> String { + let mut o = String::new(); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\\' | '{' | '}' => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 128 => o.push(c), + c => { + // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + o.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } + o +} + +/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks +/// between pages (images are left out). +pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; + let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { + match it { + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); + } + s.push_str("\\par}\n"); + } + Item::Table(t) => { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); + for row in &t.rows { + s.push_str("\\trowd\\trgaph108"); + // The row's cell boundaries come first: each cell ends at the right edge of + // the last grid column it spans. + let mut column = 0; + for c in row { + column += c.span.max(1); + if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { + s.push_str(&format!("\\cellx{x}")); + } + } + for c in row { + let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); + if c.bold { + fmt.push_str("\\b"); + } + if c.italic { + fmt.push_str("\\i"); + } + fmt.push_str(&cf(c.color)); + s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); + } + s.push_str("\\row\n"); + } + } + Item::Img(_) => {} + Item::PageBreak => s.push_str("\\page\n"), + } + } + s.push('}'); + s +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs new file mode 100644 index 0000000000..8a06050898 --- /dev/null +++ b/crates/export/src/layout.rs @@ -0,0 +1,468 @@ +use super::*; + +/// A paragraph of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub text: String, + /// [x0, y0, x1, y1] on the displayed page in points (y up). + pub rect: [f64; 4], + /// Font size in points. + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], + /// Counterclockwise baseline angle in user space (degrees). + pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], +} + +/// An image of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// "png" or "jpg". + pub ext: &'static str, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, + pub rect: [f64; 4], + /// Width and height before rotation, in points. + pub size: [f64; 2], + /// Counterclockwise rotation in user space (degrees). + pub rotation: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub blocks: Vec, + pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, +} + +/// One cell of a detected table. `span` is how many grid columns the cell covers (a header +/// row that stretches over sub-columns). +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub text: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], + pub span: usize, +} + +/// A table detected from the page's text layout: rows of cells, and the left edge of each +/// grid column (user space, y up) so the writers can size the columns. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub rect: [f64; 4], + pub cols: Vec, + pub rows: Vec>, +} + +/// What goes into the output, in order. +pub(crate) enum Item<'a> { + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), + /// Owned: tables are detected per page inside [`items`], so they cannot borrow. + Table(Table), + Img(&'a Image), + PageBreak, +} + +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + pub(crate) static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +pub(crate) fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + +/// The body text size: the size most of the document's characters are set in. +pub(crate) fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. + let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for b in pages.iter().flat_map(|p| &p.blocks) { + let k = (b.size * 2.0).round() as i64; + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); + } + } + by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) +} + +/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. +fn level(b: &Block, body: f64) -> u8 { + let short = b.text.len() < 200; + if short && b.size >= body * 1.6 { + 1 + } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { + 2 + } else { + 0 + } +} + +/// How far two cell left edges may drift (points) and still be the same grid column. +const COL_TOL: f64 = 4.0; + +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +pub(crate) const MAX_COLS: usize = 63; + +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; + +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; + +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) + }) + .collect() +} + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + +/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. +fn is_cell_like(b: &Block) -> bool { + (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() +} + +/// Detect tables in a page's blocks from their text layout: columns are left edges shared by +/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, +/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). +pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { + let mut consumed = vec![false; blocks.len()]; + // Candidate columns: cluster the left edges of cell-like blocks. + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) + for (x, i) in xs { + match cols.last_mut() { + Some((e, m)) if (x - *e).abs() <= COL_TOL => { + *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; + m.push(i); + } + _ => cols.push((x, vec![i])), + } + } + let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); + if cols.len() < 2 { + return (Vec::new(), consumed); + } + // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. + let mut col_of: Vec> = vec![None; blocks.len()]; + for (ci, (_, members)) in cols.iter().enumerate() { + for &i in members { + if let Some(c) = col_of.get_mut(i) { + *c = Some(ci); + } + } + } + // The column whose edge is within tolerance of `x` (edges are in ascending order). + let column_at = |x: f64| -> Option { + let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); + cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) + }; + // Assign every cell-like block to its nearest column (within tolerance). + let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom + let mut order: Vec = (0..blocks.len()).collect(); + order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); + for &bi in &order { + let b = &blocks[bi]; + if !is_cell_like(b) { + continue; + } + let Some(ci) = column_at(b.rect[0]) else { continue }; + let joins = rows + .last() + .and_then(|row| row.first()) + .map(|(_, fi)| { + let rb = &blocks[*fi]; + let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); + overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) + }) + .unwrap_or(false); + if joins { + if let Some(row) = rows.last_mut() { + row.push((ci, bi)); + } + } else { + rows.push(vec![(ci, bi)]); + } + } + // Runs of consecutive rows that have two or more distinct columns are tables. + let mut out = Vec::new(); + let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { + if run.len() < 2 { + run.clear(); + return; + } + let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); + used.sort_unstable(); + used.dedup(); + let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + edges.sort_by(f64::total_cmp); + edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + let ncols = edges.len(); + let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; + if !ok { + run.clear(); + return; + } + let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; + let mut rows_out = Vec::new(); + let mut slots: Vec)>> = vec![None; ncols]; + for row in run.drain(..) { + for slot in slots.iter_mut() { + *slot = None; + } + for (_ci, bi) in row { + let b = &blocks[bi]; + let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; + match slots.get_mut(slot) { + Some(Some((c, sources))) => { + c.text.push(' '); + c.text.push_str(&cell.text); + sources.push(bi); + } + Some(empty @ None) => *empty = Some((cell, vec![bi])), + None => continue, + } + } + let mut materialized = Vec::with_capacity(ncols); + let mut column = 0; + while column < ncols { + match slots.get_mut(column).and_then(Option::take) { + Some((mut cell, sources)) => { + // A spanning cell can skip later slots. Only consume source blocks + // when their text is emitted, leaving covered text in paragraph flow. + for bi in sources { + if let Some(taken) = consumed.get_mut(bi) { + *taken = true; + } + } + cell.span = cell.span.min(ncols - column).max(1); + column += cell.span; + materialized.push(cell); + } + None => { + column += 1; + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); + } + } + } + while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { + materialized.pop(); + } + rows_out.push(materialized); + } + let rect = [ + edges[0], + used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), + right, + used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), + ]; + out.push(Table { rect, cols: edges, rows: rows_out }); + }; + // Runs of consecutive multi-column rows are tables. Rows on either side of a run that + // hold a single cell wide enough to span two columns (a header band, a totals row) join + // the table; other single-column rows (captions, following text) stay paragraphs. + let spans_run = |edges: &[f64], bi: usize| -> bool { + let b = &blocks[bi]; + edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) + }; + let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { + let mut es: Vec = + run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + es.sort_by(f64::total_cmp); + es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + es + }; + let cont = continuations(blocks); + let qual: Vec = rows + .iter() + .map(|row| { + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); + cs.sort_unstable(); + cs.dedup(); + apart && cs.len() >= 2 + }) + .collect(); + let mut i = 0; + while i < rows.len() { + if !qual[i] { + i += 1; + continue; + } + let start = i; + while i < rows.len() && qual[i] { + i += 1; + } + let end = i; // run = rows[start..end] + let mut pieces: Vec> = Vec::new(); + if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { + pieces.push(rows[start - 1].clone()); + } + pieces.extend(rows[start..end].iter().cloned()); + if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { + pieces.push(rows[end].clone()); + } + flush(&mut pieces, &mut out, &mut consumed); + } + (out, consumed) +} + +pub(crate) fn items(pages: &[Page]) -> Vec> { + let body = body_size(pages); + let mut out = Vec::new(); + for (i, p) in pages.iter().enumerate() { + if i > 0 { + out.push(Item::PageBreak); + } + let (tables, consumed) = tables(&p.blocks); + // Blocks and images by their top edge, top to bottom. + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) + .collect(); + parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); + parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + out.extend(parts.into_iter().map(|x| x.2)); + } + out +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index dac5ec5172..cf32ea7930 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -18,1052 +18,24 @@ mod zip; pub use pptx::pptx; pub use zip::Zip; -/// A paragraph of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Block { - pub text: String, - /// [x0, y0, x1, y1] on the displayed page in points (y up). - pub rect: [f64; 4], - /// Font size in points. - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1 (black when unknown). - pub color: [f64; 3], - /// Counterclockwise baseline angle in user space (degrees). - pub rotation: f64, - /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, - /// which `text` leaves out. A block that continues this one on its line is a run of the same - /// paragraph, and these say whether a space separates them (#4158). - pub space_around: [bool; 2], -} - -/// An image of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Image { - /// "png" or "jpg". - pub ext: &'static str, - /// Shared: a picture drawn in many places is held once. - pub bytes: std::sync::Arc<[u8]>, - pub rect: [f64; 4], - /// Width and height before rotation, in points. - pub size: [f64; 2], - /// Counterclockwise rotation in user space (degrees). - pub rotation: f64, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub blocks: Vec, - pub images: Vec, - /// The colour the whole page is painted before its content (a page background), as sRGB - /// components in 0..=1; `None` for a page with no background of its own. - pub background: Option<[f64; 3]>, -} - -/// One cell of a detected table. `span` is how many grid columns the cell covers (a header -/// row that stretches over sub-columns). -#[derive(Clone, Debug, PartialEq)] -pub struct Cell { - pub text: String, - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1. - pub color: [f64; 3], - pub span: usize, -} - -/// A table detected from the page's text layout: rows of cells, and the left edge of each -/// grid column (user space, y up) so the writers can size the columns. -#[derive(Clone, Debug, PartialEq)] -pub struct Table { - pub rect: [f64; 4], - pub cols: Vec, - pub rows: Vec>, -} - -/// What goes into the output, in order. -enum Item<'a> { - /// A paragraph's runs (at least one) and its heading level. - Para(Vec>, u8), - /// Owned: tables are detected per page inside [`items`], so they cannot borrow. - Table(Table), - Img(&'a Image), - PageBreak, -} +mod as_docx; +mod as_html; +mod as_rtf; +mod layout; +pub use as_docx::docx; +pub(crate) use as_docx::hex; #[cfg(test)] -thread_local! { - /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. - static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Counts `n` entries read by a lookup (tests only). -#[inline] -fn count_lookup_steps(_n: usize) { - #[cfg(test)] - LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); -} - -/// The body text size: the size most of the document's characters are set in. -fn body_size(pages: &[Page]) -> f64 { - // Sizes in first-seen order (which breaks ties), and where each one is in it. - let mut by: Vec<(i64, usize)> = Vec::new(); - let mut slot: HashMap = HashMap::new(); - for b in pages.iter().flat_map(|p| &p.blocks) { - let k = (b.size * 2.0).round() as i64; - count_lookup_steps(1); - let i = *slot.entry(k).or_insert_with(|| { - by.push((k, 0)); - by.len() - 1 - }); - if let Some(e) = by.get_mut(i) { - e.1 = e.1.saturating_add(b.text.len()); - } - } - by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) -} - -/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. -fn level(b: &Block, body: f64) -> u8 { - let short = b.text.len() < 200; - if short && b.size >= body * 1.6 { - 1 - } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { - 2 - } else { - 0 - } -} - -/// How far two cell left edges may drift (points) and still be the same grid column. -const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or -/// more columns doesn't open. It also bounds a table's size: every row is materialized to all -/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make -/// (blocks / 2)² cells. -const MAX_COLS: usize = 63; -/// Two blocks in a row this close (as a fraction of their font size) are one line of running -/// text that changed font or colour, such as a link inside a sentence, not two table cells. -const INLINE_GAP: f64 = 0.5; -/// How many blocks ending near a block's start are looked at to find the one it continues; a -/// page laid out so that many end at the same place stays quick. -const CONTINUATION_PROBE: usize = 64; -/// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. -fn continuations(blocks: &[Block]) -> Vec { - continued_from(blocks).into_iter().map(|from| from.is_some()).collect() -} - -/// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. -fn continued_from(blocks: &[Block]) -> Vec> { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) - .map(|(ai, _)| ai) - }) - .collect() -} - -/// A gap between two runs wider than this (as a fraction of the font size) is a word space the -/// text left out. It is the gap the reading-block grouping uses for the same purpose. -const WORD_GAP: f64 = 0.12; - -/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space -/// before the next run when the source had one there or the page shows a gap, but the text has -/// none). -pub(crate) struct Run<'a> { - pub(crate) block: &'a Block, - pub(crate) text: String, -} - -/// A page's paragraphs, each a list of runs from left to right along its line. A block that -/// continues another on the same line ([`continued_from`]: an italic case name, a link, a -/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its -/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join -/// nothing. Paragraphs come in the order of their first runs in `blocks`. -pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { - let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); - let from = continued_from(blocks); - // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s - // order, NaN included), so that one is visited first and has its paragraph already, and the - // links cannot form a cycle. - let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); - order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); - // Each block's paragraph, named by its first block, and each paragraph's blocks in order. - let mut head: Vec = (0..blocks.len()).collect(); - let mut members: Vec> = vec![Vec::new(); blocks.len()]; - for &(x0, bi) in &order { - let Some(b) = blocks.get(bi) else { continue }; - let joined = from - .get(bi) - .copied() - .flatten() - .filter(|ai| !skipped(*ai)) - .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) - .and_then(|ai| head.get(ai).copied()); - let h = joined.unwrap_or(bi); - if let Some(slot) = head.get_mut(bi) { - *slot = h; - } - if let Some(m) = members.get_mut(h) { - m.push(bi); - } - } - members - .into_iter() - .filter(|m| !m.is_empty()) - .map(|m| { - let mut runs: Vec> = Vec::with_capacity(m.len()); - for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { - if let Some(prev) = runs.last_mut() - && !prev.text.ends_with(char::is_whitespace) - && !b.text.starts_with(char::is_whitespace) - && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) - { - prev.text.push(' '); - } - runs.push(Run { block: b, text: b.text.clone() }); - } - runs - }) - .collect() -} - -/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold -/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run -/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. -fn para_level(runs: &[Run], body: f64) -> u8 { - let Some(first) = runs.first() else { return 0 }; - let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; - runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) -} - -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(f64::total_cmp); - edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - let ncols = edges.len(); - let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; - if !ok { - run.clear(); - return; - } - let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; - let mut rows_out = Vec::new(); - let mut slots: Vec)>> = vec![None; ncols]; - for row in run.drain(..) { - for slot in slots.iter_mut() { - *slot = None; - } - for (_ci, bi) in row { - let b = &blocks[bi]; - let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; - let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match slots.get_mut(slot) { - Some(Some((c, sources))) => { - c.text.push(' '); - c.text.push_str(&cell.text); - sources.push(bi); - } - Some(empty @ None) => *empty = Some((cell, vec![bi])), - None => continue, - } - } - let mut materialized = Vec::with_capacity(ncols); - let mut column = 0; - while column < ncols { - match slots.get_mut(column).and_then(Option::take) { - Some((mut cell, sources)) => { - // A spanning cell can skip later slots. Only consume source blocks - // when their text is emitted, leaving covered text in paragraph flow. - for bi in sources { - if let Some(taken) = consumed.get_mut(bi) { - *taken = true; - } - } - cell.span = cell.span.min(ncols - column).max(1); - column += cell.span; - materialized.push(cell); - } - None => { - column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); - } - } - } - while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { - materialized.pop(); - } - rows_out.push(materialized); - } - let rect = [ - edges[0], - used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), - right, - used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), - ]; - out.push(Table { rect, cols: edges, rows: rows_out }); - }; - // Runs of consecutive multi-column rows are tables. Rows on either side of a run that - // hold a single cell wide enough to span two columns (a header band, a totals row) join - // the table; other single-column rows (captions, following text) stay paragraphs. - let spans_run = |edges: &[f64], bi: usize| -> bool { - let b = &blocks[bi]; - edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) - }; - let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { - let mut es: Vec = - run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(f64::total_cmp); - es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - es - }; - let cont = continuations(blocks); - let qual: Vec = rows - .iter() - .map(|row| { - // A block that continues another adds no column of its own, and the cells of a table row - // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). - let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); - let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); - spans.sort_by(|a, b| a[0].total_cmp(&b[0])); - let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); - let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); - cs.sort_unstable(); - cs.dedup(); - apart && cs.len() >= 2 - }) - .collect(); - let mut i = 0; - while i < rows.len() { - if !qual[i] { - i += 1; - continue; - } - let start = i; - while i < rows.len() && qual[i] { - i += 1; - } - let end = i; // run = rows[start..end] - let mut pieces: Vec> = Vec::new(); - if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { - pieces.push(rows[start - 1].clone()); - } - pieces.extend(rows[start..end].iter().cloned()); - if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { - pieces.push(rows[end].clone()); - } - flush(&mut pieces, &mut out, &mut consumed); - } - (out, consumed) -} - -fn items(pages: &[Page]) -> Vec> { - let body = body_size(pages); - let mut out = Vec::new(); - for (i, p) in pages.iter().enumerate() { - if i > 0 { - out.push(Item::PageBreak); - } - let (tables, consumed) = tables(&p.blocks); - // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) - .into_iter() - .filter_map(|runs| { - let first = runs.first()?.block; - let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) - }) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), - } - } - Item::Table(t) => { - s.push_str("
\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } - Item::Img(im) => { - let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; - s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); - } - Item::PageBreak => s.push_str("
\n"), - } - } - s.push_str("\n\n"); - s -} - -/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { - // The value is clamped to 0..=255 before the cast, so it cannot truncate. - c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) -} - -/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -pub(crate) fn hex(c: [f64; 3]) -> Option { - let [r, g, b] = rgb8(c); - (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) -} - -/// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { - let mut rpr = String::new(); - if bold { - rpr.push_str(""); - } - if italic { - rpr.push_str(""); - } - if let Some(h) = hex(color) { - rpr.push_str(&format!("")); - } - let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; - rpr.push_str(&format!("")); - format!("{rpr}{}", esc(text)) -} - -/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized -/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. -fn docx_table(t: &Table) -> String { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); - let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); - } - s.push_str(""); - for w in &widths { - s.push_str(&format!("")); - } - s.push_str(""); - for row in &t.rows { - s.push_str(""); - let mut column = 0usize; - for c in row { - let span = if c.span > 1 { format!("", c.span) } else { String::new() }; - let w: i64 = widths.iter().skip(column).take(c.span).sum(); - s.push_str(&format!( - "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic, c.color) - )); - column = column.saturating_add(c.span); - } - // Pad the grid so every row covers all columns (Word rejects short rows). - for _ in column..t.cols.len() { - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -/// Pages with rotated text, or text that actually overlaps an image or an oversized -/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. -/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including -/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. -fn positioned(page: &Page) -> bool { - if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { - return true; - } - let body = body_size(std::slice::from_ref(page)).max(1.0); - let overlays = page - .images - .iter() - .map(|im| (im.rect, None)) - .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); - // Bound the pairwise test on hostile pages (many overlays times many blocks): past the - // budget, keep the default flowing layout. - let mut budget: usize = 1_000_000; - for (r, own) in overlays { - for (i, b) in page.blocks.iter().enumerate() { - if budget == 0 { - return false; - } - budget -= 1; - if Some(i) != own && overlaps(r, b.rect) { - return true; - } - } - } - false -} - -/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that -/// only touch, or hostile non-finite coordinates, do not count. -fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { - let w = a[2].min(b[2]) - a[0].max(b[0]); - let h = a[3].min(b[3]) - a[1].max(b[1]); - w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 -} - -/// Bound every value written into a shape's style, including hostile PDF coordinates. -fn layout_number(n: f64) -> f64 { - if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } -} - -/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and -/// a 16-bit entry count; a hostile PDF must not be able to overflow them). -const MAX_MEDIA: usize = 1000; -const MAX_MEDIA_BYTES: usize = 512 << 20; - -/// Whether `im` can still be embedded: it has data, and the package stays within its limits. -fn room_for(media: &[(String, &Image)], im: &Image) -> bool { - !im.bytes.is_empty() - && media.len() < MAX_MEDIA - && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES -} - -/// Points as EMU (12700 per point); non-finite values are 0. -fn emu(pt: f64) -> i64 { - if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } -} - -/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. -fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { - let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); - format!( - "\ -\ -\ -\ -\ -" - ) -} - -fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { - // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a - // page-sized watermark cannot push the invoice footer onto another page. - let mut s = String::from(""); - let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { - *shape_id = shape_id.saturating_add(1); - let rotation = layout_number(-b.rotation).rem_euclid(360.0); - let (sin, cos) = rotation.to_radians().sin_cos(); - let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); - let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; - // Recover the unrotated width from the axis-aligned box. Use the larger axis so - // quarter turns and 45-degree watermarks do not divide by (nearly) zero. - let w = if b.rotation.abs() <= 0.1 { - bw - } else if cos.abs() >= sin.abs() { - (bw - h * sin.abs()) / cos.abs() - } else { - (bh - h * cos.abs()) / sin.abs() - }; - let w = layout_number(w).max(1.0); - let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); - let z = if b.size >= 2.0 * body { -2 } else { 1 }; - let style = format!( - "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" - ); - s.push_str(&format!( - "{}", - shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) - )); - } - for im in &page.images { - if !room_for(media, im) { - continue; - } - *shape_id = shape_id.saturating_add(1); - let n = media.len().saturating_add(1); - let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); - let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); - let rotation = layout_number(-im.rotation).rem_euclid(360.0); - let name = format!("image{n}.{}", im.ext); - let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); - // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML - // rectangle with an image does not show the picture. - s.push_str(&format!( - "\ -{}{}\ -", - n.saturating_add(1), - emu(x), - emu(y), - emu(w), - emu(h), - )); - s.push_str(&pic); - s.push_str(""); - media.push((name, im)); - } - s.push_str(""); - s -} - -fn word_page_size(page: &Page) -> (i64, i64) { - let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; - (twips(page.width), twips(page.height)) -} - -fn section(page: &Page) -> String { - let (pw, ph) = word_page_size(page); - let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); - format!( - "", - my / 2, - my / 2 - ) -} - -/// The page colour of a Word document: Word has one background for the whole document, so it -/// is written only when every page has the same, non-white background (#2992). -fn word_background(pages: &[Page]) -> Option { - let first = rgb8(pages.first()?.background?); - let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); - (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) -} - -/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; -/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. -pub fn docx(pages: &[Page], title: &str) -> Vec { - let mut body = String::new(); - let mut media: Vec<(String, &Image)> = Vec::new(); - // Word needs a paragraph between adjacent tables and after the last one in the body. - let mut after_table = false; - let mut shape_id = 0usize; - for (page_index, page) in pages.iter().enumerate() { - let (pw, _) = word_page_size(page); - let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; - if positioned(page) { - body.push_str(&positioned_page(page, &mut media, &mut shape_id)); - after_table = false; - } else { - for it in items(std::slice::from_ref(page)) { - match &it { - Item::Para(runs, lvl) => { - let style = match lvl { - 1 => "", - 2 => "", - _ => "", - }; - body.push_str(""); - body.push_str(style); - for r in runs { - body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); - } - body.push_str(""); - } - Item::Table(t) => { - if after_table { - body.push_str(""); - } - body.push_str(&docx_table(t)); - } - Item::Img(im) => { - if room_for(&media, im) { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = (emu(w * k), emu(h * k)); - body.push_str(&format!("")); - body.push_str(&picture_xml(n, &name, (cx, cy), None)); - body.push_str(""); - media.push((name, im)); - } - } - Item::PageBreak => body.push_str(""), - } - after_table = matches!(it, Item::Table(_)); - } - } - // Section breaks carry this source page's size and create exactly one next page. - // The last section must be a direct child of the body (OOXML 17.6.17). - if page_index + 1 < pages.len() { - body.push_str(&format!( - "{}", - section(page) - )); - } else { - if after_table || page.blocks.is_empty() && page.images.is_empty() { - body.push_str(""); - } - body.push_str(§ion(page)); - } - after_table = false; - } - // Word shows a document background only when its settings say so (`settings.xml` below). - let page_colour = word_background(pages); - let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); - let doc = format!( - "\n\ -{background}{body}\ -" - ); - let mut types = String::from( - "\n\ -\ -\ -\ -\ -", - ); - if page_colour.is_some() { - types.push_str( - "", - ); - } - types.push_str(""); - let rels = "\n\ -\ -"; - let mut doc_rels = String::from( - "\n\ -", - ); - for (i, (name, _)) in media.iter().enumerate() { - doc_rels.push_str(&format!( - "", - i + 1 - )); - } - if page_colour.is_some() { - doc_rels.push_str(""); - } - doc_rels.push_str(""); - let styles = "\n\ -\ -\ -\ -"; - let core = format!( - "\n{}", - esc(title) - ); - let mut z = Zip::default(); - z.add("[Content_Types].xml", types.as_bytes(), true); - z.add("_rels/.rels", rels.as_bytes(), true); - z.add("docProps/core.xml", core.as_bytes(), true); - z.add("word/document.xml", doc.as_bytes(), true); - z.add("word/styles.xml", styles.as_bytes(), true); - z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); - if page_colour.is_some() { - z.add("word/settings.xml", b"\n", true); - } - for (name, im) in &media { - z.add(&format!("word/media/{name}"), &im.bytes, false); - } - z.finish() -} - -fn rtf_text(s: &str) -> String { - let mut o = String::new(); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '\\' | '{' | '}' => { - o.push('\\'); - o.push(c); - } - c if (c as u32) < 128 => o.push(c), - c => { - // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. - let mut buf = [0u16; 2]; - for u in c.encode_utf16(&mut buf) { - o.push_str(&format!("\\u{}?", *u as i16)); - } - } - } - } - o -} - -/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks -/// between pages (images are left out). -pub fn rtf(pages: &[Page]) -> String { - let items = items(pages); - // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. - let mut colors: Vec<[u8; 3]> = Vec::new(); - let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); - let mut note = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - if c != [0, 0, 0] && !slot.contains_key(&c) { - slot.insert(c, colors.len()); - colors.push(c); - } - }; - for it in &items { - match it { - Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), - Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), - Item::Img(_) | Item::PageBreak => {} - } - } - // `\cfN` for a colour (nothing for black, which keeps the default). - let cf = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) - }; - let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - if !colors.is_empty() { - s.push_str("{\\colortbl;"); - for [r, g, b] in &colors { - s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); - } - s.push_str("}\n"); - } - for it in items { - match it { - Item::Para(runs, _) => { - // The first run's formatting is the paragraph's; each later run is a group that - // turns on or off what differs from it. - let fmt = |b: &Block, later: bool| { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); - } else if later { - fmt.push_str("\\b0"); - } - if b.italic { - fmt.push_str("\\i"); - } else if later { - fmt.push_str("\\i0"); - } - let colour = cf(b.color); - fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); - fmt - }; - let mut runs = runs.iter(); - let Some(first) = runs.next() else { continue }; - s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); - for r in runs { - s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); - } - s.push_str("\\par}\n"); - } - Item::Table(t) => { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); - for row in &t.rows { - s.push_str("\\trowd\\trgaph108"); - // The row's cell boundaries come first: each cell ends at the right edge of - // the last grid column it spans. - let mut column = 0; - for c in row { - column += c.span.max(1); - if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { - s.push_str(&format!("\\cellx{x}")); - } - } - for c in row { - let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); - if c.bold { - fmt.push_str("\\b"); - } - if c.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(c.color)); - s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); - } - s.push_str("\\row\n"); - } - } - Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), - } - } - s.push('}'); - s -} +use as_docx::{MAX_MEDIA, docx_table}; +pub(crate) use as_html::esc; +pub use as_html::html; +#[cfg(test)] +use as_html::{allowed, base64}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 044ed236dc..d735eb4c20 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -441,16 +441,7 @@ fn word_opens_tables_up_to_its_column_limit() { } fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { - Block { - text: t.into(), - rect: [x0, y0, x1, y1], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - } + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0, space_around: [false; 2] } } /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks From 0af6cf24186e446688177c8f08544f86592795a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:11:26 +0000 Subject: [PATCH 056/175] refactor(print): split lib.rs into error, settings, sheets, imposition New modules: error.rs, settings.rs, sheets.rs, imposition.rs. --- crates/print/src/error.rs | 84 +++ crates/print/src/imposition.rs | 348 +++++++++++ crates/print/src/lib.rs | 1035 +------------------------------- crates/print/src/settings.rs | 250 ++++++++ crates/print/src/sheets.rs | 350 +++++++++++ 5 files changed, 1046 insertions(+), 1021 deletions(-) create mode 100644 crates/print/src/error.rs create mode 100644 crates/print/src/imposition.rs create mode 100644 crates/print/src/settings.rs create mode 100644 crates/print/src/sheets.rs diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs new file mode 100644 index 0000000000..4b1fe95f21 --- /dev/null +++ b/crates/print/src/error.rs @@ -0,0 +1,84 @@ +/// What can go wrong when printing. +/// +/// The three things a *person* can be wrong about — there is nothing to print, a page number past +/// the end, a range that is not a page — each have their own variant with the numbers apart from +/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest +/// are faults rather than mistakes and carry their text as it is. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PrintError { + #[error("there are no pages to print")] + NoPages, + /// A page number past the end of the document: the number asked for, and the page count. + #[error("page {0} is out of range (1–{1})")] + OutOfRange(usize, usize), + /// A range token that is neither a page number nor a page label. + #[error("{0:?} is not a page number or label")] + NotAPage(String), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Spool(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs new file mode 100644 index 0000000000..1257bcf25e --- /dev/null +++ b/crates/print/src/imposition.rs @@ -0,0 +1,348 @@ +use super::error::PrintError; +use super::settings::{Content, Settings}; +use super::sheets::{Sheet, layout}; +use super::*; + +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; + let mut out = Vec::new(); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} + } + } + Some(out) +} + +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + +/// Does annotation `d` print under `content`? +pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + // The Do that draws the form applies its /Matrix itself, so only the placement goes here. + Some(a) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +/// `pages` are the document's pages, listed once by the caller: listing them for each page +/// printed would make printing quadratic in the page count. +pub(crate) fn page_form( + doc: &mut Document, + pages: &[pdfcraft_model::Page], + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0usize; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let ap = if !form.contains(b"Subtype") + && let Object::Stream(stream) = &*doc.get(ap) + { + // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. + // Keep the original intact: other document objects can share this appearance. + if let Some(&cached) = promoted.get(&ap) { + cached + } else { + let mut stream = stream.clone(); + stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); + let copy = doc.add(Object::Stream(stream)); + promoted.insert(ap, copy); + copy + } + } else { + ap + }; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + pub(crate) static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = source_pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..067112b2e7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -27,1026 +27,19 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; -/// What can go wrong when printing. -/// -/// The three things a *person* can be wrong about — there is nothing to print, a page number past -/// the end, a range that is not a page — each have their own variant with the numbers apart from -/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest -/// are faults rather than mistakes and carry their text as it is. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum PrintError { - #[error("there are no pages to print")] - NoPages, - /// A page number past the end of the document: the number asked for, and the page count. - #[error("page {0} is out of range (1–{1})")] - OutOfRange(usize, usize), - /// A range token that is neither a page number nor a page label. - #[error("{0:?} is not a page number or label")] - NotAPage(String), - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Spool(String), - #[error(transparent)] - Cos(#[from] pdfcraft_cos::CosError), -} - -/// How an interface says one of our wordings in its own language, holes and all: given the English -/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// The [`Words`] that translate nothing: the wording as it is written here. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// What a person can get wrong about what to print, in English, with their holes still in them. -/// -/// An interface that reports these in another language translates these three, which is the whole -/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; - -impl PrintError { - /// This error as one sentence, said in the language `words` translates into. - /// - /// The wording is translated and the numbers go in afterwards, in a single pass, so what the - /// person typed cannot be read as a hole. With [`in_english`] this is the error's own - /// `Display`, which is what the command line and scripts report. - pub fn said(&self, words: Words) -> String { - match self { - PrintError::NoPages => words("there are no pages to print"), - PrintError::OutOfRange(n, count) => { - say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) - } - // The token goes in quoted, the way it is typed, as the error itself prints it. - PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), - fault => fault.to_string(), - } - } -} - -/// A wording with its holes filled. -/// -/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. -/// Values go in during a single pass, so a value with braces in it is never read as a hole. -fn say(wording: &str, values: &[(&str, String)]) -> String { - let mut out = String::with_capacity(wording.len() + 8); - let mut rest = wording; - while let Some(open) = rest.find('{') { - let (before, from_brace) = rest.split_at(open); - out.push_str(before); - // `find` counts from the brace and `}` is one byte, so this is a char boundary. - let Some(close) = from_brace.find('}') else { - // A brace with nothing closing it is just text. - out.push_str(from_brace); - return out; - }; - let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; - match values.iter().find(|(name, _)| *name == hole) { - Some((_, value)) => out.push_str(value), - None => out.push_str(hole), - } - rest = from_brace.get(close + 1..).unwrap_or_default(); - } - out.push_str(rest); - out -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Orientation { - #[default] - Auto, - Portrait, - Landscape, -} - -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum SizeMode { - #[default] - Fit, - Actual, - Shrink, - /// Percent. - Custom(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrder { - #[default] - Horizontal, - HorizontalReversed, - Vertical, - VerticalReversed, - /// Single-sided sheets: cut into cell piles, then stack them in row-major order. - CutStack, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BookletSubset { - #[default] - BothSides, - FrontOnly, - BackOnly, -} - -/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as -/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, -/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for -/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned -/// sideways; front side only or back side only is one pass of a manual duplex and prints -/// single-sided. -pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { - match subset { - BookletSubset::BothSides => spool::Duplex::ShortEdge, - BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Binding { - #[default] - Left, - Right, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Layout { - Size(SizeMode), - Multiple { - cols: usize, - rows: usize, - /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid - /// with one cell left blank. - per_sheet: usize, - order: PageOrder, - border: bool, - auto_rotate: bool, - }, - Booklet { - subset: BookletSubset, - binding: Binding, - }, - /// Tile scale (percent), overlap (points), cut marks. - Poster { - scale: f64, - overlap: f64, - cut_marks: bool, - }, - /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads - /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. - Label { - flip: bool, - }, -} - -impl Default for Layout { - fn default() -> Self { - Layout::Size(SizeMode::Fit) - } -} - -impl Layout { - /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). - pub fn multiple(per_sheet: usize) -> Layout { - let (cols, rows) = match per_sheet { - 2 => (1, 2), - 4 => (2, 2), - 6 => (2, 3), - 9 => (3, 3), - 16 => (4, 4), - n => { - let c = (n as f64).sqrt().ceil().max(1.0) as usize; - (c, n.div_ceil(c).max(1)) - } - }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } - } -} - -/// Acrobat's Comments & Forms choices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Content { - /// Page content and form fields. - Document, - /// Page content, form fields and every printable comment. - #[default] - DocumentAndMarkups, - /// Page content, form fields and stamps. - DocumentAndStamps, - /// Form fields only. - FormFieldsOnly, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Settings { - /// Source pages (0-based) in print order; see [`select_pages`]. - pub pages: Vec, - /// Paper (width, height) in points, portrait. - pub paper: (f64, f64), - pub orientation: Orientation, - pub layout: Layout, - pub content: Content, - /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its - /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a - /// missing entry keeps the whole page. - pub crops: Vec>, - /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this - /// resolution before it is placed on the paper. The engine does the rendering; `impose` is - /// then given the image-only document. - pub raster: Option, -} - -/// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Raster { - /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). - pub dpi: f64, - /// Render the pictures in grey, so the print-ready PDF itself is grayscale. - pub grayscale: bool, -} - -impl Default for Raster { - fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Crop { - pub rect: [f64; 4], - pub text_up: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - pages: Vec::new(), - paper: PAPERS[0].1, - orientation: Orientation::Auto, - layout: Layout::default(), - content: Content::default(), - raster: None, - crops: Vec::new(), - } - } -} - -/// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ - ("US Letter", (612.0, 792.0)), - ("US Legal", (612.0, 1008.0)), - ("Tabloid", (792.0, 1224.0)), - ("A3", (841.89, 1190.55)), - ("A4", (595.28, 841.89)), - ("A5", (419.53, 595.28)), - ("A6", (297.64, 419.53)), - ("ARCH A (9 x 12 in)", (648.0, 864.0)), - ("ARCH B (12 x 18 in)", (864.0, 1296.0)), - ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), - ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), - ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), - ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), - ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), - ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), - ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), - ("A2", (1190.55, 1683.78)), - ("A1", (1683.78, 2383.94)), - ("A0", (2383.94, 3370.39)), - ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), - ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), -]; - -/// `PAPERS` index of the 4 × 6 in shipping label. -pub const LABEL_PAPER: usize = 19; - -/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes -/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the -/// spec and the IANA registry have them: a driverless printer may take only the names it -/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions -/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). -pub fn cups_media(index: usize) -> Option<&'static str> { - Some(match PAPERS.get(index)?.0 { - "US Letter" => "Letter", - "US Legal" => "Legal", - "Tabloid" => "Tabloid", - "A3" => "A3", - "A4" => "A4", - "A5" => "A5", - "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", - "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", - "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", - "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", - "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", - "ANSI C (17 x 22 in)" => "na_c_17x22in", - "ANSI D (22 x 34 in)" => "na_d_22x34in", - "ANSI E (34 x 44 in)" => "na_e_34x44in", - "A2" => "iso_a2_420x594mm", - "A1" => "iso_a1_594x841mm", - "A0" => "iso_a0_841x1189mm", - "US #10 Envelope (4.125 x 9.5 in)" => "Env10", - _ if index == LABEL_PAPER => "w288h432", - // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's - // paper stands, as it did for every size before #1583. - _ => return None, - }) -} - -/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print -/// dialog's jobs and `doc_print`'s alike. -pub fn media_option(index: usize) -> Option<(String, String)> { - cups_media(index).map(|m| ("media".to_string(), m.to_string())) -} - -/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid -/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects -/// are followed a few levels deep. -pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; - let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); - let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut score = 0i64; - let mut budget = DecodeBudget::page(); - // Content past the budget is left out: the answer is a guess from the text read so far. - let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); - text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); - score > 0 -} - -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { - let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { - match op.op.as_slice() { - // Bounded like the renderer's graphics-state stack: deeper saves are ignored. - b"q" if stack.len() < 256 => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(base), - b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), - b"BT" => tm = Matrix::IDENTITY, - b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), - b"Tj" | b"TJ" | b"'" | b"\"" => { - let [a, b, ..] = tm.then(&ctm).0; - *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); - } - b"Do" if depth < 4 => { - let Some(name) = op.name(0) else { continue }; - let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; - let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; - if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { - continue; - } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if !budget.enter_form() { - continue; - } - if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); - } - } - _ => {} - } - } -} - -/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed -/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. -pub const LABEL_MARGIN: f64 = 8.0; - -/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by -/// `quarter` quarter turns counter-clockwise (0–3). -fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { - let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); - let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; - let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); - let [a, b, c, d, e, f] = match quarter % 4 { - 0 => [s, 0.0, 0.0, s, x, y], - 1 => [0.0, s, -s, 0.0, x + pw, y], - 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], - _ => [0.0, -s, s, 0.0, x, y + ph], - }; - // Move the crop's corner to the origin first. - Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } -} - -/// Match a displayed PDF page to a standard sheet, independent of orientation. -/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. -pub fn matching_paper(size: (f64, f64)) -> Option { - if !size.0.is_finite() || !size.1.is_finite() { - return None; - } - let size = (size.0.min(size.1), size.0.max(size.1)); - PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) -} - -/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. -pub const MARGIN: f64 = 18.0; - -/// One page as placed on a sheet: the source page, the matrix from its *display space* to the -/// sheet, and the visible part of it in display space (clipping). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Placement { - pub page: usize, - pub matrix: Matrix, - pub clip: [f64; 4], -} - -/// One output sheet. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Sheet { - pub size: (f64, f64), - pub placed: Vec, - /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. - pub borders: Vec<[f64; 4]>, - pub lines: Vec<[f64; 4]>, -} - -fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { - let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - Orientation::Auto => { - if landscape_wanted { - (h, w) - } else { - (w, h) - } - } - } -} - -/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` -/// (centred), optionally rotated 90° (counter-clockwise). -fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { - let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); - let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; - let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); - let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; - Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } -} - -fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { - if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } -} - -/// Lay out the sheets for `settings` given each source page's display size. -pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { - let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); - if pages.is_empty() { - return Err(PrintError::NoPages); - } - let paper = settings.paper; - if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { - return Err(PrintError::Invalid("invalid paper size".into())); - } - let mut sheets = Vec::new(); - match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } - Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } - } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); - let Some(cells) = cells else { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - }; - // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. - let per_sheet = per_sheet.min(cells); - if per_sheet == 0 { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } - // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; - let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { - (cols, rows, page_landscape) - } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { - // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. - (rows, cols, true) - } else { - (cols, rows, false) - }; - let size = oriented(paper, settings.orientation, sheet_landscape); - let gap = 6.0; - let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - if cw <= 0.0 || ch <= 0.0 { - return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); - } - let sheet_count = pages.len().div_ceil(per_sheet); - for sheet_index in 0..sheet_count { - let mut sheet = Sheet { size, ..Sheet::default() }; - if order == PageOrder::CutStack { - // Marks align across every sheet, including unoccupied cells. Cut in the - // middle of each gutter; marks stay in the printable area, clear of cells. - for col in 1..gc { - let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; - sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); - } - for row in 1..gr { - let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; - sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); - } - } - for k in 0..per_sheet { - // A cell's pile gets consecutive pages. Missing pages leave blank cells, - // rather than shifting later piles left on the final sheets. - let index = if order == PageOrder::CutStack { - k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) - } else { - sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) - }; - let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; - - let (col, row) = match order { - PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), - PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), - PageOrder::Vertical => (k / gr, k % gr), - PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), - }; - let x0 = MARGIN + col as f64 * (cw + gap); - let y1 = size.1 - MARGIN - row as f64 * (ch + gap); - let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; - if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { - return Err(PrintError::Invalid("invalid page size".into())); - } - let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; - let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); - if !s.is_finite() || s <= 0.0 { - return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); - } - let pl = place_in(p, d, cell, s, rotate); - if border { - let b = pl.matrix.bbox(pl.clip); - sheet.borders.push(b); - } - sheet.placed.push(pl); - } - sheets.push(sheet); - } - } - Layout::Booklet { subset, binding } => { - // Pad to a multiple of 4 with blank pages (None). - let n4 = pages.len().div_ceil(4) * 4; - let at = |i: usize| pages.get(i).copied(); - let size = oriented(paper, Orientation::Landscape, true); - let half = (size.0 - 2.0 * MARGIN) / 2.0; - let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; - for k in 0..n4 / 4 { - let front = (at(n4 - 1 - 2 * k), at(2 * k)); - let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); - let mut sides = Vec::new(); - if subset != BookletSubset::BackOnly { - sides.push(front); - } - if subset != BookletSubset::FrontOnly { - sides.push(back); - } - for (l, r) in sides { - let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; - let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { - if let Some(p) = p { - let d = sizes[p]; - let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); - } - } - sheets.push(sheet); - } - } - } - Layout::Poster { scale, overlap, cut_marks } => { - if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { - return Err(PrintError::Invalid("invalid tile scale or overlap".into())); - } - let s = scale / 100.0; - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - // Each tile shows `step` of the scaled page plus `overlap` shared with the next. - let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - if overlap * 2.0 >= tw.min(th) { - return Err(PrintError::Invalid("the overlap is larger than the tile".into())); - } - let (sw, sh) = (d.0 * s, d.1 * s); - let (stepx, stepy) = (tw - overlap, th - overlap); - let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); - let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); - // The counts saturate at usize::MAX for a huge scale, so their product can overflow. - if nx.checked_mul(ny).is_none_or(|n| n > 1024) { - return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); - } - // Tiles from the top-left, row by row. - for ty in 0..ny { - for tx in 0..nx { - // The window into the scaled page (scaled display space, y up). - let wx0 = tx as f64 * stepx; - let wy1 = sh - ty as f64 * stepy; - let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); - let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; - let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; - if cut_marks { - let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); - for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { - let dx = if x == x0 { -1.0 } else { 1.0 }; - let dy = if y == y0 { -1.0 } else { 1.0 }; - sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); - sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); - } - } - sheets.push(sheet); - } - } - } - } - } - Ok(sheets) -} - -// Model-derived transforms and clipping bounds need their full PDF decimal precision. -fn geometry_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. -fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { - let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; - let mut out = Vec::new(); - for data in budget.decode_contents(doc, contents) { - match data { - Ok(data) => { - out.extend_from_slice(&data); - out.push(b'\n'); - } - Err(_) if budget.remaining() == 0 => return None, - Err(_) => {} - } - } - Some(out) -} - -/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that -/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For -/// printing pages as pictures, where a renderer draws each page as it stands. -pub fn keep_printed(doc: &mut Document, content: Content) { - for page in pdfcraft_model::pages(doc) { - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); - // Fails only when the page object isn't a dictionary, and such a page draws nothing. - let _ = doc.update_dict(page.obj, |d| { - if content == Content::FormFieldsOnly { - d.remove(b"Contents"); - } - if d.contains(b"Annots") { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - }); - } -} - -/// The standard annotation types of ISO 32000-2 Table 171. -const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ - b"Text", - b"Link", - b"FreeText", - b"Line", - b"Square", - b"Circle", - b"Polygon", - b"PolyLine", - b"Highlight", - b"Underline", - b"Squiggly", - b"StrikeOut", - b"Caret", - b"Stamp", - b"Ink", - b"Popup", - b"FileAttachment", - b"Sound", - b"Movie", - b"Screen", - b"Widget", - b"PrinterMark", - b"TrapNet", - b"Watermark", - b"3D", - b"Redact", - b"Projection", - b"RichMedia", -]; - -/// Does annotation `d` print under `content`? -fn prints(doc: &Document, d: &Dict, content: Content) -> bool { - const INVISIBLE: i64 = 1; - const HIDDEN: i64 = 2; - const PRINT: i64 = 4; - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & PRINT == 0 || flags & HIDDEN != 0 { - return false; - } - // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. - let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); - let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); - match subtype { - b"Widget" => true, - b"Link" | b"Popup" => false, - b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, - // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table - // 171) is not printed even with its Print flag set. - _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, - _ => content == Content::DocumentAndMarkups, - } -} - -/// The normal appearance stream to draw for an annotation (honouring `/AS`). -fn appearance(doc: &Document, d: &Dict) -> Option { - let ap = doc.resolve(d.get(b"AP")?); - let normal = ap.as_dict()?.get(b"N")?.clone(); - match &*doc.resolve(&normal) { - Object::Stream(_) => normal.as_ref(), - Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() - } - _ => None, - } -} - -/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. -fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { - let nums = - |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; - let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; - let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); - let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); - let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - if bw <= 0.0 || bh <= 0.0 { - return None; - } - let a = Matrix([ - (rect[2] - rect[0]) / bw, - 0.0, - 0.0, - (rect[3] - rect[1]) / bh, - rect[0] - b[0] * (rect[2] - rect[0]) / bw, - rect[1] - b[1] * (rect[3] - rect[1]) / bh, - ]); - // The Do that draws the form applies its /Matrix itself, so only the placement goes here. - Some(a) -} - -/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). -/// `pages` are the document's pages, listed once by the caller: listing them for each page -/// printed would make printing quadratic in the page count. -fn page_form( - doc: &mut Document, - pages: &[pdfcraft_model::Page], - index: usize, - content: Content, - promoted: &mut std::collections::HashMap, -) -> Result { - let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; - let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { - Vec::new() - } else { - decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) - .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? - }; - // Wrap the page content so a state it leaves behind can't affect the annotations. - if !body.is_empty() { - let mut wrapped = b"q\n".to_vec(); - wrapped.extend_from_slice(&body); - wrapped.extend_from_slice(b"\nQ\n"); - body = wrapped; - } - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0usize; - for a in annots { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if !prints(doc, &d, content) { - continue; - } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let Some(ap) = appearance(doc, &d) else { continue }; - let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); - let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; - let ap = if !form.contains(b"Subtype") - && let Object::Stream(stream) = &*doc.get(ap) - { - // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. - // Keep the original intact: other document objects can share this appearance. - if let Some(&cached) = promoted.get(&ap) { - cached - } else { - let mut stream = stream.clone(); - stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); - let copy = doc.add(Object::Stream(stream)); - promoted.insert(ap, copy); - copy - } - } else { - ap - }; - // Keep the page's own XObjects, including names previous print operations generated. - // Check the growing map so each appearance also keeps the name just assigned to it. - let name = loop { - let name = format!("PCPrA{k}"); - k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; - if !xobjects.contains(name.as_bytes()) { - break name; - } - }; - let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); - xobjects.set(name.into_bytes(), Object::Ref(ap)); - } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); - if let Some(g) = page.dict.get(b"Group") { - d.set(b"Group".to_vec(), g.clone()); - } - Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) -} +mod error; +mod imposition; +mod settings; +mod sheets; +pub use error::{PrintError, WORDINGS, Words, in_english}; #[cfg(test)] -thread_local! { - /// Pages listed by [`source_pages`] on this thread. - static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// The source document's pages, for printing. -fn source_pages(doc: &Document) -> Vec { - let pages = pdfcraft_model::pages(doc); - #[cfg(test)] - PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); - pages -} - -/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a -/// printer or is the user's own copy); callers must check the print permission first. -pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = source_pages(src); - let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); - let sheets = layout(&sizes, settings)?; - let mut doc = src.clone(); - // One form per source page used (a page may appear on several sheets: posters). - let mut forms: std::collections::HashMap = std::collections::HashMap::new(); - let mut promoted = std::collections::HashMap::new(); - let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); - let pages_root = doc.add(Object::Null); - let mut kids = Vec::new(); - for sheet in &sheets { - let mut c = String::new(); - let mut xo = Dict::new(); - for (i, pl) in sheet.placed.iter().enumerate() { - let form = match forms.get(&pl.page) { - Some(f) => *f, - None => { - let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; - forms.insert(pl.page, f); - f - } - }; - let name = format!("P{i}"); - xo.set(name.clone().into_bytes(), Object::Ref(form)); - // Display space → sheet, then clip to the visible part, then user → display. - let [a, b, cc, d, e, f] = pl.matrix.0; - let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); - let [ua, ub, uc, ud, ue, uf] = to_display.0; - let [x0, y0, x1, y1] = pl.clip; - c.push_str(&format!( - "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - geometry_number(a), - geometry_number(b), - geometry_number(cc), - geometry_number(d), - geometry_number(e), - geometry_number(f), - geometry_number(x0), - geometry_number(y0), - geometry_number(x1 - x0), - geometry_number(y1 - y0), - geometry_number(ua), - geometry_number(ub), - geometry_number(uc), - geometry_number(ud), - geometry_number(ue), - geometry_number(uf) - )); - } - if !sheet.borders.is_empty() || !sheet.lines.is_empty() { - c.push_str("q 0 G 0.5 w\n"); - for r in &sheet.borders { - c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); - } - for l in &sheet.lines { - c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - } - c.push_str("Q\n"); - } - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Page")); - p.set(b"Parent".to_vec(), Object::Ref(pages_root)); - p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); - p.set(b"Resources".to_vec(), Object::Dict(res)); - p.set(b"Contents".to_vec(), Object::Ref(contents)); - kids.push(Object::Ref(doc.add(Object::Dict(p)))); - } - let mut tree = Dict::new(); - tree.set(b"Type".to_vec(), Object::name("Pages")); - tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - tree.set(b"Kids".to_vec(), Object::Array(kids)); - doc.set(pages_root, Object::Dict(tree)); - let mut cat = Dict::new(); - cat.set(b"Type".to_vec(), Object::name("Catalog")); - cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); - // Keep the layers' configuration, or a layer that is off in the document would print: the - // pages' marked content still refers to its groups, and without this nothing says they are off. - let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); - if let Some(layers) = layers { - cat.set(b"OCProperties".to_vec(), layers); - } - let root = doc.add(Object::Dict(cat)); - doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); - doc.trailer_mut().remove(b"Info"); - if doc.trailer().contains(b"Encrypt") { - doc.remove_encryption(); - } - Ok(write_full(&doc, &SaveOptions::default())?) -} - -/// Which source pages land on each sheet (for previews and the sheet count). -pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { - sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() -} +use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; +pub use imposition::{impose, keep_printed, sheet_pages}; +pub use settings::{ + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + cups_media, media_option, +}; +#[cfg(test)] +use sheets::text_direction; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs new file mode 100644 index 0000000000..7c9554f25e --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid + /// with one cell left blank. + per_sheet: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, + /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this + /// resolution before it is placed on the paper. The engine does the rendering; `impose` is + /// then given the image-only document. + pub raster: Option, +} + +/// How pages are rendered when printing as image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Raster { + /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). + pub dpi: f64, + /// Render the pictures in grey, so the print-ready PDF itself is grayscale. + pub grayscale: bool, +} + +impl Default for Raster { + fn default() -> Self { + Raster { dpi: 300.0, grayscale: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + pages: Vec::new(), + paper: PAPERS[0].1, + orientation: Orientation::Auto, + layout: Layout::default(), + content: Content::default(), + raster: None, + crops: Vec::new(), + } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 21] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), + ("ARCH A (9 x 12 in)", (648.0, 864.0)), + ("ARCH B (12 x 18 in)", (864.0, 1296.0)), + ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), + ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), + ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), + ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), + ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), + ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), + ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), + ("A2", (1190.55, 1683.78)), + ("A1", (1683.78, 2383.94)), + ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), +]; + +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). +pub fn cups_media(index: usize) -> Option<&'static str> { + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs new file mode 100644 index 0000000000..ce80adc3eb --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,350 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::*; + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); + score > 0 +} + +pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if !budget.enter_form() { + continue; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + +/// Match a displayed PDF page to a standard sheet, independent of orientation. +/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. +pub fn matching_paper(size: (f64, f64)) -> Option { + if !size.0.is_finite() || !size.1.is_finite() { + return None; + } + let size = (size.0.min(size.1), size.0.max(size.1)); + PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) +} + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Label { flip } => { + let size = oriented(paper, settings.orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + for &p in &pages { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); + } + } + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + }; + // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. + let per_sheet = per_sheet.min(cells); + if per_sheet == 0 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(per_sheet); + for sheet_index in 0..sheet_count { + let mut sheet = Sheet { size, ..Sheet::default() }; + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..per_sheet { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + + let (col, row) = match order { + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + // The counts saturate at usize::MAX for a huge scale, so their product can overflow. + if nx.checked_mul(ny).is_none_or(|n| n > 1024) { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} From 4e278cb5fc7e9e47b923cf902700aaf91ba0711c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:38:14 +0000 Subject: [PATCH 057/175] refactor(xfa): split data.rs into data/ New modules: path, dates, values, build, merge, write, import, passwords. --- crates/xfa/src/data.rs | 1907 +----------------------------- crates/xfa/src/data/build.rs | 164 +++ crates/xfa/src/data/dates.rs | 193 +++ crates/xfa/src/data/import.rs | 353 ++++++ crates/xfa/src/data/merge.rs | 399 +++++++ crates/xfa/src/data/passwords.rs | 194 +++ crates/xfa/src/data/path.rs | 156 +++ crates/xfa/src/data/values.rs | 192 +++ crates/xfa/src/data/write.rs | 264 +++++ 9 files changed, 1944 insertions(+), 1878 deletions(-) create mode 100644 crates/xfa/src/data/build.rs create mode 100644 crates/xfa/src/data/dates.rs create mode 100644 crates/xfa/src/data/import.rs create mode 100644 crates/xfa/src/data/merge.rs create mode 100644 crates/xfa/src/data/passwords.rs create mode 100644 crates/xfa/src/data/path.rs create mode 100644 crates/xfa/src/data/values.rs create mode 100644 crates/xfa/src/data/write.rs diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index eac2f20bf4..7c387c44ef 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -15,6 +15,35 @@ use crate::XfaError; use crate::model::{Field, Node, SomIndices, Template, Ui}; use crate::packets::{Encoding, encode}; +mod build; +mod dates; +mod import; +mod merge; +mod passwords; +mod path; +mod values; +mod write; + +use build::MAX_INDEX; +pub use build::build_data; +#[cfg(test)] +pub(crate) use dates::iso_to_data; +pub(crate) use dates::{data_date_letters, data_to_iso, date_letters, date_tokens}; +pub use dates::{iso_to_pattern, pattern_to_iso}; +#[cfg(test)] +pub(crate) use import::MAX_IMPORT_BYTES; +pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; +pub use passwords::{clear_password_values, has_password_values}; +pub(crate) use path::template_field; +pub use path::{data_path, parse_datasets, som_to_path}; +pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with}; +#[cfg(test)] +pub(crate) use write::ENSURED; +pub use write::{ + DatasetsWrite, add_data_instances, remove_data_instance, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, + write_datasets_with, +}; + const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; /// Most data nodes read. const MAX_NODES: u32 = 1_000_000; @@ -61,1199 +90,6 @@ impl DataNode { } } -fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { - let name = n.tag_name().name().to_string(); - let children: Vec = - if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; - // The text of a leaf is its value; a container's own text is whitespace. - let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; - DataNode { name, text, children } -} - -/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a -/// tree whose root is that element. -pub fn parse_datasets(xdp: &str) -> Option { - if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { - return None; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; - let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; - let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; - Some(to_node(data, 0)) -} - -/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. -/// Unnamed containers (`#subform`) have no data node. -pub fn som_to_path(som: &str) -> DataPath { - som.split('.') - .filter_map(|seg| { - let seg = seg.trim(); - if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { - return None; - } - let (name, idx) = match seg.split_once('[') { - Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), - None => (seg, 0), - }; - Some((name.to_string(), idx.min(100_000))) - }) - .collect() -} - -/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. -/// An area is a named container in the form, so it is part of SOM expressions, but only -/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the -/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; -/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. -pub fn data_path(tpl: &Template, som: &str) -> DataPath { - let mut out = DataPath::new(); - // The template children the next segment is looked up in; `None` until the root matched, - // and again once a segment is not a container the template has. - let mut level: Option<&[Node]> = None; - for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { - let found = match level { - None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), - None => None, - Some(children) => match named(children, &name, 0) { - Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), - Some(Node::Area(a)) => Some(Container::Area(&a.children)), - _ => None, - }, - }; - match found { - Some(Container::Area(children)) => level = Some(children), - Some(Container::Children(children)) => { - level = Some(children); - out.push((name, index)); - } - None => { - level = None; - out.push((name, index)); - } - } - } - out -} - -enum Container<'a> { - Children(&'a [Node]), - /// Contents bind under the area's parent. - Area(&'a [Node]), -} - -/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no -/// SOM segment of their own). -fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), - Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), - _ => None, - }) - }) -} - -/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as -/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond -/// years, months, days and separators). -pub(crate) fn date_letters(picture: &str) -> Option { - let inner = picture - .find('{') - .and_then(|a| { - let rest = picture.get(a + 1..)?; - rest.find('}').and_then(|b| rest.get(..b)) - }) - .unwrap_or(picture) - .trim(); - if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { - return None; - } - Some(inner.replace('E', "d").to_ascii_lowercase()) -} - -/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a -/// year, a month or a day can't hold the date the field shows, so such a field's data keeps -/// ISO dates. -pub(crate) fn data_date_letters(f: &Field) -> Option { - let letters = f.data_picture.as_deref().and_then(date_letters)?; - let tokens = date_tokens(&letters); - let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); - (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) -} - -/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as -/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it -/// is: the data holds ISO dates by default. -pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { - data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) -} - -/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. -pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { - match data_pattern { - Some(p) => { - let written = iso_to_pattern(&iso, p); - if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } - } - None => iso, - } -} - -/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its -/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating -/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, -/// as [`named`] does. -fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - let sibs = SomIndices::of(nodes); - let mut repeating = None; - for (i, n) in nodes.iter().enumerate() { - if n.common().name.as_deref() != Some(name) { - continue; - } - let sib = sibs.at(i); - if sib == index { - return Some(n); - } - if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { - repeating = Some(n); - } - } - repeating.or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), - Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), - _ => None, - }) - }) -} - -/// The template field the object at a SOM path is an instance of, each segment's number counted -/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is -/// a button of an exclusion group (whose buttons take their value from the group). -pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { - let path = som_to_path(som); - let ((root, _), rest) = path.split_first()?; - if tpl.root.common.name.as_deref() != Some(root.as_str()) { - return None; - } - let ((last, last_index), inner) = rest.split_last()?; - let mut children: &[Node] = &tpl.root.children; - for (name, index) in inner { - children = match named_at(children, name, *index, 0)? { - Node::Subform(sf) => &sf.children, - Node::Area(a) => &a.children, - _ => return None, - }; - } - match named_at(children, last, *last_index, 0)? { - Node::Field(f) => Some(f), - _ => None, - } -} - -/// The date letters of the data picture of the field `som` names, when it has one. -fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { - tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) -} - -/// What a field holds, for writing the datasets. -#[derive(Clone, Debug, PartialEq)] -pub enum FieldData { - Text(String), - /// Checked or not. - Check(bool), - /// The selected button's on value, if any. - Radio(Option), - /// Nothing to write (buttons, signatures). - None, -} - -/// One field of the AcroForm, as the engine sees it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldDatum { - /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). - pub obj: ObjRef, - /// The fully qualified field name (a static form's SOM path). - pub name: String, - pub data: FieldData, -} - -fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) -} - -/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. -fn som_of(doc: &Document, f: &FieldDatum) -> String { - doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) -} - -/// The datasets text for a radio group. The AcroForm value is an appearance-state name -/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. -fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { - let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { - return String::new(); - }; - let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; - let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); - for kid in kids { - let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; - let original = text_of(doc, &widget, b"PCOn"); - let state = widget - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); - if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { - return original.unwrap_or_else(|| selected.to_string()); - } - } - selected.to_string() -} - -/// The on and off item values of a check box (`/PCItems`), or the widget's on state. -fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { - let d = doc.get(f.obj); - let items: Vec = d - .as_dict() - .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) - .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) - .unwrap_or_default(); - let on = items.first().cloned().unwrap_or_else(|| "1".into()); - let off = items.get(1).cloned().unwrap_or_default(); - (on, off) -} - -/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). -fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { - let d = doc.get(f.obj); - let aa = doc.resolve(d.as_dict()?.get(b"AA")?); - let fa = doc.resolve(aa.as_dict()?.get(b"F")?); - let js = text_of(doc, fa.as_dict()?, b"JS")?; - let i = js.find("AFDate_FormatEx(")?; - let rest = &js[i + "AFDate_FormatEx(".len()..]; - let q = rest.find('"')?; - let body = &rest[q + 1..]; - Some(body[..body.find('"')?].to_string()) -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// The weekday (0 = Sunday) of a proleptic Gregorian date. -fn weekday(y: u32, m: u32, d: u32) -> usize { - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize -} - -/// The index and length of the name in `names` that `text` starts with (ignoring case), -/// cut to `abbr` letters when that is set. -fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { - names.iter().enumerate().find_map(|(i, n)| { - let n = if abbr { n.get(..3)? } else { n }; - text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) - }) -} - -/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. -fn is_date_run(token: &str) -> bool { - token.starts_with(['y', 'm', 'd']) -} - -/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, -/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. -pub(crate) fn date_tokens(pattern: &str) -> Vec { - let mut out = Vec::new(); - let chars: Vec = pattern.chars().collect(); - let mut i = 0; - while i < chars.len() { - let c = chars[i].to_ascii_lowercase(); - if "ymd".contains(c) { - let mut j = i; - while j < chars.len() && chars[j].to_ascii_lowercase() == c { - j += 1; - } - out.push(std::iter::repeat_n(c, j - i).collect()); - i = j; - } else { - out.push(chars[i].to_string()); - i += 1; - } - } - out -} - -/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → -/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. -pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { - let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); - let [y, m, d] = parts[..] else { return iso.to_string() }; - let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; - let mut out = String::new(); - for t in date_tokens(pattern) { - match t.as_str() { - "yyyy" => out.push_str(&format!("{y:04}")), - "yy" => out.push_str(&format!("{:02}", y % 100)), - "mm" => out.push_str(&format!("{m:02}")), - "m" => out.push_str(&m.to_string()), - "dd" => out.push_str(&format!("{d:02}")), - "d" => out.push_str(&d.to_string()), - "mmm" | "mmmm" => { - let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - "ddd" | "dddd" => { - let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - other if is_date_run(other) => return iso.to_string(), - other => out.push_str(other), - } - } - out -} - -/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. -pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { - let text = text.trim(); - let (mut y, mut m, mut d) = (None, None, None); - let mut day_name = None; - let mut rest = text; - let tokens = date_tokens(pattern); - for (i, t) in tokens.iter().enumerate() { - match t.as_str() { - "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { - // Digits up to the next literal (or a fixed width). - let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); - let width = - if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; - let digits: String = rest.chars().take(width).collect(); - if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { - return None; - } - let v: u32 = digits.parse().ok()?; - rest = &rest[digits.len()..]; - match t.as_str() { - "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), - "mm" | "m" => m = Some(v), - _ => d = Some(v), - } - } - "mmm" | "mmmm" => { - let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; - m = Some(i as u32 + 1); - rest = rest.get(len..)?; - } - "ddd" | "dddd" => { - let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; - day_name = Some(i); - rest = rest.get(len..)?; - } - run if is_date_run(run) => return None, - lit => { - rest = rest.strip_prefix(lit)?; - } - } - let _ = i; - } - if !rest.is_empty() { - return None; - } - let (y, m, d) = (y?, m?, d?); - if day_name.is_some_and(|w| w != weekday(y, m, d)) { - return None; - } - (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) -} - -fn xml_name(s: &str) -> String { - let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); - if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { - out.insert(0, '_'); - } - out -} - -/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says -/// whether any were. -fn escape(s: &str) -> (String, bool) { - let mut out = String::with_capacity(s.len()); - let mut dropped = false; - for c in s.chars() { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - // Parsers turn a raw carriage return into a line feed; a reference keeps it. - '\r' => out.push_str(" "), - '\u{FFFE}' | '\u{FFFF}' => dropped = true, - c if c.is_control() && c != '\n' && c != '\t' => dropped = true, - c => out.push(c), - } - } - (out, dropped) -} - -/// Most data nodes one write may create (instances skipped by a SOM index count too). -const MAX_NEW_NODES: usize = 20_000; -/// Highest same-name sibling index a write follows. -const MAX_INDEX: usize = 10_000; -/// Most bytes of names the nodes one write creates hold together: every sibling an index -/// skips over gets its own copy of the name. -const MAX_NEW_NAME_BYTES: usize = 4 << 20; - -/// What one write may still create: nodes and bytes of their names. -struct NewNodes { - nodes: usize, - name_bytes: usize, -} - -impl NewNodes { - fn new() -> NewNodes { - NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } - } -} - -/// A data tree being built for writing. -#[derive(Default)] -struct Build { - name: String, - text: String, - children: Vec, - /// Positions in `children` by name, in order. - by_name: HashMap>, -} - -impl Build { - /// The node at `path` below this one, created as needed. `None` when the path is deeper - /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// new nodes or name bytes than `budget` holds. - fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { - if path.len() > MAX_DEPTH { - return None; - } - let mut node = self; - for (name, idx) in path { - if *idx > MAX_INDEX { - return None; - } - let name = xml_name(name); - let have = node.by_name.get(&name).map_or(0, Vec::len); - if *idx >= have { - let need = idx - have + 1; - let bytes = need.saturating_mul(name.len()); - if need > budget.nodes || bytes > budget.name_bytes { - return None; - } - budget.nodes -= need; - budget.name_bytes -= bytes; - let start = node.children.len(); - node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); - node.by_name.entry(name.clone()).or_default().extend(start..start + need); - } - let pos = *node.by_name.get(&name)?.get(*idx)?; - node = node.children.get_mut(pos)?; - } - Some(node) - } - - /// The tree as XML. Depth is bounded by [`Build::at`]. - fn write(&self, out: &mut String) { - if self.children.is_empty() { - out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); - return; - } - out.push_str(&format!("<{}>", self.name)); - self.write_children(out); - out.push_str(&format!("", self.name)); - } - - fn write_children(&self, out: &mut String) { - for c in &self.children { - c.write(out); - } - } -} - -/// A value to write: where (the field's SOM path, and the data path it binds to) and what. -struct Want { - som: String, - path: DataPath, - text: String, - /// Create the node even for an empty value (an instance of a repeating subform). - ensure: bool, -} - -/// `tpl`, or the document's own template when none is given (parsed once). -fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { - match tpl { - Some(t) => f(Some(t)), - None => { - let parsed = crate::script::template_of(doc).ok(); - f(parsed.as_ref()) - } - } -} - -/// [`data_path`] when the template is known, else [`som_to_path`]. -fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { - match tpl { - Some(t) => data_path(t, som), - None => som_to_path(som), - } -} - -/// The values the datasets should hold for `fields`, by data path: dates in each field's data -/// picture (or ISO), check and radio states, choice values as saved. -fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - if path.is_empty() { - continue; - } - let text = match &f.data { - FieldData::Text(t) => match date_pattern_of(doc, f) { - Some(p) => match pattern_to_iso(t, &p) { - // The date goes into the data in the field's data picture, or as an ISO date. - Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), - None => t.clone(), - }, - None => t.clone(), - }, - FieldData::Check(on) => { - let (on_item, off_item) = items_of(doc, f); - if *on { on_item } else { off_item } - } - FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), - FieldData::None => continue, - }; - out.push(Want { som, path, text, ensure: false }); - } - out -} - -/// What a rewrite of the datasets packet does. -#[derive(Clone, Copy)] -enum Op<'a> { - /// Put these values in (adding nodes as needed). - Values(&'a [Want]), - /// Take the node at this path out. - Remove(&'a DataPath), - /// Make these nodes the whole content of the `xfa:data` element (an import). - ReplaceData(&'a [ImportNode]), - /// Make the leaf at this path a picture: base64 text with its content type. - Picture(&'a DataPath, &'a str, &'a str), -} - -/// Note that a value could not be written (once per kind of reason, naming a few fields). -fn skipped(warnings: &mut Vec, reason: &str, som: &str) { - const MAX_WARNINGS: usize = 20; - if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { - if w.matches(", ").count() < 4 { - w.push_str(&format!(", {som}")); - } else if !w.ends_with('…') { - w.push_str(", …"); - } - } else if warnings.len() < MAX_WARNINGS { - warnings.push(format!("{reason}: {som}")); - } -} - -const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; -const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; -const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; -const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; - -/// Fresh data for `wants`: the children of an `xfa:data` element. -fn build_children(wants: &[Want], warnings: &mut Vec) -> String { - let mut root = Build::default(); - let mut budget = NewNodes::new(); - for w in wants { - match root.at(&w.path, &mut budget) { - Some(node) => node.text = w.text.clone(), - None => skipped(warnings, TOO_MANY, &w.som), - } - if escape(&w.text).1 { - skipped(warnings, CONTROL, &w.som); - } - } - let mut out = String::new(); - root.write_children(&mut out); - out -} - -/// The `xfa:data` element for `fields`, built afresh. -pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { - let wants = with_template(doc, None, |t| wanted(doc, fields, t)); - format!("{}", build_children(&wants, &mut Vec::new())) -} - -fn fresh_datasets(data_children: &str) -> String { - format!("{data_children}") -} - -/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. -fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { - let bytes = src.as_bytes(); - let mut quote = None; - for i in range.clone() { - let b = *bytes.get(i)?; - match (quote, b) { - (Some(q), _) if b == q => quote = None, - (Some(_), _) => {} - (None, b'"' | b'\'') => quote = Some(b), - (None, b'>') => return Some(i), - _ => {} - } - } - None -} - -/// An element's qualified name as written. -fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { - let rest = src.get(range.start.checked_add(1)?..range.end)?; - let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; - rest.get(..end).filter(|n| !n.is_empty()) -} - -/// One change to the packet text: replace `start..end` with `text`. -struct Edit { - start: usize, - end: usize, - text: String, -} - -/// The edit that puts `inner` (markup) at the end of `node`'s content. -fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - // `` becomes `inner`. - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) -} - -/// What merging the values into a packet's text came to. -enum Merged { - /// The data already holds every value. - Unchanged, - Changed(String), - /// The text could not be merged into (the reason is in the warnings). - Failed, -} - -/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. -#[derive(Clone, Copy, PartialEq)] -enum Within { - Packet, - Xdp, -} - -const WRAP_OPEN: &str = ""; -const WRAP_CLOSE: &str = ""; - -/// Write `wants` into the data of the datasets packet in `text`, changing only what has to -/// change: values go into the data nodes they bind to, missing nodes are added, and everything -/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as -/// written. -/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default -/// namespace, which attributes can't use). -fn xfa_prefix(n: roxmltree::Node) -> Option { - n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) -} - -/// Data read from a file, held as a tree so it can be written into any packet with the -/// prefix that packet uses, never as the file's own markup. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ImportNode { - pub name: String, - /// The element's namespace (`None`: none). - pub ns: Option, - /// `(namespace, name, value)`. - pub attrs: Vec<(Option, String, String)>, - pub text: String, - pub children: Vec, -} - -/// Deepest data an import keeps. -const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; - -/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a -/// namespace declared once is copied into every element and attribute that inherits it. -pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; - -/// What an import may still copy: nodes and bytes. -struct ImportBudget { - nodes: usize, - bytes: usize, -} - -impl ImportBudget { - fn new() -> ImportBudget { - ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } - } - - fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { - self.bytes = self - .bytes - .checked_sub(n) - .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; - Ok(()) - } -} - -/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past -/// the depth or either budget. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { - if depth > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; - let name = n.tag_name().name(); - let ns = n.tag_name().namespace(); - budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; - let mut attrs = Vec::new(); - for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { - budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; - attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); - } - let mut children = Vec::new(); - let mut text = String::new(); - for c in n.children() { - if c.is_element() { - children.push(import_node(c, depth + 1, budget)?); - } else if let Some(t) = c.text() { - budget.take_bytes(t.len())?; - text.push_str(t); - } - } - // A group's own text is layout whitespace. - if !children.is_empty() && text.trim().is_empty() { - text.clear(); - } - Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) -} - -/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where -/// they go); other namespaces are declared on the elements and attributes that use them. -fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { - let mut out = String::new(); - for n in nodes { - serialize_node(n, xfa, &mut out, 0); - } - out -} - -fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { - if depth > MAX_IMPORT_DEPTH { - return; - } - let mut decls: Vec<(String, String)> = Vec::new(); - let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { - match ns.as_deref() { - None => xml_name(name), - Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), - Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), - Some(uri) => { - let p = match decls.iter().position(|(_, u)| u == uri) { - Some(i) => format!("n{i}"), - None => { - decls.push((format!("n{}", decls.len()), uri.to_string())); - format!("n{}", decls.len() - 1) - } - }; - let _ = attr; - format!("{p}:{}", xml_name(name)) - } - } - }; - let tag = qualify(&n.ns, &n.name, false); - let attrs: Vec = n - .attrs - .iter() - .take(256) - .map(|(ns, name, value)| { - let q = qualify(ns, name, true); - format!(" {q}=\"{}\"", escape(value).0) - }) - .collect(); - out.push('<'); - out.push_str(&tag); - for (p, uri) in &decls { - out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); - } - for a in &attrs { - out.push_str(a); - } - if n.text.is_empty() && n.children.is_empty() { - out.push_str("/>"); - return; - } - out.push('>'); - out.push_str(&escape(&n.text).0); - for c in &n.children { - serialize_node(c, xfa, out, depth + 1); - } - out.push_str(&format!("")); -} - -fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { - let wants: &[Want] = match op { - Op::Values(w) => w, - Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], - }; - // Fresh data, where none exists yet (declared with the `xfa` prefix). - let fresh_children = |warnings: &mut Vec| match op { - Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), - _ => build_children(wants, warnings), - }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); - return Merged::Failed; - } - let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - // A packet of an array may use the prefixes the XDP envelope declares: read it inside - // an element declaring the usual ones. - let wrapped; - let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { - Ok(d) => (text, 0, d), - Err(first) => { - wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { - (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), - _ => { - warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); - return Merged::Failed; - } - } - } - }; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let datasets = parsed - .descendants() - .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) - .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); - let mut edits = Vec::new(); - let mut data = None; - match datasets { - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None if within == Within::Xdp => { - let root = parsed.root_element(); - let children = fresh_children(warnings); - match append_edit(src, root, &fresh_datasets(&children)) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - None => { - warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); - return Merged::Failed; - } - Some(ds) => match ds.children().find(|n| is(n, "data")) { - Some(d) => data = Some(d), - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None => { - let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); - let children = fresh_children(warnings); - let element = format!("<{prefix}data>{}", children); - match append_edit(src, ds, &element) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - }, - } - if let Some(data) = data { - match op { - Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), - Op::ReplaceData(nodes) => { - // Everything between the start and end tags; `` opens up. Written - // with the prefix the packet binds to the data namespace. - let prefix = xfa_prefix(data); - let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); - let range = data.range(); - match (start_tag_end(src, &range), qname(src, &range)) { - (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { - edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); - } - (Some(tag_end), _) => { - let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - Op::Picture(path, content_type, base64) => { - let mut node = Some(data); - for (name, idx) in path.iter() { - let want = xml_name(name); - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); - } - let Some(n) = node else { return Merged::Unchanged }; - if n.children().any(|c| c.is_element()) { - // A data group, not a leaf: a picture would wipe what's under it. - skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); - return Merged::Failed; - } - let name = qname(src, &n.range()).unwrap_or("value").to_string(); - let (ct, _) = escape(content_type); - let attr = match xfa_prefix(n) { - Some(p) => format!("{p}:contentType=\"{ct}\""), - None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), - }; - edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); - } - Op::Remove(path) => { - // The element at `path`, with the whitespace before it. - let mut node = Some(data); - for (name, idx) in path { - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); - } - if let Some(n) = node { - let r = n.range(); - let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); - edits.push(Edit { start, end: r.end, text: String::new() }); - } - } - } - } - if edits.is_empty() { - return Merged::Unchanged; - } - // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound - // to one node) keep the first. - edits.sort_by_key(|e| (e.start, e.end)); - let mut kept: Vec = Vec::with_capacity(edits.len()); - for e in edits { - if kept.last().is_some_and(|k| e.start < k.end) { - if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { - warnings.push(CONFLICT.into()); - } - continue; - } - kept.push(e); - } - let mut out = src.to_string(); - for e in kept.iter().rev() { - if out.get(e.start..e.end).is_none() { - warnings.push("The XFA data could not be updated; the field values were not written to it".into()); - return Merged::Failed; - } - out.replace_range(e.start..e.end, &e.text); - } - if shift > 0 { - match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { - Some(inner) => out = inner.to_string(), - None => return Merged::Failed, - } - } - Merged::Changed(out) -} - -/// The edits that put `wants` into the `data` element. -fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { - // Element children by local name, per element visited (built once each). - let mut index: HashMap>> = HashMap::new(); - let tree = data.document(); - // Nodes to add, per existing element they go into. - let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = NewNodes::new(); - for w in wants { - let (escaped, dropped) = escape(&w.text); - let mut node = data; - let mut depth = 0; - let mut have = 0; - for (name, idx) in &w.path { - let kids = index.entry(node.id()).or_insert_with(|| { - let mut by: HashMap> = HashMap::new(); - for c in node.children().filter(|c| c.is_element()) { - by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); - } - by - }); - let same = kids.get(name.as_str()); - match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { - Some(next) => { - node = next; - depth += 1; - } - None => { - have = same.map_or(0, Vec::len); - break; - } - } - } - if depth == w.path.len() { - // The data node exists: give it the value, unless it already holds it. A node - // only wanted to exist (a row instance) is left as it is. - if w.ensure && w.text.is_empty() { - continue; - } - let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - if node.children().any(|c| c.is_element()) { - let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - // Ignore existing XML indentation, but not whitespace in the accepted value. - // A skipped mismatch must warn: relayout would otherwise use stale data. - if all.trim() != w.text { - skipped(warnings, STRUCTURED, &w.som); - } - continue; - } - if current == w.text { - continue; - } - match replace_edit(src, node, &escaped) { - Some(e) => edits.push(e), - None => skipped(warnings, STRUCTURED, &w.som), - } - } else { - // Missing: add the rest of the path below the deepest node that exists. An empty - // value needs no node, unless the node itself is wanted (a new instance). - if w.text.is_empty() && !w.ensure { - continue; - } - let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); - if let Some(first) = rest.first_mut() { - first.1 = first.1.saturating_sub(have); - } - let pos = match added.iter().position(|(id, _)| *id == node.id()) { - Some(p) => p, - None => { - added.push((node.id(), Build::default())); - added.len() - 1 - } - }; - let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); - match built { - Some(b) => b.text = w.text.clone(), - None => { - skipped(warnings, TOO_MANY, &w.som); - continue; - } - } - } - if dropped { - skipped(warnings, CONTROL, &w.som); - } - } - for (id, build) in added { - let Some(node) = tree.get_node(id) else { continue }; - let mut inner = String::new(); - build.write_children(&mut inner); - match append_edit(src, node, &inner) { - Some(e) => edits.push(e), - None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), - } - } -} - -/// What [`write_datasets`] did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DatasetsWrite { - /// The datasets packet changed (a new revision of it was added to the document). - pub written: bool, - /// Values that could not be written, and why. - pub warnings: Vec, - /// The datasets stream written, when one was. - pub stream: Option, -} - -/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. -fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { - let s = doc.resolve(o); - let Object::Stream(s) = &*s else { - warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); - return None; - }; - let bytes = match s.decoded_within(64 << 20) { - Ok(b) => b, - Err(e) => { - warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); - return None; - } - }; - let decoded = crate::packets::decode(&bytes); - if decoded.is_none() { - warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); - } - decoded -} - -/// Write the fields' values into the datasets packet, keeping all the data no field binds to. -/// Nothing is written when the document has no XFA entry or the data already holds the values; -/// values that can't be written are reported, never silently dropped. -pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { - write_datasets_reusing(doc, fields, None) -} - -/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream -/// (written earlier in the same edit) rather than adding another. -pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { - write_datasets_with(doc, None, fields, reuse) -} - -/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says -/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. -pub fn write_datasets_with( - doc: &mut Document, - tpl: Option<&Template>, - fields: &[FieldDatum], - reuse: Option, -) -> Result { - let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); - rewrite(doc, &[Op::Values(&wants)], reuse) -} - /// One change to the data, for [`write_data_ops`]. #[derive(Clone, Debug, PartialEq)] pub enum DataOp { @@ -1316,688 +152,3 @@ impl DataNode { } } } - -fn path_som(path: &DataPath) -> String { - path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") -} - -/// Make sure the data has at least `count` instances of `name` under `parent` (a script added -/// rows: the layout repeats the subform once per data instance). -pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { - write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) -} - -/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a -/// script gave a value), adding the node as needed. -pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { - write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) -} - -/// Remove the data node at `path` (a script removed a row). -pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { - write_data_ops(doc, &[DataOp::Remove(path.clone())], None) -} - -/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on -/// all packets together, several pictures still fit. -const MAX_DATA_IMAGE: usize = 8 << 20; -/// Longest data file imported. -const MAX_IMPORT: usize = 16 << 20; - -/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with -/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The -/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream -/// this edit already wrote. -pub fn write_data_image( - doc: &mut Document, - path: &DataPath, - content_type: &str, - bytes: &[u8], - reuse: Option, -) -> Result { - use base64::Engine; - if path.is_empty() { - return Err(XfaError::Malformed("a picture needs a data path".into())); - } - if bytes.len() > MAX_DATA_IMAGE { - return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); - } - let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); - let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); - let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; - rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) -} - -/// What an import of a data file did. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DataImport { - /// Element nodes the new data holds. - pub nodes: usize, - pub warnings: Vec, - /// The datasets stream written. - pub stream: Option, -} - -/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export -/// Data's output, Designer's), a bare data element, or a data document whose root is the -/// form's root data node (named `expect_root`, when the caller knows it). Anything else is -/// refused: a form's data is never replaced by what is not its data. -pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { - if text.len() > MAX_IMPORT { - return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); - } - let text = text.trim_start_matches('\u{feff}'); - if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; - let root = parsed.root_element(); - let local = root.tag_name().name(); - let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); - let data = match local { - "xdp" => { - let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; - ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "datasets" if in_xfa(&root) => { - root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "data" if in_xfa(&root) => root, - _ => { - if root.tag_name().namespace().is_some() { - return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); - } - if let Some(want) = expect_root - && local != want - { - return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); - } - let node = import_node(root, 0, &mut ImportBudget::new())?; - return Ok(vec![node]); - } - }; - let mut budget = ImportBudget::new(); - let mut out = Vec::new(); - for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget)?); - } - Ok(out) -} - -/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping -/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already -/// wrote. -pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { - let nodes = parse_data_file(text, expect_root)?; - fn count(n: &[ImportNode]) -> usize { - n.iter().map(|c| 1 + count(&c.children)).sum() - } - let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; - if !r.written { - return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); - } - Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) -} - -/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. -fn datasets_packet(doc: &Document) -> Result, XfaError> { - let Some(root) = doc.root() else { return Ok(None) }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; - let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; - let text_of = |o: &Object| -> Result, XfaError> { - match &*doc.resolve(o) { - Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), - _ => Ok(None), - } - }; - match &*xfa { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at.and_then(|i| items.get(i + 1)) { - Some(o) => text_of(o), - None => Ok(None), - } - } - Object::Stream(_) => text_of(&xfa), - _ => Ok(None), - } -} - -/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document -/// holding the data alone (the form's root data node at the root, or the data element when -/// there are several); true gives an XDP holding the whole datasets and a reference to the -/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. -pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { - if crate::read_packets(doc)?.is_none() { - return Err(XfaError::NotXfa); - } - let packet = datasets_packet(doc)?; - let (datasets, data) = match &packet { - Some(text) => match datasets_markup(text) { - Ok(Some(m)) => m, - Ok(None) => (fresh_datasets(""), String::new()), - Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), - }, - None => (fresh_datasets(""), String::new()), - }; - let mut out = String::from("\n"); - if xdp { - let (name, _) = escape(pdf_name); - out.push_str("\n"); - out.push_str(&datasets); - out.push_str(&format!("\n\n\n")); - } else { - out.push_str(if data.is_empty() { &datasets } else { &data }); - out.push('\n'); - } - Ok(out) -} - -/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the -/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets -/// element; `Err`: the text can't be read. -fn datasets_markup(text: &str) -> Result, ()> { - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - return Err(()); - } - let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { - Ok(d) => (text, d), - Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), - }; - let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; - let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); - let data_alone = match data { - Some(d) => { - let kids: Vec = d.children().filter(|c| c.is_element()).collect(); - match kids.as_slice() { - [one] => element_markup(src, *one).ok_or(())?, - _ => element_markup(src, d).ok_or(())?, - } - } - None => String::new(), - }; - Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) -} - -/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks -/// added to it, so it stands alone. -fn element_markup(src: &str, n: roxmltree::Node) -> Option { - let range = n.range(); - let text = src.get(range.clone())?; - let tag_end = start_tag_end(src, &range)?; - let start_tag = src.get(range.start..tag_end)?; - let mut decls = String::new(); - for ns in n.namespaces() { - let attr = match ns.name() { - Some(p) => format!("xmlns:{p}="), - None => "xmlns=".to_string(), - }; - if !start_tag.contains(&attr) { - decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); - } - } - if decls.is_empty() { - return Some(text.to_string()); - } - let name_end = qname(src, &range)?.len() + 1; - Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) -} - -/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new -/// pass, which asks for the instances again). -const MAX_ENSURED: usize = 200_000; - -// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. -#[cfg(test)] -thread_local! { - pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script -/// event changed becomes a single new datasets stream. When the packet is already the stream -/// `reuse` (written earlier for the same event), that stream is replaced in place instead of -/// a new one being added. The stream written is in the report. -pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { - // Runs of values (and instances) merge in one pass; a value set twice keeps the last. - let mut steps: Vec> = Vec::new(); - let mut removes: Vec> = Vec::new(); - let mut run: Vec = Vec::new(); - // Instances queued in this run, by parent and name: asking for more only queues the rest. - let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); - let (mut ensured, mut capped) = (0usize, false); - let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { - if !run.is_empty() { - let mut seen = std::collections::HashSet::new(); - let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); - kept.reverse(); - steps.push(kept); - removes.push(None); - } - }; - for op in ops { - match op { - DataOp::Value { path, text } if !path.is_empty() => { - // An empty value clears an existing node and needs no new one. - run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); - } - DataOp::Instances { parent, name, count } => { - let done = queued.entry((parent, name.as_str())).or_insert(0); - while *done < (*count).min(MAX_INDEX) { - if ensured >= MAX_ENSURED { - capped = true; - break; - } - ensured += 1; - #[cfg(test)] - ENSURED.with(|n| n.set(n.get() + 1)); - let k = *done; - *done += 1; - let mut path = parent.clone(); - path.push((name.clone(), k)); - run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); - } - } - DataOp::Remove(path) if !path.is_empty() => { - flush(&mut run, &mut steps, &mut removes); - queued.clear(); - steps.push(Vec::new()); - removes.push(Some(path.clone())); - } - _ => {} - } - } - flush(&mut run, &mut steps, &mut removes); - let plan: Vec = steps - .iter() - .zip(&removes) - .map(|(w, r)| match r { - Some(p) => Op::Remove(p), - None => Op::Values(w), - }) - .collect(); - let mut written = rewrite(doc, &plan, reuse)?; - if capped { - written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); - } - Ok(written) -} - -/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream -/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. -fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { - let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); - match old.and_then(Object::as_ref) { - Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { - doc.set(r, stream); - r - } - _ => doc.add(stream), - } -} - -/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). -fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { - let mut current: Option = None; - for op in ops { - match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { - Merged::Changed(t) => current = Some(t), - Merged::Unchanged => {} - // Later steps still apply to what merged so far; the reason is in the warnings. - Merged::Failed => {} - } - } - current -} - -/// Longest packet text written (the packets together are read up to 64 MB). -const MAX_PACKET_WRITE: usize = 48 << 20; - -/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that -/// isn't is not written: better the old data than an unreadable packet. -fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { - if text.len() > MAX_PACKET_WRITE { - warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); - return false; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() - || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); - if !ok { - warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); - } - ok -} - -fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { - let mut report = DatasetsWrite::default(); - if ops.is_empty() { - return Ok(report); - } - let Some(root) = doc.root() else { return Ok(report) }; - let catalog = doc.get(root); - let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; - let acro_ref = acro_obj.as_ref(); - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; - let warnings = &mut report.warnings; - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut items = items.clone(); - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at { - Some(i) => { - let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; - let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; - let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; - if !readable(&t, Within::Packet, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); - report.stream = Some(r); - if let Some(slot) = items.get_mut(i + 1) { - *slot = Object::Ref(r); - } - } - None => { - // No datasets packet yet: made from the first values, then the rest merged - // in; before the postamble, or last. - let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; - let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; - let fresh = fresh_datasets(&build_children(wants, warnings)); - let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); - if !readable(&text, Within::Packet, warnings) { - return Ok(report); - } - let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); - report.stream = Some(r); - let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); - items.insert(pos, Object::Ref(r)); - items.insert(pos, Object::String(PdfString::text("datasets"))); - } - } - acro.set(b"XFA".to_vec(), Object::Array(items)); - } - Object::Stream(_) => { - let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; - let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; - if !readable(&new, Within::Xdp, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); - report.stream = Some(r); - acro.set(b"XFA".to_vec(), Object::Ref(r)); - } - _ => return Ok(report), - } - match acro_ref { - Some(ar) => doc.set(ar, Object::Dict(acro)), - None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, - } - report.written = true; - Ok(report) -} - -/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for -/// fields the data mentions. -pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - read_values_with(doc, None, fields) -} - -/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the -/// document's. -pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - with_template(doc, tpl, |t| values_at(doc, t, fields)) -} - -/// The values the document's datasets hold for `fields`, read in each field's data picture and -/// shown in its own pattern; only fields the data mentions. -fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - let Some(text) = data.value_at(&path) else { continue }; - let text = text.as_str(); - let value = match &f.data { - FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { - Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), - None => text.to_string(), - }), - FieldData::Check(_) => { - let (on, _) = items_of(doc, f); - FieldData::Check(is_on(text, &on)) - } - FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), - FieldData::None => continue, - }; - out.push((f.name.clone(), value)); - } - out -} - -/// Does a data value mean "checked" for a button whose on value is `on`? -pub fn is_on(text: &str, on: &str) -> bool { - let t = text.trim(); - if t == on { - return true; - } - !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() -} - -/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or -/// the single `/XFA` stream), when `doc` has one. -fn datasets_stream_ref(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; - let xfa = acro.as_dict()?.get(b"XFA")?; - match &*doc.resolve(xfa) { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; - items.get(at + 1)?.as_ref() - } - Object::Stream(_) => xfa.as_ref(), - _ => None, - } -} - -/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, -/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm -/// dictionary. -fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { - const PASSWORD_FLAG: i64 = 1 << 13; - let Some(root) = doc.root() else { return Vec::new() }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; - let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { - return Vec::new(); - }; - struct AcroWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - out: Vec<(String, String)>, - } - impl AcroWalk<'_> { - fn walk( - &mut self, - items: &[Object], - parent_name: &str, - inherited_ft: Option<&[u8]>, - inherited_ff: i64, - inherited_dv: Option<&str>, - depth: usize, - ) { - if depth > 32 { - return; - } - for item in items { - let Some(r) = item.as_ref() else { continue }; - if !self.seen.insert(r) { - continue; - } - let obj = self.doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let ft = d.name(b"FT").or(inherited_ft); - let ff = d.int(b"Ff").unwrap_or(inherited_ff); - let dv_own = text_of(self.doc, d, b"DV"); - let dv = dv_own.as_deref().or(inherited_dv); - let t = text_of(self.doc, d, b"T"); - let full_name = match (&t, parent_name.is_empty()) { - (Some(seg), true) => seg.clone(), - (Some(seg), false) => format!("{parent_name}.{seg}"), - (None, _) => parent_name.to_string(), - }; - let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); - if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { - let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); - if !som.is_empty() { - self.out.push((som, dv.unwrap_or_default().to_string())); - } - } - if !kids.is_empty() { - self.walk(&kids, &full_name, ft, ff, dv, depth + 1); - } - } - } - } - let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; - w.walk(&top, "", None, 0, None, 0); - w.out -} - -/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their -/// defaults (or empty when they have no default). -fn password_dataset_wants(doc: &Document) -> Vec { - if datasets_stream_ref(doc).is_none() { - return Vec::new(); - } - let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&packet) else { return Vec::new() }; - with_template(doc, None, |tpl| { - let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); - let mut seen = std::collections::HashSet::new(); - if let Some(t) = tpl { - struct Walk<'a> { - tpl: &'a Template, - data: &'a DataNode, - nodes: usize, - seen: &'a mut std::collections::HashSet, - out: &'a mut Vec<(String, DataPath, String)>, - } - impl Walk<'_> { - fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { - match name { - Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), - Some(n) => format!("{n}[{index}]"), - None => parent.to_string(), - } - } - fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { - if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { - return; - } - let sibs = SomIndices::of(children); - for (i, n) in children.iter().enumerate() { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - let sib = sibs.at(i); - match n { - Node::Subform(sf) => { - let from_data = match &sf.common.name { - Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); - for k in 0..instances { - if k > 0 { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - } - let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); - self.walk(&sf.children, &som, depth + 1); - } - } - Node::Area(a) => { - let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); - self.walk(&a.children, &som, depth + 1); - } - Node::Field(f) if f.ui == Ui::PasswordEdit => { - let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); - let path = data_path(self.tpl, &som); - if !path.is_empty() && self.seen.insert(path.clone()) { - let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); - self.out.push((som, path, default)); - } - } - _ => {} - } - } - } - } - let root = Node::Subform(Box::new(t.root.clone())); - let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; - w.walk(std::slice::from_ref(&root), "", 0); - if let Some(ps) = &t.root.page_set { - for (i, area) in ps.areas.iter().enumerate() { - let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); - w.walk(&area.items, &som, 1); - } - } - } - for (som, default) in acro_password_fields(doc) { - let path = path_of(tpl, &som); - if !path.is_empty() && seen.insert(path.clone()) { - candidates.push((som, path, default)); - } - } - candidates - .into_iter() - .filter_map(|(som, path, default)| { - let cur = data.value_at(&path)?; - (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) - }) - .collect() - }) -} - -/// Whether any XFA password field (`` or an AcroForm text field with the Password -/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). -pub fn has_password_values(doc: &Document) -> bool { - !password_dataset_wants(doc).is_empty() -} - -/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default -/// where the field has one, else emptying the element), replacing the datasets stream in place -/// (#3944). Returns how many password fields were cleared in the datasets. -pub fn clear_password_values(doc: &mut Document) -> Result { - let wants = password_dataset_wants(doc); - if wants.is_empty() { - return Ok(0); - } - let count = wants.len(); - let reuse = datasets_stream_ref(doc); - let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; - if !report.written || !password_dataset_wants(doc).is_empty() { - let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); - return Err(XfaError::Malformed(reason.to_string())); - } - Ok(count) -} diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs new file mode 100644 index 0000000000..68e8a2a364 --- /dev/null +++ b/crates/xfa/src/data/build.rs @@ -0,0 +1,164 @@ +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +pub(super) fn xml_name(s: &str) -> String { + let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); + if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { + out.insert(0, '_'); + } + out +} + +/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says +/// whether any were. +pub(super) fn escape(s: &str) -> (String, bool) { + let mut out = String::with_capacity(s.len()); + let mut dropped = false; + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + // Parsers turn a raw carriage return into a line feed; a reference keeps it. + '\r' => out.push_str(" "), + '\u{FFFE}' | '\u{FFFF}' => dropped = true, + c if c.is_control() && c != '\n' && c != '\t' => dropped = true, + c => out.push(c), + } + } + (out, dropped) +} + +/// Most data nodes one write may create (instances skipped by a SOM index count too). +const MAX_NEW_NODES: usize = 20_000; + +/// Highest same-name sibling index a write follows. +pub(super) const MAX_INDEX: usize = 10_000; + +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +pub(super) struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + pub(super) fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} + +/// A data tree being built for writing. +#[derive(Default)] +pub(super) struct Build { + name: String, + pub(super) text: String, + children: Vec, + /// Positions in `children` by name, in order. + by_name: HashMap>, +} + +impl Build { + /// The node at `path` below this one, created as needed. `None` when the path is deeper + /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more + /// new nodes or name bytes than `budget` holds. + pub(super) fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { + if path.len() > MAX_DEPTH { + return None; + } + let mut node = self; + for (name, idx) in path { + if *idx > MAX_INDEX { + return None; + } + let name = xml_name(name); + let have = node.by_name.get(&name).map_or(0, Vec::len); + if *idx >= have { + let need = idx - have + 1; + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { + return None; + } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); + } + let pos = *node.by_name.get(&name)?.get(*idx)?; + node = node.children.get_mut(pos)?; + } + Some(node) + } + + /// The tree as XML. Depth is bounded by [`Build::at`]. + fn write(&self, out: &mut String) { + if self.children.is_empty() { + out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); + return; + } + out.push_str(&format!("<{}>", self.name)); + self.write_children(out); + out.push_str(&format!("", self.name)); + } + + pub(super) fn write_children(&self, out: &mut String) { + for c in &self.children { + c.write(out); + } + } +} + +/// Note that a value could not be written (once per kind of reason, naming a few fields). +pub(super) fn skipped(warnings: &mut Vec, reason: &str, som: &str) { + const MAX_WARNINGS: usize = 20; + if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { + if w.matches(", ").count() < 4 { + w.push_str(&format!(", {som}")); + } else if !w.ends_with('…') { + w.push_str(", …"); + } + } else if warnings.len() < MAX_WARNINGS { + warnings.push(format!("{reason}: {som}")); + } +} + +pub(super) const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; + +pub(super) const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; + +pub(super) const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; + +pub(super) const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; + +/// Fresh data for `wants`: the children of an `xfa:data` element. +pub(super) fn build_children(wants: &[Want], warnings: &mut Vec) -> String { + let mut root = Build::default(); + let mut budget = NewNodes::new(); + for w in wants { + match root.at(&w.path, &mut budget) { + Some(node) => node.text = w.text.clone(), + None => skipped(warnings, TOO_MANY, &w.som), + } + if escape(&w.text).1 { + skipped(warnings, CONTROL, &w.som); + } + } + let mut out = String::new(); + root.write_children(&mut out); + out +} + +/// The `xfa:data` element for `fields`, built afresh. +pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { + let wants = with_template(doc, None, |t| wanted(doc, fields, t)); + format!("{}", build_children(&wants, &mut Vec::new())) +} + +pub(super) fn fresh_datasets(data_children: &str) -> String { + format!("{data_children}") +} diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs new file mode 100644 index 0000000000..029d91b273 --- /dev/null +++ b/crates/xfa/src/data/dates.rs @@ -0,0 +1,193 @@ +use super::path::template_field; +use super::*; + +/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as +/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond +/// years, months, days and separators). +pub(crate) fn date_letters(picture: &str) -> Option { + let inner = picture + .find('{') + .and_then(|a| { + let rest = picture.get(a + 1..)?; + rest.find('}').and_then(|b| rest.get(..b)) + }) + .unwrap_or(picture) + .trim(); + if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { + return None; + } + Some(inner.replace('E', "d").to_ascii_lowercase()) +} + +/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a +/// year, a month or a day can't hold the date the field shows, so such a field's data keeps +/// ISO dates. +pub(crate) fn data_date_letters(f: &Field) -> Option { + let letters = f.data_picture.as_deref().and_then(date_letters)?; + let tokens = date_tokens(&letters); + let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); + (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) +} + +/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as +/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it +/// is: the data holds ISO dates by default. +pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { + data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) +} + +/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the +/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { + match data_pattern { + Some(p) => { + let written = iso_to_pattern(&iso, p); + if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } + } + None => iso, + } +} + +/// The date letters of the data picture of the field `som` names, when it has one. +pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { + tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// The weekday (0 = Sunday) of a proleptic Gregorian date. +fn weekday(y: u32, m: u32, d: u32) -> usize { + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize +} + +/// The index and length of the name in `names` that `text` starts with (ignoring case), +/// cut to `abbr` letters when that is set. +fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { + names.iter().enumerate().find_map(|(i, n)| { + let n = if abbr { n.get(..3)? } else { n }; + text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) + }) +} + +/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. +fn is_date_run(token: &str) -> bool { + token.starts_with(['y', 'm', 'd']) +} + +/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, +/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. +pub(crate) fn date_tokens(pattern: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i].to_ascii_lowercase(); + if "ymd".contains(c) { + let mut j = i; + while j < chars.len() && chars[j].to_ascii_lowercase() == c { + j += 1; + } + out.push(std::iter::repeat_n(c, j - i).collect()); + i = j; + } else { + out.push(chars[i].to_string()); + i += 1; + } + } + out +} + +/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → +/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. +pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { + let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); + let [y, m, d] = parts[..] else { return iso.to_string() }; + let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; + let mut out = String::new(); + for t in date_tokens(pattern) { + match t.as_str() { + "yyyy" => out.push_str(&format!("{y:04}")), + "yy" => out.push_str(&format!("{:02}", y % 100)), + "mm" => out.push_str(&format!("{m:02}")), + "m" => out.push_str(&m.to_string()), + "dd" => out.push_str(&format!("{d:02}")), + "d" => out.push_str(&d.to_string()), + "mmm" | "mmmm" => { + let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + "ddd" | "dddd" => { + let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + other if is_date_run(other) => return iso.to_string(), + other => out.push_str(other), + } + } + out +} + +/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. +pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { + let text = text.trim(); + let (mut y, mut m, mut d) = (None, None, None); + let mut day_name = None; + let mut rest = text; + let tokens = date_tokens(pattern); + for (i, t) in tokens.iter().enumerate() { + match t.as_str() { + "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { + // Digits up to the next literal (or a fixed width). + let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); + let width = + if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; + let digits: String = rest.chars().take(width).collect(); + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let v: u32 = digits.parse().ok()?; + rest = &rest[digits.len()..]; + match t.as_str() { + "yyyy" => y = Some(v), + "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "mm" | "m" => m = Some(v), + _ => d = Some(v), + } + } + "mmm" | "mmmm" => { + let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; + m = Some(i as u32 + 1); + rest = rest.get(len..)?; + } + "ddd" | "dddd" => { + let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; + day_name = Some(i); + rest = rest.get(len..)?; + } + run if is_date_run(run) => return None, + lit => { + rest = rest.strip_prefix(lit)?; + } + } + let _ = i; + } + if !rest.is_empty() { + return None; + } + let (y, m, d) = (y?, m?, d?); + if day_name.is_some_and(|w| w != weekday(y, m, d)) { + return None; + } + (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) +} diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs new file mode 100644 index 0000000000..cec30b11b3 --- /dev/null +++ b/crates/xfa/src/data/import.rs @@ -0,0 +1,353 @@ +use super::build::{escape, fresh_datasets, xml_name}; +use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; +use super::values::Want; +use super::write::{DatasetsWrite, rewrite}; +use super::*; + +/// Data read from a file, held as a tree so it can be written into any packet with the +/// prefix that packet uses, never as the file's own markup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportNode { + pub name: String, + /// The element's namespace (`None`: none). + pub ns: Option, + /// `(namespace, name, value)`. + pub attrs: Vec<(Option, String, String)>, + pub text: String, + pub children: Vec, +} + +/// Deepest data an import keeps. +const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; + +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); + } + let mut children = Vec::new(); + let mut text = String::new(); + for c in n.children() { + if c.is_element() { + children.push(import_node(c, depth + 1, budget)?); + } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; + text.push_str(t); + } + } + // A group's own text is layout whitespace. + if !children.is_empty() && text.trim().is_empty() { + text.clear(); + } + Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) +} + +/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where +/// they go); other namespaces are declared on the elements and attributes that use them. +pub(super) fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { + let mut out = String::new(); + for n in nodes { + serialize_node(n, xfa, &mut out, 0); + } + out +} + +fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { + if depth > MAX_IMPORT_DEPTH { + return; + } + let mut decls: Vec<(String, String)> = Vec::new(); + let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { + match ns.as_deref() { + None => xml_name(name), + Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), + Some(uri) => { + let p = match decls.iter().position(|(_, u)| u == uri) { + Some(i) => format!("n{i}"), + None => { + decls.push((format!("n{}", decls.len()), uri.to_string())); + format!("n{}", decls.len() - 1) + } + }; + let _ = attr; + format!("{p}:{}", xml_name(name)) + } + } + }; + let tag = qualify(&n.ns, &n.name, false); + let attrs: Vec = n + .attrs + .iter() + .take(256) + .map(|(ns, name, value)| { + let q = qualify(ns, name, true); + format!(" {q}=\"{}\"", escape(value).0) + }) + .collect(); + out.push('<'); + out.push_str(&tag); + for (p, uri) in &decls { + out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); + } + for a in &attrs { + out.push_str(a); + } + if n.text.is_empty() && n.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + out.push_str(&escape(&n.text).0); + for c in &n.children { + serialize_node(c, xfa, out, depth + 1); + } + out.push_str(&format!("")); +} + +/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on +/// all packets together, several pictures still fit. +const MAX_DATA_IMAGE: usize = 8 << 20; + +/// Longest data file imported. +const MAX_IMPORT: usize = 16 << 20; + +/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with +/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The +/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream +/// this edit already wrote. +pub fn write_data_image( + doc: &mut Document, + path: &DataPath, + content_type: &str, + bytes: &[u8], + reuse: Option, +) -> Result { + use base64::Engine; + if path.is_empty() { + return Err(XfaError::Malformed("a picture needs a data path".into())); + } + if bytes.len() > MAX_DATA_IMAGE { + return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); + } + let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) +} + +/// What an import of a data file did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DataImport { + /// Element nodes the new data holds. + pub nodes: usize, + pub warnings: Vec, + /// The datasets stream written. + pub stream: Option, +} + +/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export +/// Data's output, Designer's), a bare data element, or a data document whose root is the +/// form's root data node (named `expect_root`, when the caller knows it). Anything else is +/// refused: a form's data is never replaced by what is not its data. +pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { + if text.len() > MAX_IMPORT { + return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); + } + let text = text.trim_start_matches('\u{feff}'); + if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; + let root = parsed.root_element(); + let local = root.tag_name().name(); + let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); + let data = match local { + "xdp" => { + let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; + ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "datasets" if in_xfa(&root) => { + root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "data" if in_xfa(&root) => root, + _ => { + if root.tag_name().namespace().is_some() { + return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); + } + if let Some(want) = expect_root + && local != want + { + return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); + } + let node = import_node(root, 0, &mut ImportBudget::new())?; + return Ok(vec![node]); + } + }; + let mut budget = ImportBudget::new(); + let mut out = Vec::new(); + for c in data.children().filter(|c| c.is_element()) { + out.push(import_node(c, 1, &mut budget)?); + } + Ok(out) +} + +/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping +/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already +/// wrote. +pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { + let nodes = parse_data_file(text, expect_root)?; + fn count(n: &[ImportNode]) -> usize { + n.iter().map(|c| 1 + count(&c.children)).sum() + } + let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; + if !r.written { + return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); + } + Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) +} + +/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. +pub(super) fn datasets_packet(doc: &Document) -> Result, XfaError> { + let Some(root) = doc.root() else { return Ok(None) }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; + let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; + let text_of = |o: &Object| -> Result, XfaError> { + match &*doc.resolve(o) { + Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), + _ => Ok(None), + } + }; + match &*xfa { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at.and_then(|i| items.get(i + 1)) { + Some(o) => text_of(o), + None => Ok(None), + } + } + Object::Stream(_) => text_of(&xfa), + _ => Ok(None), + } +} + +/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document +/// holding the data alone (the form's root data node at the root, or the data element when +/// there are several); true gives an XDP holding the whole datasets and a reference to the +/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. +pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { + if crate::read_packets(doc)?.is_none() { + return Err(XfaError::NotXfa); + } + let packet = datasets_packet(doc)?; + let (datasets, data) = match &packet { + Some(text) => match datasets_markup(text) { + Ok(Some(m)) => m, + Ok(None) => (fresh_datasets(""), String::new()), + Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), + }, + None => (fresh_datasets(""), String::new()), + }; + let mut out = String::from("\n"); + if xdp { + let (name, _) = escape(pdf_name); + out.push_str("\n"); + out.push_str(&datasets); + out.push_str(&format!("\n\n\n")); + } else { + out.push_str(if data.is_empty() { &datasets } else { &data }); + out.push('\n'); + } + Ok(out) +} + +/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the +/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets +/// element; `Err`: the text can't be read. +fn datasets_markup(text: &str) -> Result, ()> { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return Err(()); + } + let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { + Ok(d) => (text, d), + Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), + }; + let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; + let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); + let data_alone = match data { + Some(d) => { + let kids: Vec = d.children().filter(|c| c.is_element()).collect(); + match kids.as_slice() { + [one] => element_markup(src, *one).ok_or(())?, + _ => element_markup(src, d).ok_or(())?, + } + } + None => String::new(), + }; + Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) +} + +/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks +/// added to it, so it stands alone. +fn element_markup(src: &str, n: roxmltree::Node) -> Option { + let range = n.range(); + let text = src.get(range.clone())?; + let tag_end = start_tag_end(src, &range)?; + let start_tag = src.get(range.start..tag_end)?; + let mut decls = String::new(); + for ns in n.namespaces() { + let attr = match ns.name() { + Some(p) => format!("xmlns:{p}="), + None => "xmlns=".to_string(), + }; + if !start_tag.contains(&attr) { + decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); + } + } + if decls.is_empty() { + return Some(text.to_string()); + } + let name_end = qname(src, &range)?.len() + 1; + Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) +} diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs new file mode 100644 index 0000000000..6888b1439a --- /dev/null +++ b/crates/xfa/src/data/merge.rs @@ -0,0 +1,399 @@ +use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, build_children, escape, fresh_datasets, skipped, xml_name}; +use super::import::{ImportNode, serialize_nodes}; +use super::values::Want; +use super::write::MAX_PACKET_WRITE; +use super::*; + +/// What a rewrite of the datasets packet does. +#[derive(Clone, Copy)] +pub(super) enum Op<'a> { + /// Put these values in (adding nodes as needed). + Values(&'a [Want]), + /// Take the node at this path out. + Remove(&'a DataPath), + /// Make these nodes the whole content of the `xfa:data` element (an import). + ReplaceData(&'a [ImportNode]), + /// Make the leaf at this path a picture: base64 text with its content type. + Picture(&'a DataPath, &'a str, &'a str), +} + +/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. +pub(super) fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { + let bytes = src.as_bytes(); + let mut quote = None; + for i in range.clone() { + let b = *bytes.get(i)?; + match (quote, b) { + (Some(q), _) if b == q => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(b), + (None, b'>') => return Some(i), + _ => {} + } + } + None +} + +/// An element's qualified name as written. +pub(super) fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { + let rest = src.get(range.start.checked_add(1)?..range.end)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; + rest.get(..end).filter(|n| !n.is_empty()) +} + +/// One change to the packet text: replace `start..end` with `text`. +struct Edit { + start: usize, + end: usize, + text: String, +} + +/// The edit that puts `inner` (markup) at the end of `node`'s content. +fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + // `` becomes `inner`. + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) +} + +/// What merging the values into a packet's text came to. +enum Merged { + /// The data already holds every value. + Unchanged, + Changed(String), + /// The text could not be merged into (the reason is in the warnings). + Failed, +} + +/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Within { + Packet, + Xdp, +} + +pub(super) const WRAP_OPEN: &str = ""; + +pub(super) const WRAP_CLOSE: &str = ""; + +/// Write `wants` into the data of the datasets packet in `text`, changing only what has to +/// change: values go into the data nodes they bind to, missing nodes are added, and everything +/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as +/// written. +/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default +/// namespace, which attributes can't use). +fn xfa_prefix(n: roxmltree::Node) -> Option { + n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) +} + +fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { + let wants: &[Want] = match op { + Op::Values(w) => w, + Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], + }; + // Fresh data, where none exists yet (declared with the `xfa` prefix). + let fresh_children = |warnings: &mut Vec| match op { + Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), + _ => build_children(wants, warnings), + }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); + return Merged::Failed; + } + let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + // A packet of an array may use the prefixes the XDP envelope declares: read it inside + // an element declaring the usual ones. + let wrapped; + let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { + Ok(d) => (text, 0, d), + Err(first) => { + wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { + (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), + _ => { + warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); + return Merged::Failed; + } + } + } + }; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let datasets = parsed + .descendants() + .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) + .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); + let mut edits = Vec::new(); + let mut data = None; + match datasets { + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None if within == Within::Xdp => { + let root = parsed.root_element(); + let children = fresh_children(warnings); + match append_edit(src, root, &fresh_datasets(&children)) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + None => { + warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); + return Merged::Failed; + } + Some(ds) => match ds.children().find(|n| is(n, "data")) { + Some(d) => data = Some(d), + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None => { + let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); + let children = fresh_children(warnings); + let element = format!("<{prefix}data>{}", children); + match append_edit(src, ds, &element) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + }, + } + if let Some(data) = data { + match op { + Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::ReplaceData(nodes) => { + // Everything between the start and end tags; `` opens up. Written + // with the prefix the packet binds to the data namespace. + let prefix = xfa_prefix(data); + let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); + let range = data.range(); + match (start_tag_end(src, &range), qname(src, &range)) { + (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { + edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); + } + (Some(tag_end), _) => { + let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + Op::Picture(path, content_type, base64) => { + let mut node = Some(data); + for (name, idx) in path.iter() { + let want = xml_name(name); + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); + } + let Some(n) = node else { return Merged::Unchanged }; + if n.children().any(|c| c.is_element()) { + // A data group, not a leaf: a picture would wipe what's under it. + skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); + return Merged::Failed; + } + let name = qname(src, &n.range()).unwrap_or("value").to_string(); + let (ct, _) = escape(content_type); + let attr = match xfa_prefix(n) { + Some(p) => format!("{p}:contentType=\"{ct}\""), + None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), + }; + edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); + } + Op::Remove(path) => { + // The element at `path`, with the whitespace before it. + let mut node = Some(data); + for (name, idx) in path { + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); + } + if let Some(n) = node { + let r = n.range(); + let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); + edits.push(Edit { start, end: r.end, text: String::new() }); + } + } + } + } + if edits.is_empty() { + return Merged::Unchanged; + } + // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound + // to one node) keep the first. + edits.sort_by_key(|e| (e.start, e.end)); + let mut kept: Vec = Vec::with_capacity(edits.len()); + for e in edits { + if kept.last().is_some_and(|k| e.start < k.end) { + if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { + warnings.push(CONFLICT.into()); + } + continue; + } + kept.push(e); + } + let mut out = src.to_string(); + for e in kept.iter().rev() { + if out.get(e.start..e.end).is_none() { + warnings.push("The XFA data could not be updated; the field values were not written to it".into()); + return Merged::Failed; + } + out.replace_range(e.start..e.end, &e.text); + } + if shift > 0 { + match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { + Some(inner) => out = inner.to_string(), + None => return Merged::Failed, + } + } + Merged::Changed(out) +} + +/// The edits that put `wants` into the `data` element. +fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { + // Element children by local name, per element visited (built once each). + let mut index: HashMap>> = HashMap::new(); + let tree = data.document(); + // Nodes to add, per existing element they go into. + let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); + let mut budget = NewNodes::new(); + for w in wants { + let (escaped, dropped) = escape(&w.text); + let mut node = data; + let mut depth = 0; + let mut have = 0; + for (name, idx) in &w.path { + let kids = index.entry(node.id()).or_insert_with(|| { + let mut by: HashMap> = HashMap::new(); + for c in node.children().filter(|c| c.is_element()) { + by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); + } + by + }); + let same = kids.get(name.as_str()); + match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { + Some(next) => { + node = next; + depth += 1; + } + None => { + have = same.map_or(0, Vec::len); + break; + } + } + } + if depth == w.path.len() { + // The data node exists: give it the value, unless it already holds it. A node + // only wanted to exist (a row instance) is left as it is. + if w.ensure && w.text.is_empty() { + continue; + } + let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if node.children().any(|c| c.is_element()) { + let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + // Ignore existing XML indentation, but not whitespace in the accepted value. + // A skipped mismatch must warn: relayout would otherwise use stale data. + if all.trim() != w.text { + skipped(warnings, STRUCTURED, &w.som); + } + continue; + } + if current == w.text { + continue; + } + match replace_edit(src, node, &escaped) { + Some(e) => edits.push(e), + None => skipped(warnings, STRUCTURED, &w.som), + } + } else { + // Missing: add the rest of the path below the deepest node that exists. An empty + // value needs no node, unless the node itself is wanted (a new instance). + if w.text.is_empty() && !w.ensure { + continue; + } + let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); + if let Some(first) = rest.first_mut() { + first.1 = first.1.saturating_sub(have); + } + let pos = match added.iter().position(|(id, _)| *id == node.id()) { + Some(p) => p, + None => { + added.push((node.id(), Build::default())); + added.len() - 1 + } + }; + let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); + match built { + Some(b) => b.text = w.text.clone(), + None => { + skipped(warnings, TOO_MANY, &w.som); + continue; + } + } + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + } + for (id, build) in added { + let Some(node) = tree.get_node(id) else { continue }; + let mut inner = String::new(); + build.write_children(&mut inner); + match append_edit(src, node, &inner) { + Some(e) => edits.push(e), + None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), + } + } +} + +/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). +pub(super) fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { + let mut current: Option = None; + for op in ops { + match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { + Merged::Changed(t) => current = Some(t), + Merged::Unchanged => {} + // Later steps still apply to what merged so far; the reason is in the warnings. + Merged::Failed => {} + } + } + current +} + +/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that +/// isn't is not written: better the old data than an unreadable packet. +pub(super) fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { + if text.len() > MAX_PACKET_WRITE { + warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); + return false; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() + || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); + if !ok { + warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); + } + ok +} diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs new file mode 100644 index 0000000000..78503bb53c --- /dev/null +++ b/crates/xfa/src/data/passwords.rs @@ -0,0 +1,194 @@ +use super::import::datasets_packet; +use super::merge::Op; +use super::path::{data_path, parse_datasets}; +use super::values::{Want, path_of, text_of, with_template}; +use super::write::{datasets_stream_ref, rewrite}; +use super::*; + +/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, +/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm +/// dictionary. +fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { + const PASSWORD_FLAG: i64 = 1 << 13; + let Some(root) = doc.root() else { return Vec::new() }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { + return Vec::new(); + }; + struct AcroWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + out: Vec<(String, String)>, + } + impl AcroWalk<'_> { + fn walk( + &mut self, + items: &[Object], + parent_name: &str, + inherited_ft: Option<&[u8]>, + inherited_ff: i64, + inherited_dv: Option<&str>, + depth: usize, + ) { + if depth > 32 { + return; + } + for item in items { + let Some(r) = item.as_ref() else { continue }; + if !self.seen.insert(r) { + continue; + } + let obj = self.doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let ft = d.name(b"FT").or(inherited_ft); + let ff = d.int(b"Ff").unwrap_or(inherited_ff); + let dv_own = text_of(self.doc, d, b"DV"); + let dv = dv_own.as_deref().or(inherited_dv); + let t = text_of(self.doc, d, b"T"); + let full_name = match (&t, parent_name.is_empty()) { + (Some(seg), true) => seg.clone(), + (Some(seg), false) => format!("{parent_name}.{seg}"), + (None, _) => parent_name.to_string(), + }; + let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); + if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { + let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); + if !som.is_empty() { + self.out.push((som, dv.unwrap_or_default().to_string())); + } + } + if !kids.is_empty() { + self.walk(&kids, &full_name, ft, ff, dv, depth + 1); + } + } + } + } + let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + w.walk(&top, "", None, 0, None, 0); + w.out +} + +/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their +/// defaults (or empty when they have no default). +fn password_dataset_wants(doc: &Document) -> Vec { + if datasets_stream_ref(doc).is_none() { + return Vec::new(); + } + let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&packet) else { return Vec::new() }; + with_template(doc, None, |tpl| { + let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(t) = tpl { + struct Walk<'a> { + tpl: &'a Template, + data: &'a DataNode, + nodes: usize, + seen: &'a mut std::collections::HashSet, + out: &'a mut Vec<(String, DataPath, String)>, + } + impl Walk<'_> { + fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), + Some(n) => format!("{n}[{index}]"), + None => parent.to_string(), + } + } + fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { + if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { + return; + } + let sibs = SomIndices::of(children); + for (i, n) in children.iter().enumerate() { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + let sib = sibs.at(i); + match n { + Node::Subform(sf) => { + let from_data = match &sf.common.name { + Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); + for k in 0..instances { + if k > 0 { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + } + let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); + self.walk(&sf.children, &som, depth + 1); + } + } + Node::Area(a) => { + let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); + self.walk(&a.children, &som, depth + 1); + } + Node::Field(f) if f.ui == Ui::PasswordEdit => { + let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); + let path = data_path(self.tpl, &som); + if !path.is_empty() && self.seen.insert(path.clone()) { + let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); + self.out.push((som, path, default)); + } + } + _ => {} + } + } + } + } + let root = Node::Subform(Box::new(t.root.clone())); + let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; + w.walk(std::slice::from_ref(&root), "", 0); + if let Some(ps) = &t.root.page_set { + for (i, area) in ps.areas.iter().enumerate() { + let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); + w.walk(&area.items, &som, 1); + } + } + } + for (som, default) in acro_password_fields(doc) { + let path = path_of(tpl, &som); + if !path.is_empty() && seen.insert(path.clone()) { + candidates.push((som, path, default)); + } + } + candidates + .into_iter() + .filter_map(|(som, path, default)| { + let cur = data.value_at(&path)?; + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + }) + .collect() + }) +} + +/// Whether any XFA password field (`` or an AcroForm text field with the Password +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +pub fn has_password_values(doc: &Document) -> bool { + !password_dataset_wants(doc).is_empty() +} + +/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default +/// where the field has one, else emptying the element), replacing the datasets stream in place +/// (#3944). Returns how many password fields were cleared in the datasets. +pub fn clear_password_values(doc: &mut Document) -> Result { + let wants = password_dataset_wants(doc); + if wants.is_empty() { + return Ok(0); + } + let count = wants.len(); + let reuse = datasets_stream_ref(doc); + let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; + if !report.written || !password_dataset_wants(doc).is_empty() { + let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); + return Err(XfaError::Malformed(reason.to_string())); + } + Ok(count) +} diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs new file mode 100644 index 0000000000..90cc4346f2 --- /dev/null +++ b/crates/xfa/src/data/path.rs @@ -0,0 +1,156 @@ +use super::*; + +fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { + let name = n.tag_name().name().to_string(); + let children: Vec = + if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; + // The text of a leaf is its value; a container's own text is whitespace. + let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; + DataNode { name, text, children } +} + +/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a +/// tree whose root is that element. +pub fn parse_datasets(xdp: &str) -> Option { + if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { + return None; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; + let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; + let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; + Some(to_node(data, 0)) +} + +/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. +/// Unnamed containers (`#subform`) have no data node. +pub fn som_to_path(som: &str) -> DataPath { + som.split('.') + .filter_map(|seg| { + let seg = seg.trim(); + if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { + return None; + } + let (name, idx) = match seg.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), + None => (seg, 0), + }; + Some((name.to_string(), idx.min(100_000))) + }) + .collect() +} + +/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. +/// An area is a named container in the form, so it is part of SOM expressions, but only +/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the +/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; +/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. +pub fn data_path(tpl: &Template, som: &str) -> DataPath { + let mut out = DataPath::new(); + // The template children the next segment is looked up in; `None` until the root matched, + // and again once a segment is not a container the template has. + let mut level: Option<&[Node]> = None; + for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { + let found = match level { + None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), + None => None, + Some(children) => match named(children, &name, 0) { + Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), + Some(Node::Area(a)) => Some(Container::Area(&a.children)), + _ => None, + }, + }; + match found { + Some(Container::Area(children)) => level = Some(children), + Some(Container::Children(children)) => { + level = Some(children); + out.push((name, index)); + } + None => { + level = None; + out.push((name, index)); + } + } + } + out +} + +enum Container<'a> { + Children(&'a [Node]), + /// Contents bind under the area's parent. + Area(&'a [Node]), +} + +/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no +/// SOM segment of their own). +fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), + Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), + _ => None, + }) + }) +} + +/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its +/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating +/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, +/// as [`named`] does. +fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + let sibs = SomIndices::of(nodes); + let mut repeating = None; + for (i, n) in nodes.iter().enumerate() { + if n.common().name.as_deref() != Some(name) { + continue; + } + let sib = sibs.at(i); + if sib == index { + return Some(n); + } + if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { + repeating = Some(n); + } + } + repeating.or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), + Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), + _ => None, + }) + }) +} + +/// The template field the object at a SOM path is an instance of, each segment's number counted +/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is +/// a button of an exclusion group (whose buttons take their value from the group). +pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { + let path = som_to_path(som); + let ((root, _), rest) = path.split_first()?; + if tpl.root.common.name.as_deref() != Some(root.as_str()) { + return None; + } + let ((last, last_index), inner) = rest.split_last()?; + let mut children: &[Node] = &tpl.root.children; + for (name, index) in inner { + children = match named_at(children, name, *index, 0)? { + Node::Subform(sf) => &sf.children, + Node::Area(a) => &a.children, + _ => return None, + }; + } + match named_at(children, last, *last_index, 0)? { + Node::Field(f) => Some(f), + _ => None, + } +} + +pub(super) fn path_som(path: &DataPath) -> String { + path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") +} diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs new file mode 100644 index 0000000000..41fb6644e9 --- /dev/null +++ b/crates/xfa/src/data/values.rs @@ -0,0 +1,192 @@ +use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; +use super::path::{data_path, parse_datasets, som_to_path}; +use super::*; + +/// What a field holds, for writing the datasets. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldData { + Text(String), + /// Checked or not. + Check(bool), + /// The selected button's on value, if any. + Radio(Option), + /// Nothing to write (buttons, signatures). + None, +} + +/// One field of the AcroForm, as the engine sees it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDatum { + /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). + pub obj: ObjRef, + /// The fully qualified field name (a static form's SOM path). + pub name: String, + pub data: FieldData, +} + +pub(super) fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) +} + +/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. +fn som_of(doc: &Document, f: &FieldDatum) -> String { + doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) +} + +/// The datasets text for a radio group. The AcroForm value is an appearance-state name +/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { + let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { + return String::new(); + }; + let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; + let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); + for kid in kids { + let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + return original.unwrap_or_else(|| selected.to_string()); + } + } + selected.to_string() +} + +/// The on and off item values of a check box (`/PCItems`), or the widget's on state. +fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { + let d = doc.get(f.obj); + let items: Vec = d + .as_dict() + .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) + .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) + .unwrap_or_default(); + let on = items.first().cloned().unwrap_or_else(|| "1".into()); + let off = items.get(1).cloned().unwrap_or_default(); + (on, off) +} + +/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). +fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { + let d = doc.get(f.obj); + let aa = doc.resolve(d.as_dict()?.get(b"AA")?); + let fa = doc.resolve(aa.as_dict()?.get(b"F")?); + let js = text_of(doc, fa.as_dict()?, b"JS")?; + let i = js.find("AFDate_FormatEx(")?; + let rest = &js[i + "AFDate_FormatEx(".len()..]; + let q = rest.find('"')?; + let body = &rest[q + 1..]; + Some(body[..body.find('"')?].to_string()) +} + +/// A value to write: where (the field's SOM path, and the data path it binds to) and what. +pub(super) struct Want { + pub(super) som: String, + pub(super) path: DataPath, + pub(super) text: String, + /// Create the node even for an empty value (an instance of a repeating subform). + pub(super) ensure: bool, +} + +/// `tpl`, or the document's own template when none is given (parsed once). +pub(super) fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { + match tpl { + Some(t) => f(Some(t)), + None => { + let parsed = crate::script::template_of(doc).ok(); + f(parsed.as_ref()) + } + } +} + +/// [`data_path`] when the template is known, else [`som_to_path`]. +pub(super) fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { + match tpl { + Some(t) => data_path(t, som), + None => som_to_path(som), + } +} + +/// The values the datasets should hold for `fields`, by data path: dates in each field's data +/// picture (or ISO), check and radio states, choice values as saved. +pub(super) fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + if path.is_empty() { + continue; + } + let text = match &f.data { + FieldData::Text(t) => match date_pattern_of(doc, f) { + Some(p) => match pattern_to_iso(t, &p) { + // The date goes into the data in the field's data picture, or as an ISO date. + Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), + None => t.clone(), + }, + None => t.clone(), + }, + FieldData::Check(on) => { + let (on_item, off_item) = items_of(doc, f); + if *on { on_item } else { off_item } + } + FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), + FieldData::None => continue, + }; + out.push(Want { som, path, text, ensure: false }); + } + out +} + +/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for +/// fields the data mentions. +pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + read_values_with(doc, None, fields) +} + +/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the +/// document's. +pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + with_template(doc, tpl, |t| values_at(doc, t, fields)) +} + +/// The values the document's datasets hold for `fields`, read in each field's data picture and +/// shown in its own pattern; only fields the data mentions. +fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + let Some(text) = data.value_at(&path) else { continue }; + let text = text.as_str(); + let value = match &f.data { + FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { + Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), + None => text.to_string(), + }), + FieldData::Check(_) => { + let (on, _) = items_of(doc, f); + FieldData::Check(is_on(text, &on)) + } + FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), + FieldData::None => continue, + }; + out.push((f.name.clone(), value)); + } + out +} + +/// Does a data value mean "checked" for a button whose on value is `on`? +pub fn is_on(text: &str, on: &str) -> bool { + let t = text.trim(); + if t == on { + return true; + } + !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() +} diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs new file mode 100644 index 0000000000..282dd33ef5 --- /dev/null +++ b/crates/xfa/src/data/write.rs @@ -0,0 +1,264 @@ +use super::build::{MAX_INDEX, build_children, fresh_datasets}; +use super::merge::{Op, Within, merge_all, readable}; +use super::path::path_som; +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +/// What [`write_datasets`] did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DatasetsWrite { + /// The datasets packet changed (a new revision of it was added to the document). + pub written: bool, + /// Values that could not be written, and why. + pub warnings: Vec, + /// The datasets stream written, when one was. + pub stream: Option, +} + +/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. +fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { + let s = doc.resolve(o); + let Object::Stream(s) = &*s else { + warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); + return None; + }; + let bytes = match s.decoded_within(64 << 20) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); + return None; + } + }; + let decoded = crate::packets::decode(&bytes); + if decoded.is_none() { + warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); + } + decoded +} + +/// Write the fields' values into the datasets packet, keeping all the data no field binds to. +/// Nothing is written when the document has no XFA entry or the data already holds the values; +/// values that can't be written are reported, never silently dropped. +pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { + write_datasets_reusing(doc, fields, None) +} + +/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream +/// (written earlier in the same edit) rather than adding another. +pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { + write_datasets_with(doc, None, fields, reuse) +} + +/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says +/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. +pub fn write_datasets_with( + doc: &mut Document, + tpl: Option<&Template>, + fields: &[FieldDatum], + reuse: Option, +) -> Result { + let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); + rewrite(doc, &[Op::Values(&wants)], reuse) +} + +/// Make sure the data has at least `count` instances of `name` under `parent` (a script added +/// rows: the layout repeats the subform once per data instance). +pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { + write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) +} + +/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a +/// script gave a value), adding the node as needed. +pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { + write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) +} + +/// Remove the data node at `path` (a script removed a row). +pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { + write_data_ops(doc, &[DataOp::Remove(path.clone())], None) +} + +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script +/// event changed becomes a single new datasets stream. When the packet is already the stream +/// `reuse` (written earlier for the same event), that stream is replaced in place instead of +/// a new one being added. The stream written is in the report. +pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { + // Runs of values (and instances) merge in one pass; a value set twice keeps the last. + let mut steps: Vec> = Vec::new(); + let mut removes: Vec> = Vec::new(); + let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); + let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { + if !run.is_empty() { + let mut seen = std::collections::HashSet::new(); + let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); + kept.reverse(); + steps.push(kept); + removes.push(None); + } + }; + for op in ops { + match op { + DataOp::Value { path, text } if !path.is_empty() => { + // An empty value clears an existing node and needs no new one. + run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); + } + DataOp::Instances { parent, name, count } => { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; + let mut path = parent.clone(); + path.push((name.clone(), k)); + run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); + } + } + DataOp::Remove(path) if !path.is_empty() => { + flush(&mut run, &mut steps, &mut removes); + queued.clear(); + steps.push(Vec::new()); + removes.push(Some(path.clone())); + } + _ => {} + } + } + flush(&mut run, &mut steps, &mut removes); + let plan: Vec = steps + .iter() + .zip(&removes) + .map(|(w, r)| match r { + Some(p) => Op::Remove(p), + None => Op::Values(w), + }) + .collect(); + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) +} + +/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream +/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. +fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { + let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); + match old.and_then(Object::as_ref) { + Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { + doc.set(r, stream); + r + } + _ => doc.add(stream), + } +} + +/// Longest packet text written (the packets together are read up to 64 MB). +pub(super) const MAX_PACKET_WRITE: usize = 48 << 20; + +pub(super) fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { + let mut report = DatasetsWrite::default(); + if ops.is_empty() { + return Ok(report); + } + let Some(root) = doc.root() else { return Ok(report) }; + let catalog = doc.get(root); + let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; + let acro_ref = acro_obj.as_ref(); + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; + let warnings = &mut report.warnings; + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut items = items.clone(); + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at { + Some(i) => { + let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; + let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; + let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; + if !readable(&t, Within::Packet, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); + report.stream = Some(r); + if let Some(slot) = items.get_mut(i + 1) { + *slot = Object::Ref(r); + } + } + None => { + // No datasets packet yet: made from the first values, then the rest merged + // in; before the postamble, or last. + let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; + let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; + let fresh = fresh_datasets(&build_children(wants, warnings)); + let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); + if !readable(&text, Within::Packet, warnings) { + return Ok(report); + } + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); + report.stream = Some(r); + let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); + items.insert(pos, Object::Ref(r)); + items.insert(pos, Object::String(PdfString::text("datasets"))); + } + } + acro.set(b"XFA".to_vec(), Object::Array(items)); + } + Object::Stream(_) => { + let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; + let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + if !readable(&new, Within::Xdp, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); + report.stream = Some(r); + acro.set(b"XFA".to_vec(), Object::Ref(r)); + } + _ => return Ok(report), + } + match acro_ref { + Some(ar) => doc.set(ar, Object::Dict(acro)), + None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, + } + report.written = true; + Ok(report) +} + +/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or +/// the single `/XFA` stream), when `doc` has one. +pub(super) fn datasets_stream_ref(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; + let xfa = acro.as_dict()?.get(b"XFA")?; + match &*doc.resolve(xfa) { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; + items.get(at + 1)?.as_ref() + } + Object::Stream(_) => xfa.as_ref(), + _ => None, + } +} From 3da49321f2ac00ee12531ce88461f19bf0c35fd8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:46:36 +0000 Subject: [PATCH 058/175] refactor(xfa): split layout.rs into layout/ New modules: items, helpers, pages, flow, measure, draw, field. --- crates/xfa/src/layout.rs | 1930 +----------------------------- crates/xfa/src/layout/common.rs | 211 ++++ crates/xfa/src/layout/draw.rs | 324 +++++ crates/xfa/src/layout/field.rs | 305 +++++ crates/xfa/src/layout/flow.rs | 310 +++++ crates/xfa/src/layout/items.rs | 145 +++ crates/xfa/src/layout/measure.rs | 366 ++++++ crates/xfa/src/layout/pages.rs | 280 +++++ 8 files changed, 1955 insertions(+), 1916 deletions(-) create mode 100644 crates/xfa/src/layout/common.rs create mode 100644 crates/xfa/src/layout/draw.rs create mode 100644 crates/xfa/src/layout/field.rs create mode 100644 crates/xfa/src/layout/flow.rs create mode 100644 crates/xfa/src/layout/items.rs create mode 100644 crates/xfa/src/layout/measure.rs create mode 100644 crates/xfa/src/layout/pages.rs diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 670d43791a..3d443980b0 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -20,6 +20,20 @@ use crate::model::{ }; use crate::text::{self, Block, Face, Span}; +mod draw; +mod field; +mod flow; +mod common; +mod items; +mod measure; +mod pages; + +use common::collect_values; +pub(crate) use common::date_pattern; +pub use common::formatted_value; +pub(crate) use items::UNDERLINE_DROP; +pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetBorder, WidgetKind}; + /// Most pages a form may lay out to. pub const MAX_PAGES: usize = 500; /// Most items (text spans, lines, widgets…) across all pages. @@ -40,150 +54,6 @@ const MAX_IMAGE_BYTES: usize = 256 << 20; /// Placeholder for the page count in text laid out before the count is known. const PAGE_COUNT_MARK: char = '\u{E000}'; -/// How far below its baseline an underline is drawn, in the text's size: an underline is the -/// level line pushed just before its span, this far below it (the PDF writer tells it apart from -/// a rule by this). -pub(crate) const UNDERLINE_DROP: f64 = 0.1; - -#[derive(Clone, Debug, PartialEq)] -pub enum Item { - Fill { - rect: Rect, - color: [f64; 3], - }, - Line { - from: (f64, f64), - to: (f64, f64), - width: f64, - color: [f64; 3], - dashed: bool, - }, - /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. - Rounded(Box), - Text(Span), - Image { - rect: Rect, - data: Arc>, - content_type: String, - }, - Widget(Box), -} - -/// A box with rounded corners ([`Item::Rounded`]). -#[derive(Clone, Debug, PartialEq)] -pub struct Rounded { - pub rect: Rect, - /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's - /// shorter side. - pub radii: [f64; 4], - /// Corners whose curve goes into the box rather than around it, in the same order. - pub inverted: [bool; 4], - pub fill: Option<[f64; 3]>, - /// The edge: its width, colour and whether it is dashed. - pub edge: Option<(f64, [f64; 3], bool)>, - /// A page break cut the box here: that side has no edge, and its corners are square. - pub open_top: bool, - pub open_bottom: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WidgetKind { - Text, - /// A text field whose characters are shown as asterisks. - Password, - /// A drop-down (`list_box` false) or list box: `options` are (saved value, shown text). - Choice { - options: Vec<(String, String)>, - list_box: bool, - multi: bool, - editable: bool, - }, - /// A signature field, to be signed in the app. - Signature, - /// An image field: a push button that picks a picture (the picture itself is page content). - Image, - /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). - Date(String), - CheckBox { - on: String, - round: bool, - }, - Radio { - group: String, - on: String, - round: bool, - }, - Button { - caption: String, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderShape { - None, - Full, - Underline, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct WidgetBorder { - pub color: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - pub shape: BorderShape, - pub width: f64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Action { - Reset, - Print, - SaveAs, - Url(String), - /// Any other click script: run by the XFA scripting engine. - Script(String), -} - -/// An interactive field to create as an AcroForm widget. -#[derive(Clone, Debug, PartialEq)] -pub struct Widget { - pub rect: Rect, - pub kind: WidgetKind, - /// The PDF field name (unique in the form, no periods). - pub name: String, - /// The template's scripting object model path, for data round trips. - pub som: String, - pub tooltip: Option, - pub face: Face, - pub h_align: HAlign, - pub multiline: bool, - pub max_chars: Option, - pub read_only: bool, - pub border: WidgetBorder, - /// The field's value: saved (bound) values for choice lists, newline-separated when several. - pub value: Option, - /// The template's default value (what a reset restores); data values are not defaults. - pub default: Option, - pub action: Option, - /// Check boxes: the on and off values of the data; radio buttons: the on value. - pub items: Vec, - /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). - pub no_scroll: bool, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub items: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Form { - pub pages: Vec, - pub fields: usize, - pub warnings: Vec, -} - /// Inherited text defaults and the SOM path of the enclosing containers. #[derive(Clone)] struct Ctx { @@ -358,1775 +228,3 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result thread_local! { pub(crate) static EMBED_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } - -fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for n in nodes { - match n { - Node::Field(f) => { - if let Some(id) = &f.common.id { - out.insert(id.clone(), f.value.plain().unwrap_or_default()); - } - } - Node::Subform(s) => { - collect_values(&s.children, out, depth + 1); - if let Some(ps) = &s.page_set { - for a in &ps.areas { - collect_values(&a.items, out, depth + 1); - } - } - } - Node::Area(a) => collect_values(&a.children, out, depth + 1), - Node::ExclGroup(_) | Node::Draw(_) => {} - } - } -} - -fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { - let mut v = v; - if let Some(m) = min { - v = v.max(m); - } - if let Some(m) = max - && m > 0.0 - { - v = v.min(m); - } - v.max(0.0) -} - -/// Does `node` flow (may be split across pages) rather than being placed as one block? -fn splittable(node: &Node) -> bool { - matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) -} - -/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` -/// for fills and lines, which are cut at the break. -fn solid_extent(item: &Item) -> Option<(f64, f64)> { - match item { - Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), - Item::Image { rect, .. } => Some((rect.y, rect.bottom())), - Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), - Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, - } -} - -/// `item` as the part above `cut` and the part below: solid items go whole to the side their -/// top is on, fills and lines crossing the cut are cut in two. -fn divide(item: Item, cut: f64) -> (Option, Option) { - if let Some((top, _)) = solid_extent(&item) { - return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; - } - match item { - Item::Fill { rect, color } => { - if rect.bottom() <= cut + 0.01 { - (Some(Item::Fill { rect, color }), None) - } else if rect.y >= cut - 0.01 { - (None, Some(Item::Fill { rect, color })) - } else { - let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); - let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); - (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) - } - } - Item::Line { from, to, width, color, dashed } => { - let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; - if lo.1 >= cut { - (None, Some(Item::Line { from, to, width, color, dashed })) - } else if hi.1 <= cut { - (Some(Item::Line { from, to, width, color, dashed }), None) - } else { - // lo.1 < cut < hi.1, so the divisor is positive. - let t = (cut - lo.1) / (hi.1 - lo.1); - let mid = (lo.0 + (hi.0 - lo.0) * t, cut); - (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) - } - } - Item::Rounded(r) => { - if r.rect.bottom() <= cut + 0.01 { - (Some(Item::Rounded(r)), None) - } else if r.rect.y >= cut - 0.01 { - (None, Some(Item::Rounded(r))) - } else { - // Each part keeps the corners on its side, rounded no more than its own height - // allows, and is open where it was cut. - let [tl, tr, br, bl] = r.radii; - let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); - let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); - let fit = |v: f64, h: f64| v.min(h.max(0.0)); - let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; - let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; - (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) - } - } - other => (Some(other), None), - } -} - -/// `item` moved by (`dx`, `dy`). -fn moved(mut item: Item, dx: f64, dy: f64) -> Item { - match &mut item { - Item::Fill { rect, .. } | Item::Image { rect, .. } => { - rect.x += dx; - rect.y += dy; - } - Item::Rounded(r) => { - r.rect.x += dx; - r.rect.y += dy; - } - Item::Line { from, to, .. } => { - *from = (from.0 + dx, from.1 + dy); - *to = (to.0 + dx, to.1 + dy); - } - Item::Text(s) => { - s.x += dx; - s.baseline += dy; - } - Item::Widget(w) => { - w.rect.x += dx; - w.rect.y += dy; - } - } - item -} - -fn is_row(node: &Node) -> bool { - matches!(node, Node::Subform(s) if s.layout == Layout::Row) -} - -/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. -pub(crate) fn date_pattern(picture: Option<&str>) -> String { - picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) -} - -/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the -/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into -/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a -/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO -/// date: the raw value stands then. -pub fn formatted_value(picture: &str, raw: &str) -> Option { - let first = picture.split('|').next()?.trim(); - let rest = first.strip_prefix("date")?; - if !rest.starts_with(['{', '(']) { - return None; - } - let pattern = date_pattern(Some(first)); - // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would - // leave them as letters: the raw value stands then. - let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d"); - if !crate::data::date_tokens(&pattern).iter().all(shows) { - return None; - } - // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands. - let b = raw.as_bytes(); - let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }); - if !iso { - return None; - } - let shown = iso_to_pattern(raw, &pattern); - (shown != raw).then_some(shown) -} - -/// The native PDF action a button's click script amounts to (a reset, print, save or link), -/// else the script itself to run; `None` without a click script. -fn button_action(scripts: &[Script]) -> Option { - let s = scripts.iter().find(|s| s.activity == "click")?; - let t = &s.text; - // Only a single-statement idiom becomes a native PDF action; anything else is run by the - // XFA scripting engine. - let one = t.trim().trim_end_matches(';'); - let single = !one.contains(';') && !one.contains('\n'); - if !single { - return (!t.trim().is_empty()).then(|| Action::Script(t.clone())); - } - if t.contains("resetData") { - Some(Action::Reset) - } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { - Some(Action::Print) - } else if t.contains("SaveAs") || t.contains("execMenuItem(\"Save\")") { - Some(Action::SaveAs) - } else if let Some(i) = t.find("launchURL(") { - let rest = &t[i + "launchURL(".len()..]; - let q = rest.find(['"', '\''])?; - let quote = rest[q..].chars().next()?; - let body = &rest[q + 1..]; - let end = body.find(quote)?; - Some(Action::Url(body[..end].to_string())) - } else if t.trim().is_empty() { - None - } else { - Some(Action::Script(t.clone())) - } -} - -/// A PDF field name: no periods (they separate hierarchy levels), no control characters. -fn clean_name(name: &str) -> String { - let s: String = name.chars().filter(|c| !c.is_control()).map(|c| if c == '.' { '_' } else { c }).collect(); - let s = s.trim().to_string(); - if s.is_empty() { "field".into() } else { s } -} - -impl Layouter<'_> { - fn warn_once(&mut self, msg: impl Into) { - let m = msg.into(); - if self.warned.insert(m.clone()) && self.warnings.len() < 100 { - self.warnings.push(m); - } - } - - fn page_no(&self) -> usize { - self.pages.len() - } - - /// The text an embedded field `id` shows, at most `max` bytes of a field value (the page - /// number and count are a few bytes; the text layout cuts them). - fn embed(&self, id: &str, max: usize) -> String { - let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { - Some(PageRole::Number) => self.page_no().to_string(), - Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), - None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(), - }; - #[cfg(test)] - EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64))); - v - } - - /// `TooLarge` once the measurement budget is spent (sizes measured after that are guesses). - fn check_measures(&self) -> Result<(), XfaError> { - if self.measures.get() > MAX_MEASURES { - return Err(XfaError::TooLarge("the form is nested too deeply to lay out".into())); - } - Ok(()) - } - - fn push(&mut self, item: Item) -> Result<(), XfaError> { - self.check_measures()?; - self.items += 1; - if self.items > MAX_ITEMS { - return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); - } - if let Some(p) = self.pages.last_mut() { - p.items.push(item); - } - Ok(()) - } - - fn push_block(&mut self, b: Block) -> Result<(), XfaError> { - for s in b.spans { - if s.face.underline { - let w = s.face.width(&s.text); - let y = s.baseline + s.face.size * UNDERLINE_DROP; - self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; - } - if !s.text.trim().is_empty() { - self.push(Item::Text(s))?; - } - } - Ok(()) - } - - // ───────────────────────────────────────────────────────────────────────── pages - - fn new_page(&mut self) -> Result<(), XfaError> { - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); - self.new_page_of(idx) - } - - /// Once the body has been laid out, give every page area that has been used fewer times than - /// its `occur min` the pages it still needs, in template order, after the body's pages. - fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { - let areas = Rc::clone(&self.areas); - for (idx, area) in areas.iter().enumerate() { - while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { - self.new_page_of(idx)?; - } - } - Ok(()) - } - - /// Start a page from page area `idx`. - fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { - if self.pages.len() >= MAX_PAGES { - return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); - } - let areas = Rc::clone(&self.areas); - let Some(area) = areas.get(idx) else { - return Err(XfaError::Malformed("the template has no page area".into())); - }; - if let Some(u) = self.uses.get_mut(idx) { - *u += 1; - } - self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); - let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); - self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); - // Master page content: positioned draws and fields. - let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; - let page_rect = Rect::new(0.0, 0.0, area.width, area.height); - let sibs = SomIndices::of(&area.items); - for (i, item) in area.items.iter().enumerate() { - self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; - } - Ok(()) - } - - /// Move to the next content area, or the next page. - fn next_area(&mut self) -> Result<(), XfaError> { - if let Some(c) = &self.cur - && let Some(area) = self.areas.get(c.area) - && let Some(next) = area.content.get(c.content + 1).copied() - { - let i = c.content + 1; - self.cur = Some(Cursor { area: c.area, content: i, rect: next, y: next.y, placed: false }); - return Ok(()); - } - self.new_page() - } - - /// The current content area, how far down it the flow is, and whether anything has been - /// placed in it; before the first page, an empty letter-size page from its top. - fn cursor(&self) -> (Rect, f64, bool) { - match &self.cur { - Some(c) => (c.rect, c.y, c.placed), - None => (Rect::new(0.0, 0.0, 612.0, 792.0), 0.0, false), - } - } - - /// Where the flow is now ([`FlowMark`]); `None` before the first page. - fn flow_mark(&self) -> Option { - let c = self.cur.as_ref()?; - Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) - } - - /// Whether the flow is still in the content area `m` was taken in. - fn on_area(&self, m: &FlowMark) -> bool { - self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) - } - - /// Whether page `page` (an index) got anything from item `at` on. - fn has_items_from(&self, page: usize, at: usize) -> bool { - self.pages.get(page).is_some_and(|p| p.items.len() > at) - } - - /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as - /// another viewer breaks such a section on a synthetic form: each one's part here ends after - /// its content and its bottom margin (the innermost first, each inside the next), the flow - /// moves to the next content area, and each starts a new part there after its top margin - /// (the outermost first). The top margins take only what the `need` points of what comes next - /// leave of the new area: a child that fits a fresh area but not after the margins would - /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a - /// child's own page break), which breaks the subforms around it the same way. - fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { - let (rect, mut y, _) = self.cursor(); - let page = self.pages.len().saturating_sub(1); - let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); - let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); - for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { - y = (y + f.margin.bottom).min(rect.bottom()); - if !here { - f.lost = true; - } else if filled && y - f.part.y >= 0.01 { - f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); - } - } - if to_page { - self.new_page()?; - } else { - self.next_area()?; - } - let (rect, y, _) = self.cursor(); - let mut room = (rect.bottom() - y - need).max(0.0); - for i in 0..self.frames.len() { - let Some(mark) = self.flow_mark() else { break }; - let Some(f) = self.frames.get_mut(i) else { break }; - f.part = mark; - // `max` before `min`: a NaN margin counts as none and leaves `room` a number. - let top = f.margin.top.max(0.0).min(room); - room = (room - top).max(0.0); - self.advance(top); - } - Ok(()) - } - - /// Close flowed subform `f` once its content is placed: its last part ends where the flow - /// is, and its border (edges and fill) is drawn round each part, closed on every side, under - /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) - /// is drawn without its border, and the layout says so. - fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { - let Some(b) = f.border.take() else { return Ok(()) }; - if b.fill().is_none() && b.visible_edges() == [false; 4] { - return Ok(()); - } - let (area, end, _) = self.cursor(); - // The bottom margin stays in the content area, as at a break; content that itself ran - // past it has already cost the section its border ([`Self::overflowed`]). - let end = end.min(area.bottom()).max(end - f.margin.bottom); - let page = self.pages.len().saturating_sub(1); - if !self.on_area(&f.part) { - f.lost = true; - } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { - f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); - } - if f.lost { - self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); - return Ok(()); - } - // The last part first: a part's place on its page stays where it was found. - for (page, at, rect) in f.parts.into_iter().rev() { - self.insert_border(&b, rect, page, at)?; - } - Ok(()) - } - - /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item - /// `at` on. - fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { - let before = self.pages.last().map_or(0, |p| p.items.len()); - self.draw_border(b, rect)?; - let drawn: Vec = match self.pages.last_mut() { - Some(p) => { - let from = before.min(p.items.len()); - p.items.drain(from..).collect() - } - None => return Ok(()), - }; - if let Some(target) = self.pages.get_mut(page) { - let at = at.min(target.items.len()); - target.items.splice(at..at, drawn); - } - Ok(()) - } - - /// Move the cursor down (margins and spacing: the area still counts as empty). - fn advance(&mut self, dy: f64) { - if let Some(c) = &mut self.cur { - c.y += dy.max(0.0); - } - } - - /// Move past something that was placed. - fn place(&mut self, dy: f64) { - if let Some(c) = &mut self.cur { - c.y += dy.max(0.0); - c.placed = true; - } - let (_, y, _) = self.cursor(); - self.overflowed(y); - } - - /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms - /// it is inside can't hold it in a part, and are drawn without their border. - fn overflowed(&mut self, bottom: f64) { - let (rect, _, _) = self.cursor(); - if bottom > rect.bottom() + 0.01 { - for f in &mut self.frames { - f.lost = true; - } - } - } - - /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow - /// leader): stay, or move to the next content area / page. Returns whether a break happened. - /// Something that fits a fresh area breaks the flowed subforms it is in with it - /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, - /// and those subforms are drawn without their border ([`Self::finish_frame`]). - fn make_room(&mut self, h: f64, after: f64) -> Result { - let (rect, y, placed) = self.cursor(); - if y + h <= rect.bottom() + 0.01 || !placed { - return Ok(false); - } - if h <= rect.h + 0.01 { - self.break_frames(h + after, false)?; - } else { - self.next_area()?; - } - Ok(true) - } - - /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, - /// in the next when it fits there, and across as many as it takes when it is taller than a - /// content area (a positioned or fixed-height subform holding more than a page). - fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { - self.make_room(h, 0.0)?; - } - let (rect, y, _) = self.cursor(); - let first = self.pages.last().map_or(0, |p| p.items.len()); - self.leaves.clear(); - self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; - let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); - // What was drawn, when it is lower than the block's own height says (content that - // outgrows a fixed height) and ends off the page. - let drawn = self - .pages - .last() - .and_then(|p| p.items.get(first..)) - .unwrap_or_default() - .iter() - .filter_map(solid_extent) - .map(|e| e.1) - .fold(y + h, f64::max); - if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { - self.leaves.clear(); - self.place(h); - return Ok(()); - } - self.split_block(first, y, drawn) - } - - /// Remember that the items the last page got since `start` are one field or draw at - /// `rect`, to be kept together by a page break. - fn keep_whole(&mut self, start: usize, rect: Rect) { - let end = self.pages.last().map_or(0, |p| p.items.len()); - if end > start { - self.leaves.push((start, end, rect.y, rect.bottom())); - } - } - - fn item_count(&self) -> usize { - self.pages.last().map_or(0, |p| p.items.len()) - } - - /// Spread the items the last page holds from index `first` on (a block drawn from `top` to - /// `end`, past the bottom of its content area) over this and the following content areas. - /// Breaks fall between text, pictures and fields, never through one that fits in an area; - /// fills and lines are cut at the break. - fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { - let items: Vec = match self.pages.last_mut() { - Some(p) if first <= p.items.len() => p.items.split_off(first), - _ => return Ok(()), - }; - // Each item with the extent of the field or draw it belongs to: such a leaf moves - // whole (its border with its text), unless it is taller than a content area. - let area_h = self.cursor().0.h; - let mut extents: Vec> = vec![None; items.len()]; - for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { - if bottom - top <= area_h + 0.01 - && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) - { - slots.fill(Some((top, bottom))); - } - } - let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); - let origin_x = self.cursor().0.x; - // An item's place on the current page is its drawn place moved by (dx, -shift). - let (mut dx, mut shift) = (0.0, 0.0); - let mut seg_top = top; - let mut first_segment = true; - loop { - let (rect, _, placed) = self.cursor(); - let limit = rect.bottom() + shift; - let mut cut = limit; - if rect.h < 1.0 { - // No room to split into: leave the rest here. - cut = f64::INFINITY; - } else { - // The lowest break at or above the bottom that crosses no solid item: going - // through them from the lowest top up, a break moved to an item's top can't - // cross one already passed. - let mut solid: Vec<(f64, f64)> = - rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); - solid.sort_by(|a, b| b.0.total_cmp(&a.0)); - for (t, b) in solid { - if t < cut - 0.01 && b > cut + 0.01 { - cut = t; - } - } - if cut <= seg_top + 0.01 { - // An item taller than what is left. Start it in a fresh area if this one - // already holds something; otherwise cut through it. - cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; - } - } - let mut here = Vec::new(); - let mut below = Vec::new(); - for (item, kept) in rest { - if let Some((top, _)) = kept { - if top < cut - 0.01 { - here.push(item) - } else { - below.push((item, kept)) - } - continue; - } - match divide(item, cut) { - (Some(a), None) => here.push(a), - (None, Some(b)) => below.push((b, None)), - (Some(a), Some(b)) => { - here.push(a); - below.push((b, None)); - } - (None, None) => {} - } - } - if let Some(p) = self.pages.last_mut() { - p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); - } - if below.is_empty() { - if let Some(c) = &mut self.cur { - c.y = (end - shift).min(c.rect.bottom()).max(c.y); - c.placed = true; - } - self.overflowed(end - shift); - return Ok(()); - } - rest = below; - seg_top = cut; - first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; - dx = rect.x - origin_x; - } - } - - fn page_has_flow(&self) -> bool { - self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) - } - - // ───────────────────────────────────────────────────────────────────────── flow - - /// Flow `node` (a subform; anything else is ignored) into the content areas. - fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - let Node::Subform(sf) = node else { return Ok(()) }; - self.check_measures()?; - if sf.break_before_page && self.page_has_flow() { - self.break_frames(0.0, true)?; - } - // Data adds instances of a repeating subform (rows added in another viewer). - let instances = self.instances(node, &ctx.som); - self.warn_truncated(node, &ctx.som, instances); - let ctx_parent = ctx; - for i in 0..instances { - // Every instance spends budget, so nested repeats that place nothing still stop. - self.measures.set(self.measures.get().saturating_add(1)); - self.check_measures()?; - let ctx = ctx_parent.with_som(&sf.common, sib + i); - let (rect, _, _) = self.cursor(); - let (x, w) = avail.unwrap_or((rect.x, rect.w)); - let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); - if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { - // Not splittable: one block. - let (_, h) = self.size_of(node, w, ctx_parent, depth); - self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; - continue; - } - let m = sf.common.margin; - // Its border and fill go round what it takes from here, a part on each content area. - let framed = match self.flow_mark() { - Some(part) => { - self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); - true - } - None => false, - }; - self.advance(m.top); - let inner_x = x + m.left; - let inner_w = (w - m.horizontal()).max(1.0); - match sf.layout { - Layout::Table => self.flow_table(sf, inner_x, inner_w, &ctx, depth)?, - _ => { - let sibs = SomIndices::of(&sf.children); - for (ci, child) in sf.children.iter().enumerate() { - if !child.common().presence.occupies() { - continue; - } - let csib = sibs.at(ci); - if let Node::Subform(_) = child - && splittable(child) - { - self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; - continue; - } - let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; - } - } - } - self.advance(m.bottom); - if framed && let Some(frame) = self.frames.pop() { - self.finish_frame(frame)?; - } - } - if sf.break_after_page { - self.break_frames(0.0, true)?; - } - Ok(()) - } - - fn columns(&self, sf: &Subform, inner_w: f64) -> Vec { - if !sf.column_widths.is_empty() { - return sf.column_widths.clone(); - } - let cells = sf.children.iter().filter_map(|c| if let Node::Subform(r) = c { Some(r.children.len()) } else { None }).max().unwrap_or(1).max(1); - vec![inner_w / cells as f64; cells] - } - - fn flow_table(&mut self, sf: &Subform, x: f64, inner_w: f64, ctx: &Ctx, depth: usize) -> Result<(), XfaError> { - let cols = self.columns(sf, inner_w); - let leader = - sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); - // The overflow leader starts each part after the first, before the row that moved. - let lead = leader.and_then(|li| match sf.children.get(li) { - Some(Node::Subform(l)) => Some((li, l.as_ref())), - _ => None, - }); - let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); - let sibs = SomIndices::of(&sf.children); - for (ci, child) in sf.children.iter().enumerate() { - if !child.common().presence.occupies() { - continue; - } - let csib = sibs.at(ci); - if let Node::Subform(row) = child - && row.layout == Layout::Row - { - // Rows added as data (in another viewer) repeat the row. - let instances = self.instances(child, &ctx.som); - self.warn_truncated(child, &ctx.som, instances); - for inst in 0..instances { - let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); - let led = lead.filter(|(li, _)| *li != ci); - if self.make_room(h, led.map_or(0.0, |_| lead_h))? - && let Some((li, l)) = led - { - let (_, y, _) = self.cursor(); - self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; - self.place(lead_h); - } - let (_, y, _) = self.cursor(); - self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; - self.place(h); - } - continue; - } - if splittable(child) - && let Node::Subform(_) = child - { - self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; - continue; - } - let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; - } - Ok(()) - } - - // ───────────────────────────────────────────────────────────────────────── sizing - - /// The size a node takes when given `avail_w` of width. Containers are measured once per - /// (node, width, depth): measuring a width-less container measures its content twice (for - /// its width, then its height), which would otherwise be exponential in the nesting. - /// How many instances of `node` to lay out under the container at `parent_som`: a - /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. - fn instances(&self, node: &Node, parent_som: &str) -> usize { - let Node::Subform(sf) = node else { return 1 }; - let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - instance_count(&sf.occur, from_data) - } - - /// The data holds more instances of `node` than `laid_out`: say so, since calculations - /// then see only those. - fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { - let Node::Subform(sf) = node else { return }; - let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - if from_data > laid_out { - self.warn_once(format!( - "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", - sf.common.name.as_deref().unwrap_or("a subform") - )); - } - } - - /// The width and height `node` takes in `avail_w`: its own size, else what its content needs - /// (containers are measured once per width and depth, and measuring stops at its caps). - fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { - let c = node.common(); - let spent = self.measures.get().saturating_add(1); - self.measures.set(spent); - if depth > MAX_DEPTH || spent > MAX_MEASURES { - return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); - } - // Only containers recurse; their addresses are stable for the whole layout (the - // template is borrowed, the root and page areas are held by the layouter). - let key = match node { - Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), - Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), - _ => None, - }; - if let Some(key) = key - && let Ok(cache) = self.measured.try_borrow() - && let Some(m) = cache.get(&key) - && m.font == ctx.font - && m.para == ctx.para - { - return m.size; - } - let size = self.measure(node, avail_w, ctx, depth); - if let Some(key) = key - && let Ok(mut cache) = self.measured.try_borrow_mut() - { - cache.insert(key, Measured { font: ctx.font.clone(), para: ctx.para.clone(), size }); - } - size - } - - /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). - /// Without a width, a container, a text draw or a button is as wide as its content (at most - /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. - fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { - let c = node.common(); - // The node's own SOM: its repeating children count their instances under it, and a - // growing text field finds its value at it. Measurements are cached per template node, - // so instance 0 stands for all of them. - let ctx = ctx.with_som(c, 0); - let som = ctx.som.clone(); - // A container without a width is as wide as its content (a radio group beside its - // question, say), and so is a text label or a button (as wide as its caption), within - // what is available; any other field without one takes what is available. - let w = match c.w { - Some(w) => w, - None => match node { - Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { - let inner = (avail_w - c.margin.horizontal()).max(1.0); - (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) - } - Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), - Node::Field(f) if f.ui == Ui::Button => f - .caption - .as_ref() - .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) - .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), - Node::Field(_) => avail_w, - }, - }; - let w = clamp_opt(w, c.min_w, c.max_w); - let h = match c.h { - Some(h) => h, - None => { - let inner_w = (w - c.margin.horizontal()).max(1.0); - let content = match node { - Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), - Node::Field(f) => self.field_height(f, inner_w, &ctx), - Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), - Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), - Node::Subform(s) => match s.layout { - Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), - // Each instance of a repeating child takes its height again. - Layout::Tb => s - .children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) - .sum(), - Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, - Layout::Table => { - let cols = self.columns(s, inner_w); - s.children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| match k { - Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), - _ => self.size_of(k, inner_w, &ctx, depth + 1).1, - }) - .sum() - } - Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), - }, - }; - content + c.margin.vertical() - } - }; - // A flowed subform is as tall as what it holds: other viewers do not stretch one to - // the `minH` a designer left on it. - let min_h = match node { - Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, - _ => c.min_h, - }; - (w, clamp_opt(h, min_h, c.max_h)) - } - - /// The natural width of a container's content within `avail_w`. - fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { - let group: Vec; - let (children, layout): (&[Node], Layout) = match node { - Node::Subform(s) => { - if s.layout == Layout::Table && !s.column_widths.is_empty() { - return s.column_widths.iter().sum::().min(avail_w); - } - (&s.children, s.layout) - } - Node::ExclGroup(g) => { - // Fields are leaves: measuring these copies caches nothing. - group = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - (&group, g.layout) - } - Node::Area(a) => (&a.children, Layout::Positioned), - _ => return avail_w, - }; - let visible = children.iter().filter(|k| k.common().presence.occupies()); - match layout { - Layout::LrTb | Layout::RlTb | Layout::Row => { - let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); - if sum <= avail_w + 0.05 { sum } else { avail_w } - } - Layout::Positioned => visible - .map(|k| { - let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); - k.common().top_left(w, h).0 + w - }) - .fold(0.0, f64::max) - .min(avail_w), - Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), - } - } - - /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying - /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws - /// nothing. - fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option { - let rich = match v { - Value::Text(t) => text::plain(t), - Value::Rich(r) => r.clone(), - _ => return None, - }; - // No text (a bordered spacer or rule): nothing to size to, so it keeps what it has room for. - let w = text::natural_width(&rich, para, &Face::of(font), &|id, max| self.embed(id, max)); - (w > 0.0).then_some(w + 0.01) - } - - /// How tall value `v` is laid out `w` wide: text wrapped in the context's font and paragraph, - /// an image at its aspect ratio, and nothing for lines, rectangles and empty values. - fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { - match v { - Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), - Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), - Value::Image { data, .. } => crate::image::size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }), - Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0, - } - } - - /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None` - /// when it has no caption that takes room (a button's is drawn inside its box). - fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { - let cap = f.caption.as_ref()?; - if !cap.presence.occupies() || f.ui == Ui::Button { - return None; - } - if cap.value.is_empty() && cap.reserve.is_none() { - return None; - } - // A check box has no text for an inline caption to run with: it is not shown. - if f.ui == Ui::CheckButton && cap.placement == Placement::Inline { - return None; - } - let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; - let font = ctx.font.apply(&cap.font); - let para = ctx.para.apply(&cap.para); - let reserve = cap.reserve.unwrap_or_else(|| match placement { - Placement::Top | Placement::Bottom => { - let rich = match &cap.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - text::measure_height(&rich, inner_w, ¶, &Face::of(&font), &|id, max| self.embed(id, max)) - } - _ => (Face::of(&font).width(&cap.value.plain().unwrap_or_default()) + 2.0).min(inner_w * 0.6), - }); - Some((placement, reserve)) - } - - /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check - /// box, a button's caption, or one line of text or three when multi-line, more for a text field - /// that grows) plus a caption above or below it, or the taller of the two for one beside it. - fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - let font = ctx.font.apply(&f.common.font); - let lines = if f.multiline { 3.0 } else { 1.0 }; - let ui = match f.ui { - Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), - // A button shows its caption: as tall as the caption's text in its own font. - Ui::Button if f.caption.as_ref().is_some_and(|c| !c.value.is_empty()) => { - let cap = f.caption.as_ref().map_or(0.0, |c| { - let rich = match &c.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - let w = (inner_w - f.ui_margin.horizontal()).max(1.0); - text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) - }); - cap + f.ui_margin.vertical() - } - _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), - }; - match self.caption_reserve(f, inner_w, ctx) { - Some((Placement::Top | Placement::Bottom, r)) => ui + r, - Some((placement, r)) => { - // A check box's caption without a reserve is drawn in all the box leaves. - let w = match auto_check_reserve(f, placement, inner_w, None) { - Some(auto) => auto, - None => inner_w - r, - }; - let cap = f.caption.as_ref().map(|c| { - let rich = match &c.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { - self.embed(id, max) - }) - }); - ui.max(cap.unwrap_or(0.0)) - } - None => ui, - } - } - - /// The face a field's text is laid out in: at its horizontal scale when the layout draws - /// the text itself (a non-interactive field), as designed when the forms layer draws it in - /// a widget, whose appearances have no horizontal scaling. - fn field_text_face(f: &Field, font: &Font) -> Face { - let face = Face::of(font); - if f.access == Access::NonInteractive { face } else { face.unscaled() } - } - - /// The height the text of a multi-line text field takes (its value in the data, else the - /// template's), for a field without a fixed height, which grows to hold its text. `ctx` - /// is the field's own (its SOM ends in the field). Zero for anything else. - fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - if !f.grows_with_text() || f.common.name.is_none() { - return 0.0; - } - let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); - let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; - // Beside a caption the text has what the caption leaves; a widget's text has 2pt of - // padding on each side, a non-interactive field's (drawn as text) none, so it wraps - // where it is drawn. - let beside = match self.caption_reserve(f, inner_w, ctx) { - Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, - Some((_, r)) => r, - }; - let padding = if f.access == Access::NonInteractive { 0.0 } else { 4.0 }; - let w = (inner_w - beside - f.ui_margin.horizontal() - padding).max(1.0); - let font = ctx.font.apply(&f.common.font); - text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_, _| String::new()) - } - - /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked - /// top to bottom, at their own positions, or packed in rows. - fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - match g.layout { - Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), - Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), - _ => self.packed_height(&nodes, inner_w, ctx, depth), - } - } - - fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| { - let c = k.common(); - let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); - c.top_left(w, h).1 + h - }) - .fold(0.0, f64::max) - } - - /// Left-to-right packing: rows of children, wrapped at the available width, as (child, - /// instance, place). A repeating subform is packed once per instance it has under the - /// container at `som`. - fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { - let mut out = Vec::new(); - let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); - for (i, k) in children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); - for instance in 0..self.instances(k, som) { - if x > 0.0 && x + w > inner_w + 0.05 { - y += line_h; - x = 0.0; - line_h = 0.0; - } - out.push((i, instance, Rect::new(x, y, w, h))); - x += w; - line_h = line_h.max(h); - } - } - (out, y + line_h) - } - - /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). - fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { - let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); - (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) - } - - fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.pack(children, inner_w, ctx, depth).1 - } - - /// Cell rectangles of a row (relative to the row's inner box) and the row height. - fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { - let ctx = ctx.child(&row.common); - let mut out = Vec::new(); - let mut col = 0usize; - let mut x = 0.0; - let mut h: f64 = 0.0; - for (i, k) in row.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let span = k.common().col_span.max(1); - let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; - let (_, ch) = self.size_of(k, w, &ctx, depth + 1); - out.push((i, Rect::new(x, 0.0, w, ch))); - h = h.max(ch); - x += w; - col += span; - } - let h = match row.common.h { - Some(rh) => rh, - None => h + row.common.margin.vertical(), - }; - (out, clamp_opt(h, row.common.min_h, row.common.max_h)) - } - - fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.cells(row, cols, inner_w, ctx, depth).1 - } - - // ───────────────────────────────────────────────────────────────────────── drawing - - fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - if let Some(b) = &row.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.with_som(&row.common, sib); - let inner = rect.inset(&row.common.margin); - let (cells, _) = self.cells(row, cols, inner.w, &ctx, depth); - let sibs = SomIndices::of(&row.children); - for (i, r) in cells { - if let Some(k) = row.children.get(i) { - // Cells stretch to the row height. - self.draw_node(k, Rect::new(inner.x + r.x, inner.y, r.w, inner.h), &ctx, depth + 1, sibs.at(i))?; - } - } - Ok(()) - } - - fn draw_positioned_child(&mut self, k: &Node, inner: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - let c = k.common(); - if !c.presence.occupies() { - return Ok(()); - } - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); - let (x, y) = c.top_left(w, h); - self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) - } - - fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - let c = node.common(); - if c.presence == Presence::Invisible { - return Ok(()); - } - match node { - Node::Draw(d) => { - let start = self.item_count(); - self.draw_draw(d, rect, ctx)?; - // Text and pictures stay with their border; a bare line or rectangle is cut. - if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { - self.keep_whole(start, rect); - } - Ok(()) - } - Node::Field(f) => { - let start = self.item_count(); - self.draw_field(f, rect, ctx, None, sib)?; - self.keep_whole(start, rect); - Ok(()) - } - Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), - Node::Area(a) => { - let ctx = ctx.with_som(&a.common, sib); - let sibs = SomIndices::of(&a.children); - for (i, k) in a.children.iter().enumerate() { - self.draw_positioned_child(k, rect, &ctx, depth + 1, sibs.at(i))?; - } - Ok(()) - } - Node::Subform(s) => { - // Every drawn instance spends budget, as a flowed one does, so nested repeats that - // place nothing still stop. - self.measures.set(self.measures.get().saturating_add(1)); - self.check_measures()?; - if let Some(b) = &s.common.border { - self.draw_border(b, rect)?; - } - let parent_ctx = ctx; - let ctx = ctx.with_som(&s.common, sib); - let inner = rect.inset(&s.common.margin); - let sibs = SomIndices::of(&s.children); - match s.layout { - Layout::Positioned => { - for (i, k) in s.children.iter().enumerate() { - self.draw_positioned_child(k, inner, &ctx, depth + 1, sibs.at(i))?; - } - } - Layout::Tb => { - let mut y = inner.y; - for (i, k) in s.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - // A repeating subform here (a table in an area of a positioned page, - // say) gets its instances, as in a flowed one, one under another. - for instance in 0..self.instances(k, &ctx.som) { - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; - y += h; - } - } - } - Layout::LrTb | Layout::RlTb => { - // A repeating subform here (rows of fields under a heading) gets its - // instances, as in the other layouts. - let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); - for (i, instance, r) in placed { - if let Some(k) = s.children.get(i) { - let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; - self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?; - } - } - } - Layout::Table => { - let cols = self.columns(s, inner.w); - let mut y = inner.y; - for (i, k) in s.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let csib = sibs.at(i); - if let Node::Subform(r) = k - && r.layout == Layout::Row - { - let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); - self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; - y += h; - } else { - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; - y += h; - } - } - } - Layout::Row => { - let cols = self.columns(s, inner.w); - self.draw_row(s, &cols, rect, parent_ctx, depth, sib)?; - } - } - Ok(()) - } - } - } - - /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or - /// the whole box as one rounded shape when its corners are rounded. - fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { - let vis = b.visible_edges(); - // Rounded corners: the box is one shape, filled and edged along its curves, when its - // edges are all alike (all shown and the same, or all hidden). Edges that differ keep - // square corners. - let radii = b.radii(rect.w, rect.h); - let alike = b.edges.iter().all(|e| *e == b.edges[0]); - if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { - let e = b.edges[0]; - let edge = vis[0].then(|| { - let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; - (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) - }); - let shape = Rounded { - rect, - radii, - inverted: b.corners.map(|c| c.inverted), - fill: b.fill().map(|f| f.0), - edge, - open_top: false, - open_bottom: false, - }; - return self.push(Item::Rounded(Box::new(shape))); - } - if let Some(fill) = b.fill() { - self.push(Item::Fill { rect, color: fill.0 })?; - } - let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); - // Top, right, bottom, left; lines sit inside the box. - let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; - for (i, seg) in segs.iter().enumerate() { - if !vis[i] { - continue; - } - let e = b.edges[i]; - let t = e.thickness.clamp(0.1, 20.0); - let (dx, dy) = match i { - 0 => (0.0, t / 2.0), - 1 => (-t / 2.0, 0.0), - 2 => (0.0, -t / 2.0), - _ => (t / 2.0, 0.0), - }; - let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; - self.push(Item::Line { - from: (seg.0.0 + dx, seg.0.1 + dy), - to: (seg.1.0 + dx, seg.1.1 + dy), - width: t, - color, - dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted), - })?; - } - Ok(()) - } - - fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { - let rich = match v { - Value::Text(t) => text::plain(t), - Value::Rich(r) => r.clone(), - _ => return Ok(()), - }; - let face = Face::of(font); - // Text that doesn't fit the height it was given (the template's face is narrower than - // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, - // down to 70%, rather than over what lies below. - let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; - let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; - let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); - if scale < 1.0 { - for s in &mut block.spans { - s.x = rect.x + (s.x - rect.x) * scale; - s.baseline = rect.y + (s.baseline - rect.y) * scale; - s.face.size *= scale; - } - } - self.push_block(block) - } - - fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { - if let Some(b) = &d.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.child(&d.common); - let inner = rect.inset(&d.common.margin); - match &d.value { - Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), - Value::Image { content_type, data } => self.draw_image(data, content_type, inner), - Value::Line { slope_up, edge } => { - if !edge.visible() { - return Ok(()); - } - let (from, to) = if *slope_up { - ((inner.x, inner.bottom()), (inner.right(), inner.y)) - } else { - ((inner.x, inner.y), (inner.right(), inner.bottom())) - }; - self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed }) - } - Value::Rectangle(b) => self.draw_border(b, inner), - Value::Empty => Ok(()), - } - } - - fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if let Some(b) = &g.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.with_som(&g.common, sib); - // The group's data node holds the selected button's on value. - let selected: Option = - self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - let inner = rect.inset(&g.common.margin); - let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); - let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - let placed: Vec<(usize, Rect)> = match g.layout { - Layout::Tb => { - let mut y = 0.0; - let mut out = Vec::new(); - for (i, k) in nodes.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - out.push((i, Rect::new(0.0, y, w, h))); - y += h; - } - out - } - Layout::Positioned => nodes - .iter() - .enumerate() - .filter(|(_, k)| k.common().presence.occupies()) - .map(|(i, k)| { - let c = k.common(); - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); - let (x, y) = c.top_left(w, h); - (i, Rect::new(x, y, w, h)) - }) - .collect(), - _ => self.pack(&nodes, inner.w, &ctx, depth).0, - }; - let sibs = SomIndices::of(&nodes); - for (i, r) in placed { - if let Some(f) = g.fields.get(i) { - let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); - let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); - let chosen = selected.as_deref() == Some(on.as_str()); - self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; - } - } - Ok(()) - } - - /// A picture scaled to fit `inner`, keeping its shape, from its top left. - fn draw_image(&mut self, data: &[u8], content_type: &str, inner: Rect) -> Result<(), XfaError> { - self.image_bytes = self.image_bytes.saturating_add(data.len()); - if self.image_bytes > MAX_IMAGE_BYTES { - return Err(XfaError::TooLarge("the form's pictures are too large together to lay out".into())); - } - let fitted = match crate::image::size(data) { - Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => { - let k = (inner.w / iw as f64).min(inner.h / ih as f64); - Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k) - } - _ => inner, - }; - self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) - } - - /// A field name made from `base` that no earlier field of the form has: the first keeps it, - /// later ones get `_2`, `_3`, …. - fn unique_name(&mut self, base: &str) -> String { - let base = clean_name(base); - let n = self.names.entry(base.clone()).or_insert(0); - *n += 1; - if *n == 1 { base } else { format!("{base}_{n}") } - } - - /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its - /// widget, or its value as plain text when it is non-interactive, a barcode, or a text field - /// taller than its content area. `radio` is set for a group's radio button; `sib` is its SOM index. - fn draw_field( - &mut self, - f: &Field, - rect: Rect, - ctx: &Ctx, - radio: Option<(&str, &str, Option, bool)>, - sib: usize, - ) -> Result<(), XfaError> { - if let Some(b) = &f.common.border - && f.ui != Ui::Button - { - self.draw_border(b, rect)?; - } - let fctx = ctx.child(&f.common); - let inner = rect.inset(&f.common.margin); - let (mut ui, mut cap_rect) = (inner, None); - if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { - let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); - let r = reserve.max(0.0); - match placement { - Placement::Top => { - cap_rect = Some(Rect::new(inner.x, inner.y, inner.w, r.min(inner.h))); - ui = Rect::new(inner.x, inner.y + r, inner.w, inner.h - r); - } - Placement::Bottom => { - cap_rect = Some(Rect::new(inner.x, inner.bottom() - r.min(inner.h), inner.w, r.min(inner.h))); - ui = Rect::new(inner.x, inner.y, inner.w, inner.h - r); - } - Placement::Right => { - cap_rect = Some(Rect::new(inner.right() - r.min(inner.w), inner.y, r.min(inner.w), inner.h)); - ui = Rect::new(inner.x, inner.y, inner.w - r, inner.h); - } - _ => { - cap_rect = Some(Rect::new(inner.x, inner.y, r.min(inner.w), inner.h)); - ui = Rect::new(inner.x + r, inner.y, inner.w - r, inner.h); - } - } - } - if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) - && cap.presence == Presence::Visible - { - let font = ctx.font.apply(&cap.font); - let para = ctx.para.apply(&cap.para); - self.draw_value_text(&cap.value, cr, &font, ¶)?; - } - let som_path = || { - if fctx.som.is_empty() { - format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field")) - } else { - format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field")) - } - }; - match f.ui { - Ui::Unknown => { - self.warn_once("a field of an unknown kind is left blank"); - return Ok(()); - } - Ui::ImageEdit => { - // The picture the data holds (base64, as Designer saves it) or the template's. - let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into()); - let text: Vec = self - .data - .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) - .map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect()) - .unwrap_or_default(); - let from_data = if text.is_empty() { - None - } else if text.len() > MAX_DATA_IMAGE / 3 * 4 + 4 { - self.warn_once(format!("{field_name}: the picture in the data is larger than 32 MB and is left out")); - None - } else { - match base64::engine::general_purpose::STANDARD.decode(&text) { - Ok(d) if crate::image::kind(&d).is_some() => Some(d), - Ok(_) => { - self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); - None - } - Err(_) => { - self.warn_once(format!("{field_name}: the data holds no readable picture (not base64) and is left out")); - None - } - } - }; - let box_ = ui.inset(&f.ui_margin); - match (&from_data, &f.value) { - (Some(d), _) => self.draw_image(d, "", box_)?, - (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, - _ => {} - } - // Then the field itself, which clicking gives a new picture. - } - Ui::Barcode => { - // The value as text, where the bars would be. - self.warn_once("barcode fields show their value as text, not as bars"); - let value = self - .data - .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) - .map(str::to_string) - .or_else(|| f.value.plain()) - .unwrap_or_default(); - if !value.trim().is_empty() { - let mut para = fctx.para.clone(); - para.h_align = HAlign::Center; - self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?; - } - return Ok(()); - } - _ => {} - } - if f.access == Access::NonInteractive && f.ui != Ui::Button { - // Shown as text, not as a field. - return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); - } - // A text field that grew past what a content area holds can't be one widget: its text - // is drawn as lines the page breaks fall between, and stays what the data holds. - if f.grows_with_text() && rect.h > self.cursor().0.h + 0.01 { - let value = - self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); - if let Some(value) = value.filter(|t| !t.trim().is_empty()) { - let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); - self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); - let m = Margin { - top: f.ui_margin.top + 2.0, - bottom: f.ui_margin.bottom + 2.0, - left: f.ui_margin.left + 2.0, - right: f.ui_margin.right + 2.0, - }; - let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; - // In the face it was measured in, so it takes the lines it was given. - let face = Self::field_text_face(f, &fctx.font); - let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_, _| String::new()); - return self.push_block(block); - } - } - let face = match (&f.ui, &f.caption) { - // Buttons show their caption in the caption's font. - (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), - _ => Face::of(&fctx.font).unscaled(), - }; - let h_align = match (&f.ui, &f.caption) { - (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, - _ => fctx.para.h_align, - }; - let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); - let som = som_path(); - let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); - // The data's value wins over the template's default. - let data_value: Option = - self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - // A choice list's default is a saved value too. - let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty()); - let plain_value = data_value.clone().or_else(|| template_default.clone()); - let (kind, rect, border, value, tooltip) = match f.ui { - Ui::CheckButton => { - let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); - // Without a caption the box sits at the right of the content area, as if a - // caption on its left took the rest, so the paragraph's alignment has no room to - // act on; beside a caption that alignment places it. The box's own margin doesn't - // move it. - let x = match (cap_rect, fctx.para.h_align) { - (None, _) | (Some(_), HAlign::Right) => ui.right() - s, - (Some(_), HAlign::Center) => ui.x + (ui.w - s) / 2.0, - (Some(_), HAlign::Left | HAlign::Justify) => ui.x, - }; - let y = match fctx.para.v_align { - VAlign::Top => ui.y, - VAlign::Middle => ui.y + (ui.h - s) / 2.0, - VAlign::Bottom => ui.bottom() - s, - }; - let r = Rect::new(x, y, s, s); - let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); - let (kind, tip, checked) = match radio { - Some((group, on_value, tip, chosen)) => ( - WidgetKind::Radio { group: group.to_string(), on: on_value.to_string(), round: f.check_round }, - tip.or_else(|| f.tooltip.clone()), - chosen, - ), - None => ( - WidgetKind::CheckBox { on: on.clone(), round: f.check_round }, - f.tooltip.clone(), - plain_value.as_deref().is_some_and(|v| is_on(v, &on)), - ), - }; - let border = widget_border(f.ui_border.as_ref(), true); - (kind, r, border, checked.then(|| on.clone()), tip) - } - Ui::Button => { - let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string(); - // A button's box is its field border, around the whole field (its margins pad - // the caption, not the box); without one it is invisible (a link over text, say). - let border = match &f.common.border { - Some(b) => widget_border(Some(b), false), - None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }, - }; - (WidgetKind::Button { caption }, rect, border, None, f.tooltip.clone()) - } - Ui::DateTimeEdit => { - let pattern = date_pattern(f.picture.as_deref()); - // The data holds the date in the field's data picture, or else as an ISO date; the - // field shows it in its own pattern. - let data_pattern = data_date_letters(f); - let shown = plain_value - .as_ref() - .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() }); - (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) - } - Ui::ChoiceList => { - let options: Vec<(String, String)> = f - .items - .iter() - .enumerate() - .map(|(i, shown)| (f.item_values.get(i).cloned().unwrap_or_else(|| shown.clone()), shown.clone())) - .collect(); - // The data holds saved values (several on separate lines); shown text is taken - // too, as a user typing into an editable list leaves it. - let value = plain_value.as_ref().map(|v| f.saved_value(v)); - let kind = WidgetKind::Choice { options, list_box: f.choice.list_box, multi: f.choice.multi, editable: f.choice.editable }; - (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) - } - Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), - Ui::ImageEdit => { - let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; - (WidgetKind::Image, ui, none, None, f.tooltip.clone()) - } - Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), - _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), - }; - if rect.w < 1.0 || rect.h < 1.0 { - return Ok(()); - } - let action = if f.ui == Ui::Button { button_action(&f.scripts) } else { None }; - let items: Vec = match &kind { - WidgetKind::CheckBox { on, .. } => vec![on.clone(), f.items.get(1).cloned().unwrap_or_default()], - WidgetKind::Radio { on, .. } => vec![on.clone()], - _ => Vec::new(), - }; - self.fields += 1; - self.push(Item::Widget(Box::new(Widget { - rect, - kind, - name, - som, - tooltip, - face, - h_align, - multiline: f.multiline, - max_chars: f.max_chars, - read_only, - border, - value, - default: template_default, - action, - items, - no_scroll: f.no_scroll, - }))) - } -} - -/// The room a caption without a reserve takes beside a check box: all the box leaves of a -/// content area `w` wide (and `h` tall, when known), the box at the size it can be drawn there. -/// `None` for any other field, or a caption with a reserve of its own. -fn auto_check_reserve(f: &Field, placement: Placement, w: f64, h: Option) -> Option { - if f.ui != Ui::CheckButton || f.caption.as_ref().is_none_or(|c| c.reserve.is_some()) { - return None; - } - let s = f.check_size.unwrap_or(10.0).min(w.max(1.0)).min(h.map_or(f64::INFINITY, |h| h.max(1.0))); - let rest = match (placement, h) { - (Placement::Top | Placement::Bottom, Some(h)) => h - s, - (Placement::Top | Placement::Bottom, None) => return None, - _ => w - s, - }; - Some(rest.max(0.0)) -} - -/// The widget's own border from the `` border element. Without one, a check box gets a -/// solid box, as Designer draws it, and any other field has no box of its own. -fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { - let Some(b) = b else { - if !check { - return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; - } - return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; - }; - let vis = b.visible_edges(); - let shape = match vis { - [false, false, false, false] => BorderShape::None, - [false, false, true, false] => BorderShape::Underline, - _ => BorderShape::Full, - }; - let edge = (0..4).find(|&i| vis[i]).map(|i| b.edges[i]).unwrap_or_default(); - WidgetBorder { - color: (shape != BorderShape::None).then_some(edge.color.0), - fill: b.fill().map(|c| c.0), - shape, - width: edge.thickness.clamp(0.1, 12.0), - } -} diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs new file mode 100644 index 0000000000..3b60f8a6a0 --- /dev/null +++ b/crates/xfa/src/layout/common.rs @@ -0,0 +1,211 @@ +use super::items::{Action, Item, Rounded}; +use super::*; + +pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + match n { + Node::Field(f) => { + if let Some(id) = &f.common.id { + out.insert(id.clone(), f.value.plain().unwrap_or_default()); + } + } + Node::Subform(s) => { + collect_values(&s.children, out, depth + 1); + if let Some(ps) = &s.page_set { + for a in &ps.areas { + collect_values(&a.items, out, depth + 1); + } + } + } + Node::Area(a) => collect_values(&a.children, out, depth + 1), + Node::ExclGroup(_) | Node::Draw(_) => {} + } + } +} + +pub(super) fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { + let mut v = v; + if let Some(m) = min { + v = v.max(m); + } + if let Some(m) = max + && m > 0.0 + { + v = v.min(m); + } + v.max(0.0) +} + +/// Does `node` flow (may be split across pages) rather than being placed as one block? +pub(super) fn splittable(node: &Node) -> bool { + matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) +} + +/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` +/// for fills and lines, which are cut at the break. +pub(super) fn solid_extent(item: &Item) -> Option<(f64, f64)> { + match item { + Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), + Item::Image { rect, .. } => Some((rect.y, rect.bottom())), + Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), + Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, + } +} + +/// `item` as the part above `cut` and the part below: solid items go whole to the side their +/// top is on, fills and lines crossing the cut are cut in two. +pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Some((top, _)) = solid_extent(&item) { + return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; + } + match item { + Item::Fill { rect, color } => { + if rect.bottom() <= cut + 0.01 { + (Some(Item::Fill { rect, color }), None) + } else if rect.y >= cut - 0.01 { + (None, Some(Item::Fill { rect, color })) + } else { + let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); + let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); + (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) + } + } + Item::Line { from, to, width, color, dashed } => { + let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; + if lo.1 >= cut { + (None, Some(Item::Line { from, to, width, color, dashed })) + } else if hi.1 <= cut { + (Some(Item::Line { from, to, width, color, dashed }), None) + } else { + // lo.1 < cut < hi.1, so the divisor is positive. + let t = (cut - lo.1) / (hi.1 - lo.1); + let mid = (lo.0 + (hi.0 - lo.0) * t, cut); + (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) + } + } + Item::Rounded(r) => { + if r.rect.bottom() <= cut + 0.01 { + (Some(Item::Rounded(r)), None) + } else if r.rect.y >= cut - 0.01 { + (None, Some(Item::Rounded(r))) + } else { + // Each part keeps the corners on its side, rounded no more than its own height + // allows, and is open where it was cut. + let [tl, tr, br, bl] = r.radii; + let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); + let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); + let fit = |v: f64, h: f64| v.min(h.max(0.0)); + let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; + let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; + (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) + } + } + other => (Some(other), None), + } +} + +/// `item` moved by (`dx`, `dy`). +pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + match &mut item { + Item::Fill { rect, .. } | Item::Image { rect, .. } => { + rect.x += dx; + rect.y += dy; + } + Item::Rounded(r) => { + r.rect.x += dx; + r.rect.y += dy; + } + Item::Line { from, to, .. } => { + *from = (from.0 + dx, from.1 + dy); + *to = (to.0 + dx, to.1 + dy); + } + Item::Text(s) => { + s.x += dx; + s.baseline += dy; + } + Item::Widget(w) => { + w.rect.x += dx; + w.rect.y += dy; + } + } + item +} + +pub(super) fn is_row(node: &Node) -> bool { + matches!(node, Node::Subform(s) if s.layout == Layout::Row) +} + +/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. +pub(crate) fn date_pattern(picture: Option<&str>) -> String { + picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) +} + +/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the +/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into +/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a +/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO +/// date: the raw value stands then. +pub fn formatted_value(picture: &str, raw: &str) -> Option { + let first = picture.split('|').next()?.trim(); + let rest = first.strip_prefix("date")?; + if !rest.starts_with(['{', '(']) { + return None; + } + let pattern = date_pattern(Some(first)); + // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would + // leave them as letters: the raw value stands then. + let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d"); + if !crate::data::date_tokens(&pattern).iter().all(shows) { + return None; + } + // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands. + let b = raw.as_bytes(); + let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }); + if !iso { + return None; + } + let shown = iso_to_pattern(raw, &pattern); + (shown != raw).then_some(shown) +} + +/// The native PDF action a button's click script amounts to (a reset, print, save or link), +/// else the script itself to run; `None` without a click script. +pub(super) fn button_action(scripts: &[Script]) -> Option { + let s = scripts.iter().find(|s| s.activity == "click")?; + let t = &s.text; + // Only a single-statement idiom becomes a native PDF action; anything else is run by the + // XFA scripting engine. + let one = t.trim().trim_end_matches(';'); + let single = !one.contains(';') && !one.contains('\n'); + if !single { + return (!t.trim().is_empty()).then(|| Action::Script(t.clone())); + } + if t.contains("resetData") { + Some(Action::Reset) + } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { + Some(Action::Print) + } else if t.contains("SaveAs") || t.contains("execMenuItem(\"Save\")") { + Some(Action::SaveAs) + } else if let Some(i) = t.find("launchURL(") { + let rest = &t[i + "launchURL(".len()..]; + let q = rest.find(['"', '\''])?; + let quote = rest[q..].chars().next()?; + let body = &rest[q + 1..]; + let end = body.find(quote)?; + Some(Action::Url(body[..end].to_string())) + } else if t.trim().is_empty() { + None + } else { + Some(Action::Script(t.clone())) + } +} + +/// A PDF field name: no periods (they separate hierarchy levels), no control characters. +pub(super) fn clean_name(name: &str) -> String { + let s: String = name.chars().filter(|c| !c.is_control()).map(|c| if c == '.' { '_' } else { c }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "field".into() } else { s } +} diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs new file mode 100644 index 0000000000..4f318a73d6 --- /dev/null +++ b/crates/xfa/src/layout/draw.rs @@ -0,0 +1,324 @@ +use super::common::clean_name; +use super::items::{Item, Rounded}; +use super::*; + +impl Layouter<'_> { + // ───────────────────────────────────────────────────────────────────────── drawing + + pub(super) fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + if let Some(b) = &row.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&row.common, sib); + let inner = rect.inset(&row.common.margin); + let (cells, _) = self.cells(row, cols, inner.w, &ctx, depth); + let sibs = SomIndices::of(&row.children); + for (i, r) in cells { + if let Some(k) = row.children.get(i) { + // Cells stretch to the row height. + self.draw_node(k, Rect::new(inner.x + r.x, inner.y, r.w, inner.h), &ctx, depth + 1, sibs.at(i))?; + } + } + Ok(()) + } + + pub(super) fn draw_positioned_child(&mut self, k: &Node, inner: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let c = k.common(); + if !c.presence.occupies() { + return Ok(()); + } + let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (x, y) = c.top_left(w, h); + self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) + } + + pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let c = node.common(); + if c.presence == Presence::Invisible { + return Ok(()); + } + match node { + Node::Draw(d) => { + let start = self.item_count(); + self.draw_draw(d, rect, ctx)?; + // Text and pictures stay with their border; a bare line or rectangle is cut. + if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { + self.keep_whole(start, rect); + } + Ok(()) + } + Node::Field(f) => { + let start = self.item_count(); + self.draw_field(f, rect, ctx, None, sib)?; + self.keep_whole(start, rect); + Ok(()) + } + Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), + Node::Area(a) => { + let ctx = ctx.with_som(&a.common, sib); + let sibs = SomIndices::of(&a.children); + for (i, k) in a.children.iter().enumerate() { + self.draw_positioned_child(k, rect, &ctx, depth + 1, sibs.at(i))?; + } + Ok(()) + } + Node::Subform(s) => { + // Every drawn instance spends budget, as a flowed one does, so nested repeats that + // place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + if let Some(b) = &s.common.border { + self.draw_border(b, rect)?; + } + let parent_ctx = ctx; + let ctx = ctx.with_som(&s.common, sib); + let inner = rect.inset(&s.common.margin); + let sibs = SomIndices::of(&s.children); + match s.layout { + Layout::Positioned => { + for (i, k) in s.children.iter().enumerate() { + self.draw_positioned_child(k, inner, &ctx, depth + 1, sibs.at(i))?; + } + } + Layout::Tb => { + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + // A repeating subform here (a table in an area of a positioned page, + // say) gets its instances, as in a flowed one, one under another. + for instance in 0..self.instances(k, &ctx.som) { + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; + y += h; + } + } + } + Layout::LrTb | Layout::RlTb => { + // A repeating subform here (rows of fields under a heading) gets its + // instances, as in the other layouts. + let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); + for (i, instance, r) in placed { + if let Some(k) = s.children.get(i) { + let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?; + } + } + } + Layout::Table => { + let cols = self.columns(s, inner.w); + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let csib = sibs.at(i); + if let Node::Subform(r) = k + && r.layout == Layout::Row + { + let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); + self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; + y += h; + } else { + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; + y += h; + } + } + } + Layout::Row => { + let cols = self.columns(s, inner.w); + self.draw_row(s, &cols, rect, parent_ctx, depth, sib)?; + } + } + Ok(()) + } + } + } + + /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or + /// the whole box as one rounded shape when its corners are rounded. + pub(super) fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + let vis = b.visible_edges(); + // Rounded corners: the box is one shape, filled and edged along its curves, when its + // edges are all alike (all shown and the same, or all hidden). Edges that differ keep + // square corners. + let radii = b.radii(rect.w, rect.h); + let alike = b.edges.iter().all(|e| *e == b.edges[0]); + if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { + let e = b.edges[0]; + let edge = vis[0].then(|| { + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) + }); + let shape = Rounded { + rect, + radii, + inverted: b.corners.map(|c| c.inverted), + fill: b.fill().map(|f| f.0), + edge, + open_top: false, + open_bottom: false, + }; + return self.push(Item::Rounded(Box::new(shape))); + } + if let Some(fill) = b.fill() { + self.push(Item::Fill { rect, color: fill.0 })?; + } + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + // Top, right, bottom, left; lines sit inside the box. + let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; + for (i, seg) in segs.iter().enumerate() { + if !vis[i] { + continue; + } + let e = b.edges[i]; + let t = e.thickness.clamp(0.1, 20.0); + let (dx, dy) = match i { + 0 => (0.0, t / 2.0), + 1 => (-t / 2.0, 0.0), + 2 => (0.0, -t / 2.0), + _ => (t / 2.0, 0.0), + }; + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + self.push(Item::Line { + from: (seg.0.0 + dx, seg.0.1 + dy), + to: (seg.1.0 + dx, seg.1.1 + dy), + width: t, + color, + dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted), + })?; + } + Ok(()) + } + + pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return Ok(()), + }; + let face = Face::of(font); + // Text that doesn't fit the height it was given (the template's face is narrower than + // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, + // down to 70%, rather than over what lies below. + let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; + let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; + let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); + if scale < 1.0 { + for s in &mut block.spans { + s.x = rect.x + (s.x - rect.x) * scale; + s.baseline = rect.y + (s.baseline - rect.y) * scale; + s.face.size *= scale; + } + } + self.push_block(block) + } + + fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { + if let Some(b) = &d.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.child(&d.common); + let inner = rect.inset(&d.common.margin); + match &d.value { + Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), + Value::Image { content_type, data } => self.draw_image(data, content_type, inner), + Value::Line { slope_up, edge } => { + if !edge.visible() { + return Ok(()); + } + let (from, to) = if *slope_up { + ((inner.x, inner.bottom()), (inner.right(), inner.y)) + } else { + ((inner.x, inner.y), (inner.right(), inner.bottom())) + }; + self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed }) + } + Value::Rectangle(b) => self.draw_border(b, inner), + Value::Empty => Ok(()), + } + } + + fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if let Some(b) = &g.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&g.common, sib); + // The group's data node holds the selected button's on value. + let selected: Option = + self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let inner = rect.inset(&g.common.margin); + let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + let placed: Vec<(usize, Rect)> = match g.layout { + Layout::Tb => { + let mut y = 0.0; + let mut out = Vec::new(); + for (i, k) in nodes.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + out.push((i, Rect::new(0.0, y, w, h))); + y += h; + } + out + } + Layout::Positioned => nodes + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| { + let c = k.common(); + let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); + let (x, y) = c.top_left(w, h); + (i, Rect::new(x, y, w, h)) + }) + .collect(), + _ => self.pack(&nodes, inner.w, &ctx, depth).0, + }; + let sibs = SomIndices::of(&nodes); + for (i, r) in placed { + if let Some(f) = g.fields.get(i) { + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); + let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); + let chosen = selected.as_deref() == Some(on.as_str()); + self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; + } + } + Ok(()) + } + + /// A picture scaled to fit `inner`, keeping its shape, from its top left. + pub(super) fn draw_image(&mut self, data: &[u8], content_type: &str, inner: Rect) -> Result<(), XfaError> { + self.image_bytes = self.image_bytes.saturating_add(data.len()); + if self.image_bytes > MAX_IMAGE_BYTES { + return Err(XfaError::TooLarge("the form's pictures are too large together to lay out".into())); + } + let fitted = match crate::image::size(data) { + Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => { + let k = (inner.w / iw as f64).min(inner.h / ih as f64); + Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k) + } + _ => inner, + }; + self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) + } + + /// A field name made from `base` that no earlier field of the form has: the first keeps it, + /// later ones get `_2`, `_3`, …. + pub(super) fn unique_name(&mut self, base: &str) -> String { + let base = clean_name(base); + let n = self.names.entry(base.clone()).or_insert(0); + *n += 1; + if *n == 1 { base } else { format!("{base}_{n}") } + } +} diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs new file mode 100644 index 0000000000..40ec1497de --- /dev/null +++ b/crates/xfa/src/layout/field.rs @@ -0,0 +1,305 @@ +use super::common::{button_action, date_pattern}; +use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; +use super::*; + +impl Layouter<'_> { + /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its + /// widget, or its value as plain text when it is non-interactive, a barcode, or a text field + /// taller than its content area. `radio` is set for a group's radio button; `sib` is its SOM index. + pub(super) fn draw_field( + &mut self, + f: &Field, + rect: Rect, + ctx: &Ctx, + radio: Option<(&str, &str, Option, bool)>, + sib: usize, + ) -> Result<(), XfaError> { + if let Some(b) = &f.common.border + && f.ui != Ui::Button + { + self.draw_border(b, rect)?; + } + let fctx = ctx.child(&f.common); + let inner = rect.inset(&f.common.margin); + let (mut ui, mut cap_rect) = (inner, None); + if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { + let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); + let r = reserve.max(0.0); + match placement { + Placement::Top => { + cap_rect = Some(Rect::new(inner.x, inner.y, inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y + r, inner.w, inner.h - r); + } + Placement::Bottom => { + cap_rect = Some(Rect::new(inner.x, inner.bottom() - r.min(inner.h), inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y, inner.w, inner.h - r); + } + Placement::Right => { + cap_rect = Some(Rect::new(inner.right() - r.min(inner.w), inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x, inner.y, inner.w - r, inner.h); + } + _ => { + cap_rect = Some(Rect::new(inner.x, inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x + r, inner.y, inner.w - r, inner.h); + } + } + } + if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) + && cap.presence == Presence::Visible + { + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + self.draw_value_text(&cap.value, cr, &font, ¶)?; + } + let som_path = || { + if fctx.som.is_empty() { + format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field")) + } else { + format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field")) + } + }; + match f.ui { + Ui::Unknown => { + self.warn_once("a field of an unknown kind is left blank"); + return Ok(()); + } + Ui::ImageEdit => { + // The picture the data holds (base64, as Designer saves it) or the template's. + let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into()); + let text: Vec = self + .data + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) + .map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect()) + .unwrap_or_default(); + let from_data = if text.is_empty() { + None + } else if text.len() > MAX_DATA_IMAGE / 3 * 4 + 4 { + self.warn_once(format!("{field_name}: the picture in the data is larger than 32 MB and is left out")); + None + } else { + match base64::engine::general_purpose::STANDARD.decode(&text) { + Ok(d) if crate::image::kind(&d).is_some() => Some(d), + Ok(_) => { + self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); + None + } + Err(_) => { + self.warn_once(format!("{field_name}: the data holds no readable picture (not base64) and is left out")); + None + } + } + }; + let box_ = ui.inset(&f.ui_margin); + match (&from_data, &f.value) { + (Some(d), _) => self.draw_image(d, "", box_)?, + (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, + _ => {} + } + // Then the field itself, which clicking gives a new picture. + } + Ui::Barcode => { + // The value as text, where the bars would be. + self.warn_once("barcode fields show their value as text, not as bars"); + let value = self + .data + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) + .map(str::to_string) + .or_else(|| f.value.plain()) + .unwrap_or_default(); + if !value.trim().is_empty() { + let mut para = fctx.para.clone(); + para.h_align = HAlign::Center; + self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?; + } + return Ok(()); + } + _ => {} + } + if f.access == Access::NonInteractive && f.ui != Ui::Button { + // Shown as text, not as a field. + return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); + } + // A text field that grew past what a content area holds can't be one widget: its text + // is drawn as lines the page breaks fall between, and stays what the data holds. + if f.grows_with_text() && rect.h > self.cursor().0.h + 0.01 { + let value = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + if let Some(value) = value.filter(|t| !t.trim().is_empty()) { + let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); + self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); + let m = Margin { + top: f.ui_margin.top + 2.0, + bottom: f.ui_margin.bottom + 2.0, + left: f.ui_margin.left + 2.0, + right: f.ui_margin.right + 2.0, + }; + let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; + // In the face it was measured in, so it takes the lines it was given. + let face = Self::field_text_face(f, &fctx.font); + let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_, _| String::new()); + return self.push_block(block); + } + } + let face = match (&f.ui, &f.caption) { + // Buttons show their caption in the caption's font. + (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), + _ => Face::of(&fctx.font).unscaled(), + }; + let h_align = match (&f.ui, &f.caption) { + (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, + _ => fctx.para.h_align, + }; + let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); + let som = som_path(); + let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); + // The data's value wins over the template's default. + let data_value: Option = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + // A choice list's default is a saved value too. + let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty()); + let plain_value = data_value.clone().or_else(|| template_default.clone()); + let (kind, rect, border, value, tooltip) = match f.ui { + Ui::CheckButton => { + let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); + // Without a caption the box sits at the right of the content area, as if a + // caption on its left took the rest, so the paragraph's alignment has no room to + // act on; beside a caption that alignment places it. The box's own margin doesn't + // move it. + let x = match (cap_rect, fctx.para.h_align) { + (None, _) | (Some(_), HAlign::Right) => ui.right() - s, + (Some(_), HAlign::Center) => ui.x + (ui.w - s) / 2.0, + (Some(_), HAlign::Left | HAlign::Justify) => ui.x, + }; + let y = match fctx.para.v_align { + VAlign::Top => ui.y, + VAlign::Middle => ui.y + (ui.h - s) / 2.0, + VAlign::Bottom => ui.bottom() - s, + }; + let r = Rect::new(x, y, s, s); + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); + let (kind, tip, checked) = match radio { + Some((group, on_value, tip, chosen)) => ( + WidgetKind::Radio { group: group.to_string(), on: on_value.to_string(), round: f.check_round }, + tip.or_else(|| f.tooltip.clone()), + chosen, + ), + None => ( + WidgetKind::CheckBox { on: on.clone(), round: f.check_round }, + f.tooltip.clone(), + plain_value.as_deref().is_some_and(|v| is_on(v, &on)), + ), + }; + let border = widget_border(f.ui_border.as_ref(), true); + (kind, r, border, checked.then(|| on.clone()), tip) + } + Ui::Button => { + let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string(); + // A button's box is its field border, around the whole field (its margins pad + // the caption, not the box); without one it is invisible (a link over text, say). + let border = match &f.common.border { + Some(b) => widget_border(Some(b), false), + None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }, + }; + (WidgetKind::Button { caption }, rect, border, None, f.tooltip.clone()) + } + Ui::DateTimeEdit => { + let pattern = date_pattern(f.picture.as_deref()); + // The data holds the date in the field's data picture, or else as an ISO date; the + // field shows it in its own pattern. + let data_pattern = data_date_letters(f); + let shown = plain_value + .as_ref() + .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() }); + (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) + } + Ui::ChoiceList => { + let options: Vec<(String, String)> = f + .items + .iter() + .enumerate() + .map(|(i, shown)| (f.item_values.get(i).cloned().unwrap_or_else(|| shown.clone()), shown.clone())) + .collect(); + // The data holds saved values (several on separate lines); shown text is taken + // too, as a user typing into an editable list leaves it. + let value = plain_value.as_ref().map(|v| f.saved_value(v)); + let kind = WidgetKind::Choice { options, list_box: f.choice.list_box, multi: f.choice.multi, editable: f.choice.editable }; + (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) + } + Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), + Ui::ImageEdit => { + let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + (WidgetKind::Image, ui, none, None, f.tooltip.clone()) + } + Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), + _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), + }; + if rect.w < 1.0 || rect.h < 1.0 { + return Ok(()); + } + let action = if f.ui == Ui::Button { button_action(&f.scripts) } else { None }; + let items: Vec = match &kind { + WidgetKind::CheckBox { on, .. } => vec![on.clone(), f.items.get(1).cloned().unwrap_or_default()], + WidgetKind::Radio { on, .. } => vec![on.clone()], + _ => Vec::new(), + }; + self.fields += 1; + self.push(Item::Widget(Box::new(Widget { + rect, + kind, + name, + som, + tooltip, + face, + h_align, + multiline: f.multiline, + max_chars: f.max_chars, + read_only, + border, + value, + default: template_default, + action, + items, + no_scroll: f.no_scroll, + }))) + } +} + +/// The room a caption without a reserve takes beside a check box: all the box leaves of a +/// content area `w` wide (and `h` tall, when known), the box at the size it can be drawn there. +/// `None` for any other field, or a caption with a reserve of its own. +pub(super) fn auto_check_reserve(f: &Field, placement: Placement, w: f64, h: Option) -> Option { + if f.ui != Ui::CheckButton || f.caption.as_ref().is_none_or(|c| c.reserve.is_some()) { + return None; + } + let s = f.check_size.unwrap_or(10.0).min(w.max(1.0)).min(h.map_or(f64::INFINITY, |h| h.max(1.0))); + let rest = match (placement, h) { + (Placement::Top | Placement::Bottom, Some(h)) => h - s, + (Placement::Top | Placement::Bottom, None) => return None, + _ => w - s, + }; + Some(rest.max(0.0)) +} + +/// The widget's own border from the `` border element. Without one, a check box gets a +/// solid box, as Designer draws it, and any other field has no box of its own. +fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { + let Some(b) = b else { + if !check { + return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + } + return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; + }; + let vis = b.visible_edges(); + let shape = match vis { + [false, false, false, false] => BorderShape::None, + [false, false, true, false] => BorderShape::Underline, + _ => BorderShape::Full, + }; + let edge = (0..4).find(|&i| vis[i]).map(|i| b.edges[i]).unwrap_or_default(); + WidgetBorder { + color: (shape != BorderShape::None).then_some(edge.color.0), + fill: b.fill().map(|c| c.0), + shape, + width: edge.thickness.clamp(0.1, 12.0), + } +} diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs new file mode 100644 index 0000000000..48c57cd888 --- /dev/null +++ b/crates/xfa/src/layout/flow.rs @@ -0,0 +1,310 @@ +use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; +use super::items::Item; +use super::*; + +impl Layouter<'_> { + /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, + /// in the next when it fits there, and across as many as it takes when it is taller than a + /// content area (a positioned or fixed-height subform holding more than a page). + fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let (rect, y, _) = self.cursor(); + if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + self.make_room(h, 0.0)?; + } + let (rect, y, _) = self.cursor(); + let first = self.pages.last().map_or(0, |p| p.items.len()); + self.leaves.clear(); + self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); + // What was drawn, when it is lower than the block's own height says (content that + // outgrows a fixed height) and ends off the page. + let drawn = self + .pages + .last() + .and_then(|p| p.items.get(first..)) + .unwrap_or_default() + .iter() + .filter_map(solid_extent) + .map(|e| e.1) + .fold(y + h, f64::max); + if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { + self.leaves.clear(); + self.place(h); + return Ok(()); + } + self.split_block(first, y, drawn) + } + + /// Remember that the items the last page got since `start` are one field or draw at + /// `rect`, to be kept together by a page break. + pub(super) fn keep_whole(&mut self, start: usize, rect: Rect) { + let end = self.pages.last().map_or(0, |p| p.items.len()); + if end > start { + self.leaves.push((start, end, rect.y, rect.bottom())); + } + } + + pub(super) fn item_count(&self) -> usize { + self.pages.last().map_or(0, |p| p.items.len()) + } + + /// Spread the items the last page holds from index `first` on (a block drawn from `top` to + /// `end`, past the bottom of its content area) over this and the following content areas. + /// Breaks fall between text, pictures and fields, never through one that fits in an area; + /// fills and lines are cut at the break. + fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { + let items: Vec = match self.pages.last_mut() { + Some(p) if first <= p.items.len() => p.items.split_off(first), + _ => return Ok(()), + }; + // Each item with the extent of the field or draw it belongs to: such a leaf moves + // whole (its border with its text), unless it is taller than a content area. + let area_h = self.cursor().0.h; + let mut extents: Vec> = vec![None; items.len()]; + for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { + if bottom - top <= area_h + 0.01 + && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) + { + slots.fill(Some((top, bottom))); + } + } + let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); + let origin_x = self.cursor().0.x; + // An item's place on the current page is its drawn place moved by (dx, -shift). + let (mut dx, mut shift) = (0.0, 0.0); + let mut seg_top = top; + let mut first_segment = true; + loop { + let (rect, _, placed) = self.cursor(); + let limit = rect.bottom() + shift; + let mut cut = limit; + if rect.h < 1.0 { + // No room to split into: leave the rest here. + cut = f64::INFINITY; + } else { + // The lowest break at or above the bottom that crosses no solid item: going + // through them from the lowest top up, a break moved to an item's top can't + // cross one already passed. + let mut solid: Vec<(f64, f64)> = + rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + solid.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (t, b) in solid { + if t < cut - 0.01 && b > cut + 0.01 { + cut = t; + } + } + if cut <= seg_top + 0.01 { + // An item taller than what is left. Start it in a fresh area if this one + // already holds something; otherwise cut through it. + cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; + } + } + let mut here = Vec::new(); + let mut below = Vec::new(); + for (item, kept) in rest { + if let Some((top, _)) = kept { + if top < cut - 0.01 { + here.push(item) + } else { + below.push((item, kept)) + } + continue; + } + match divide(item, cut) { + (Some(a), None) => here.push(a), + (None, Some(b)) => below.push((b, None)), + (Some(a), Some(b)) => { + here.push(a); + below.push((b, None)); + } + (None, None) => {} + } + } + if let Some(p) = self.pages.last_mut() { + p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); + } + if below.is_empty() { + if let Some(c) = &mut self.cur { + c.y = (end - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + self.overflowed(end - shift); + return Ok(()); + } + rest = below; + seg_top = cut; + first_segment = false; + self.next_area()?; + let (rect, _, _) = self.cursor(); + shift = cut - rect.y; + dx = rect.x - origin_x; + } + } + + fn page_has_flow(&self) -> bool { + self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) + } + + // ───────────────────────────────────────────────────────────────────────── flow + + /// Flow `node` (a subform; anything else is ignored) into the content areas. + pub(super) fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let Node::Subform(sf) = node else { return Ok(()) }; + self.check_measures()?; + if sf.break_before_page && self.page_has_flow() { + self.break_frames(0.0, true)?; + } + // Data adds instances of a repeating subform (rows added in another viewer). + let instances = self.instances(node, &ctx.som); + self.warn_truncated(node, &ctx.som, instances); + let ctx_parent = ctx; + for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + let ctx = ctx_parent.with_som(&sf.common, sib + i); + let (rect, _, _) = self.cursor(); + let (x, w) = avail.unwrap_or((rect.x, rect.w)); + let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); + if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { + // Not splittable: one block. + let (_, h) = self.size_of(node, w, ctx_parent, depth); + self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; + continue; + } + let m = sf.common.margin; + // Its border and fill go round what it takes from here, a part on each content area. + let framed = match self.flow_mark() { + Some(part) => { + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + true + } + None => false, + }; + self.advance(m.top); + let inner_x = x + m.left; + let inner_w = (w - m.horizontal()).max(1.0); + match sf.layout { + Layout::Table => self.flow_table(sf, inner_x, inner_w, &ctx, depth)?, + _ => { + let sibs = SomIndices::of(&sf.children); + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sibs.at(ci); + if let Node::Subform(_) = child + && splittable(child) + { + self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); + self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; + } + } + } + self.advance(m.bottom); + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; + } + } + if sf.break_after_page { + self.break_frames(0.0, true)?; + } + Ok(()) + } + + pub(super) fn columns(&self, sf: &Subform, inner_w: f64) -> Vec { + if !sf.column_widths.is_empty() { + return sf.column_widths.clone(); + } + let cells = sf.children.iter().filter_map(|c| if let Node::Subform(r) = c { Some(r.children.len()) } else { None }).max().unwrap_or(1).max(1); + vec![inner_w / cells as f64; cells] + } + + fn flow_table(&mut self, sf: &Subform, x: f64, inner_w: f64, ctx: &Ctx, depth: usize) -> Result<(), XfaError> { + let cols = self.columns(sf, inner_w); + let leader = + sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + // The overflow leader starts each part after the first, before the row that moved. + let lead = leader.and_then(|li| match sf.children.get(li) { + Some(Node::Subform(l)) => Some((li, l.as_ref())), + _ => None, + }); + let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); + let sibs = SomIndices::of(&sf.children); + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sibs.at(ci); + if let Node::Subform(row) = child + && row.layout == Layout::Row + { + // Rows added as data (in another viewer) repeat the row. + let instances = self.instances(child, &ctx.som); + self.warn_truncated(child, &ctx.som, instances); + for inst in 0..instances { + let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); + let led = lead.filter(|(li, _)| *li != ci); + if self.make_room(h, led.map_or(0.0, |_| lead_h))? + && let Some((li, l)) = led + { + let (_, y, _) = self.cursor(); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; + self.place(lead_h); + } + let (_, y, _) = self.cursor(); + self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; + self.place(h); + } + continue; + } + if splittable(child) + && let Node::Subform(_) = child + { + self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); + self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── sizing + + /// The size a node takes when given `avail_w` of width. Containers are measured once per + /// (node, width, depth): measuring a width-less container measures its content twice (for + /// its width, then its height), which would otherwise be exponential in the nesting. + /// How many instances of `node` to lay out under the container at `parent_som`: a + /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. + pub(super) fn instances(&self, node: &Node, parent_som: &str) -> usize { + let Node::Subform(sf) = node else { return 1 }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + instance_count(&sf.occur, from_data) + } + + /// The data holds more instances of `node` than `laid_out`: say so, since calculations + /// then see only those. + fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + let Node::Subform(sf) = node else { return }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + if from_data > laid_out { + self.warn_once(format!( + "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", + sf.common.name.as_deref().unwrap_or("a subform") + )); + } + } +} diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs new file mode 100644 index 0000000000..51383792c1 --- /dev/null +++ b/crates/xfa/src/layout/items.rs @@ -0,0 +1,145 @@ +use super::*; + +/// How far below its baseline an underline is drawn, in the text's size: an underline is the +/// level line pushed just before its span, this far below it (the PDF writer tells it apart from +/// a rule by this). +pub(crate) const UNDERLINE_DROP: f64 = 0.1; + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, + /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. + Rounded(Box), + Text(Span), + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, + Widget(Box), +} + +/// A box with rounded corners ([`Item::Rounded`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Rounded { + pub rect: Rect, + /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's + /// shorter side. + pub radii: [f64; 4], + /// Corners whose curve goes into the box rather than around it, in the same order. + pub inverted: [bool; 4], + pub fill: Option<[f64; 3]>, + /// The edge: its width, colour and whether it is dashed. + pub edge: Option<(f64, [f64; 3], bool)>, + /// A page break cut the box here: that side has no edge, and its corners are square. + pub open_top: bool, + pub open_bottom: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WidgetKind { + Text, + /// A text field whose characters are shown as asterisks. + Password, + /// A drop-down (`list_box` false) or list box: `options` are (saved value, shown text). + Choice { + options: Vec<(String, String)>, + list_box: bool, + multi: bool, + editable: bool, + }, + /// A signature field, to be signed in the app. + Signature, + /// An image field: a push button that picks a picture (the picture itself is page content). + Image, + /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). + Date(String), + CheckBox { + on: String, + round: bool, + }, + Radio { + group: String, + on: String, + round: bool, + }, + Button { + caption: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderShape { + None, + Full, + Underline, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WidgetBorder { + pub color: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + pub shape: BorderShape, + pub width: f64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Reset, + Print, + SaveAs, + Url(String), + /// Any other click script: run by the XFA scripting engine. + Script(String), +} + +/// An interactive field to create as an AcroForm widget. +#[derive(Clone, Debug, PartialEq)] +pub struct Widget { + pub rect: Rect, + pub kind: WidgetKind, + /// The PDF field name (unique in the form, no periods). + pub name: String, + /// The template's scripting object model path, for data round trips. + pub som: String, + pub tooltip: Option, + pub face: Face, + pub h_align: HAlign, + pub multiline: bool, + pub max_chars: Option, + pub read_only: bool, + pub border: WidgetBorder, + /// The field's value: saved (bound) values for choice lists, newline-separated when several. + pub value: Option, + /// The template's default value (what a reset restores); data values are not defaults. + pub default: Option, + pub action: Option, + /// Check boxes: the on and off values of the data; radio buttons: the on value. + pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub items: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Form { + pub pages: Vec, + pub fields: usize, + pub warnings: Vec, +} diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs new file mode 100644 index 0000000000..5bccf4005f --- /dev/null +++ b/crates/xfa/src/layout/measure.rs @@ -0,0 +1,366 @@ +use super::field::auto_check_reserve; +use super::common::clamp_opt; +use super::*; + +impl Layouter<'_> { + /// The width and height `node` takes in `avail_w`: its own size, else what its content needs + /// (containers are measured once per width and depth, and measuring stops at its caps). + pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + let spent = self.measures.get().saturating_add(1); + self.measures.set(spent); + if depth > MAX_DEPTH || spent > MAX_MEASURES { + return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); + } + // Only containers recurse; their addresses are stable for the whole layout (the + // template is borrowed, the root and page areas are held by the layouter). + let key = match node { + Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), + Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), + _ => None, + }; + if let Some(key) = key + && let Ok(cache) = self.measured.try_borrow() + && let Some(m) = cache.get(&key) + && m.font == ctx.font + && m.para == ctx.para + { + return m.size; + } + let size = self.measure(node, avail_w, ctx, depth); + if let Some(key) = key + && let Ok(mut cache) = self.measured.try_borrow_mut() + { + cache.insert(key, Measured { font: ctx.font.clone(), para: ctx.para.clone(), size }); + } + size + } + + /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). + /// Without a width, a container, a text draw or a button is as wide as its content (at most + /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. + fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + // The node's own SOM: its repeating children count their instances under it, and a + // growing text field finds its value at it. Measurements are cached per template node, + // so instance 0 stands for all of them. + let ctx = ctx.with_som(c, 0); + let som = ctx.som.clone(); + // A container without a width is as wide as its content (a radio group beside its + // question, say), and so is a text label or a button (as wide as its caption), within + // what is available; any other field without one takes what is available. + let w = match c.w { + Some(w) => w, + None => match node { + Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { + let inner = (avail_w - c.margin.horizontal()).max(1.0); + (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) + } + Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::Button => f + .caption + .as_ref() + .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) + .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), + Node::Field(_) => avail_w, + }, + }; + let w = clamp_opt(w, c.min_w, c.max_w); + let h = match c.h { + Some(h) => h, + None => { + let inner_w = (w - c.margin.horizontal()).max(1.0); + let content = match node { + Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), + Node::Field(f) => self.field_height(f, inner_w, &ctx), + Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), + Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), + Node::Subform(s) => match s.layout { + Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), + // Each instance of a repeating child takes its height again. + Layout::Tb => s + .children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) + .sum(), + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, + Layout::Table => { + let cols = self.columns(s, inner_w); + s.children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| match k { + Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), + _ => self.size_of(k, inner_w, &ctx, depth + 1).1, + }) + .sum() + } + Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), + }, + }; + content + c.margin.vertical() + } + }; + // A flowed subform is as tall as what it holds: other viewers do not stretch one to + // the `minH` a designer left on it. + let min_h = match node { + Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, + _ => c.min_h, + }; + (w, clamp_opt(h, min_h, c.max_h)) + } + + /// The natural width of a container's content within `avail_w`. + fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let group: Vec; + let (children, layout): (&[Node], Layout) = match node { + Node::Subform(s) => { + if s.layout == Layout::Table && !s.column_widths.is_empty() { + return s.column_widths.iter().sum::().min(avail_w); + } + (&s.children, s.layout) + } + Node::ExclGroup(g) => { + // Fields are leaves: measuring these copies caches nothing. + group = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + (&group, g.layout) + } + Node::Area(a) => (&a.children, Layout::Positioned), + _ => return avail_w, + }; + let visible = children.iter().filter(|k| k.common().presence.occupies()); + match layout { + Layout::LrTb | Layout::RlTb | Layout::Row => { + let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); + if sum <= avail_w + 0.05 { sum } else { avail_w } + } + Layout::Positioned => visible + .map(|k| { + let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); + k.common().top_left(w, h).0 + w + }) + .fold(0.0, f64::max) + .min(avail_w), + Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), + } + } + + /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying + /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws + /// nothing. + fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return None, + }; + // No text (a bordered spacer or rule): nothing to size to, so it keeps what it has room for. + let w = text::natural_width(&rich, para, &Face::of(font), &|id, max| self.embed(id, max)); + (w > 0.0).then_some(w + 0.01) + } + + /// How tall value `v` is laid out `w` wide: text wrapped in the context's font and paragraph, + /// an image at its aspect ratio, and nothing for lines, rectangles and empty values. + fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { + match v { + Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), + Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), + Value::Image { data, .. } => crate::image::size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }), + Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0, + } + } + + /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None` + /// when it has no caption that takes room (a button's is drawn inside its box). + pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { + let cap = f.caption.as_ref()?; + if !cap.presence.occupies() || f.ui == Ui::Button { + return None; + } + if cap.value.is_empty() && cap.reserve.is_none() { + return None; + } + // A check box has no text for an inline caption to run with: it is not shown. + if f.ui == Ui::CheckButton && cap.placement == Placement::Inline { + return None; + } + let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + let reserve = cap.reserve.unwrap_or_else(|| match placement { + Placement::Top | Placement::Bottom => { + let rich = match &cap.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, inner_w, ¶, &Face::of(&font), &|id, max| self.embed(id, max)) + } + _ => (Face::of(&font).width(&cap.value.plain().unwrap_or_default()) + 2.0).min(inner_w * 0.6), + }); + Some((placement, reserve)) + } + + /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check + /// box, a button's caption, or one line of text or three when multi-line, more for a text field + /// that grows) plus a caption above or below it, or the taller of the two for one beside it. + fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + let font = ctx.font.apply(&f.common.font); + let lines = if f.multiline { 3.0 } else { 1.0 }; + let ui = match f.ui { + Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), + // A button shows its caption: as tall as the caption's text in its own font. + Ui::Button if f.caption.as_ref().is_some_and(|c| !c.value.is_empty()) => { + let cap = f.caption.as_ref().map_or(0.0, |c| { + let rich = match &c.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + let w = (inner_w - f.ui_margin.horizontal()).max(1.0); + text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) + }); + cap + f.ui_margin.vertical() + } + _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), + }; + match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, r)) => ui + r, + Some((placement, r)) => { + // A check box's caption without a reserve is drawn in all the box leaves. + let w = match auto_check_reserve(f, placement, inner_w, None) { + Some(auto) => auto, + None => inner_w - r, + }; + let cap = f.caption.as_ref().map(|c| { + let rich = match &c.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { + self.embed(id, max) + }) + }); + ui.max(cap.unwrap_or(0.0)) + } + None => ui, + } + } + + /// The face a field's text is laid out in: at its horizontal scale when the layout draws + /// the text itself (a non-interactive field), as designed when the forms layer draws it in + /// a widget, whose appearances have no horizontal scaling. + pub(super) fn field_text_face(f: &Field, font: &Font) -> Face { + let face = Face::of(font); + if f.access == Access::NonInteractive { face } else { face.unscaled() } + } + + /// The height the text of a multi-line text field takes (its value in the data, else the + /// template's), for a field without a fixed height, which grows to hold its text. `ctx` + /// is the field's own (its SOM ends in the field). Zero for anything else. + fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + if !f.grows_with_text() || f.common.name.is_none() { + return 0.0; + } + let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; + // Beside a caption the text has what the caption leaves; a widget's text has 2pt of + // padding on each side, a non-interactive field's (drawn as text) none, so it wraps + // where it is drawn. + let beside = match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, + Some((_, r)) => r, + }; + let padding = if f.access == Access::NonInteractive { 0.0 } else { 4.0 }; + let w = (inner_w - beside - f.ui_margin.horizontal() - padding).max(1.0); + let font = ctx.font.apply(&f.common.font); + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_, _| String::new()) + } + + /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked + /// top to bottom, at their own positions, or packed in rows. + fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + match g.layout { + Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), + Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), + _ => self.packed_height(&nodes, inner_w, ctx, depth), + } + } + + fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| { + let c = k.common(); + let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + c.top_left(w, h).1 + h + }) + .fold(0.0, f64::max) + } + + /// Left-to-right packing: rows of children, wrapped at the available width, as (child, + /// instance, place). A repeating subform is packed once per instance it has under the + /// container at `som`. + pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { + let mut out = Vec::new(); + let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); + for (i, k) in children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); + for instance in 0..self.instances(k, som) { + if x > 0.0 && x + w > inner_w + 0.05 { + y += line_h; + x = 0.0; + line_h = 0.0; + } + out.push((i, instance, Rect::new(x, y, w, h))); + x += w; + line_h = line_h.max(h); + } + } + (out, y + line_h) + } + + /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). + pub(super) fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); + (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) + } + + fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.pack(children, inner_w, ctx, depth).1 + } + + /// Cell rectangles of a row (relative to the row's inner box) and the row height. + pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let ctx = ctx.child(&row.common); + let mut out = Vec::new(); + let mut col = 0usize; + let mut x = 0.0; + let mut h: f64 = 0.0; + for (i, k) in row.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let span = k.common().col_span.max(1); + let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; + let (_, ch) = self.size_of(k, w, &ctx, depth + 1); + out.push((i, Rect::new(x, 0.0, w, ch))); + h = h.max(ch); + x += w; + col += span; + } + let h = match row.common.h { + Some(rh) => rh, + None => h + row.common.margin.vertical(), + }; + (out, clamp_opt(h, row.common.min_h, row.common.max_h)) + } + + pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.cells(row, cols, inner_w, ctx, depth).1 + } +} diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs new file mode 100644 index 0000000000..13047058b3 --- /dev/null +++ b/crates/xfa/src/layout/pages.rs @@ -0,0 +1,280 @@ +use super::items::{Item, Page, UNDERLINE_DROP}; +use super::*; + +impl Layouter<'_> { + pub(super) fn warn_once(&mut self, msg: impl Into) { + let m = msg.into(); + if self.warned.insert(m.clone()) && self.warnings.len() < 100 { + self.warnings.push(m); + } + } + + fn page_no(&self) -> usize { + self.pages.len() + } + + /// The text an embedded field `id` shows, at most `max` bytes of a field value (the page + /// number and count are a few bytes; the text layout cuts them). + pub(super) fn embed(&self, id: &str, max: usize) -> String { + let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { + Some(PageRole::Number) => self.page_no().to_string(), + Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), + None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(), + }; + #[cfg(test)] + EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64))); + v + } + + /// `TooLarge` once the measurement budget is spent (sizes measured after that are guesses). + pub(super) fn check_measures(&self) -> Result<(), XfaError> { + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("the form is nested too deeply to lay out".into())); + } + Ok(()) + } + + pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> { + self.check_measures()?; + self.items += 1; + if self.items > MAX_ITEMS { + return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); + } + if let Some(p) = self.pages.last_mut() { + p.items.push(item); + } + Ok(()) + } + + pub(super) fn push_block(&mut self, b: Block) -> Result<(), XfaError> { + for s in b.spans { + if s.face.underline { + let w = s.face.width(&s.text); + let y = s.baseline + s.face.size * UNDERLINE_DROP; + self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; + } + if !s.text.trim().is_empty() { + self.push(Item::Text(s))?; + } + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── pages + + pub(super) fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + pub(super) fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { + if self.pages.len() >= MAX_PAGES { + return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); + } + let areas = Rc::clone(&self.areas); + let Some(area) = areas.get(idx) else { + return Err(XfaError::Malformed("the template has no page area".into())); + }; + if let Some(u) = self.uses.get_mut(idx) { + *u += 1; + } + self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); + let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); + self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); + // Master page content: positioned draws and fields. + let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; + let page_rect = Rect::new(0.0, 0.0, area.width, area.height); + let sibs = SomIndices::of(&area.items); + for (i, item) in area.items.iter().enumerate() { + self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; + } + Ok(()) + } + + /// Move to the next content area, or the next page. + pub(super) fn next_area(&mut self) -> Result<(), XfaError> { + if let Some(c) = &self.cur + && let Some(area) = self.areas.get(c.area) + && let Some(next) = area.content.get(c.content + 1).copied() + { + let i = c.content + 1; + self.cur = Some(Cursor { area: c.area, content: i, rect: next, y: next.y, placed: false }); + return Ok(()); + } + self.new_page() + } + + /// The current content area, how far down it the flow is, and whether anything has been + /// placed in it; before the first page, an empty letter-size page from its top. + pub(super) fn cursor(&self) -> (Rect, f64, bool) { + match &self.cur { + Some(c) => (c.rect, c.y, c.placed), + None => (Rect::new(0.0, 0.0, 612.0, 792.0), 0.0, false), + } + } + + /// Where the flow is now ([`FlowMark`]); `None` before the first page. + pub(super) fn flow_mark(&self) -> Option { + let c = self.cur.as_ref()?; + Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) + } + + /// Whether the flow is still in the content area `m` was taken in. + fn on_area(&self, m: &FlowMark) -> bool { + self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) + } + + /// Whether page `page` (an index) got anything from item `at` on. + fn has_items_from(&self, page: usize, at: usize) -> bool { + self.pages.get(page).is_some_and(|p| p.items.len() > at) + } + + /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as + /// another viewer breaks such a section on a synthetic form: each one's part here ends after + /// its content and its bottom margin (the innermost first, each inside the next), the flow + /// moves to the next content area, and each starts a new part there after its top margin + /// (the outermost first). The top margins take only what the `need` points of what comes next + /// leave of the new area: a child that fits a fresh area but not after the margins would + /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a + /// child's own page break), which breaks the subforms around it the same way. + pub(super) fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { + let (rect, mut y, _) = self.cursor(); + let page = self.pages.len().saturating_sub(1); + let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); + let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { + y = (y + f.margin.bottom).min(rect.bottom()); + if !here { + f.lost = true; + } else if filled && y - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); + } + } + if to_page { + self.new_page()?; + } else { + self.next_area()?; + } + let (rect, y, _) = self.cursor(); + let mut room = (rect.bottom() - y - need).max(0.0); + for i in 0..self.frames.len() { + let Some(mark) = self.flow_mark() else { break }; + let Some(f) = self.frames.get_mut(i) else { break }; + f.part = mark; + // `max` before `min`: a NaN margin counts as none and leaves `room` a number. + let top = f.margin.top.max(0.0).min(room); + room = (room - top).max(0.0); + self.advance(top); + } + Ok(()) + } + + /// Close flowed subform `f` once its content is placed: its last part ends where the flow + /// is, and its border (edges and fill) is drawn round each part, closed on every side, under + /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) + /// is drawn without its border, and the layout says so. + pub(super) fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { + let Some(b) = f.border.take() else { return Ok(()) }; + if b.fill().is_none() && b.visible_edges() == [false; 4] { + return Ok(()); + } + let (area, end, _) = self.cursor(); + // The bottom margin stays in the content area, as at a break; content that itself ran + // past it has already cost the section its border ([`Self::overflowed`]). + let end = end.min(area.bottom()).max(end - f.margin.bottom); + let page = self.pages.len().saturating_sub(1); + if !self.on_area(&f.part) { + f.lost = true; + } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); + } + if f.lost { + self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); + return Ok(()); + } + // The last part first: a part's place on its page stays where it was found. + for (page, at, rect) in f.parts.into_iter().rev() { + self.insert_border(&b, rect, page, at)?; + } + Ok(()) + } + + /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item + /// `at` on. + fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + let before = self.pages.last().map_or(0, |p| p.items.len()); + self.draw_border(b, rect)?; + let drawn: Vec = match self.pages.last_mut() { + Some(p) => { + let from = before.min(p.items.len()); + p.items.drain(from..).collect() + } + None => return Ok(()), + }; + if let Some(target) = self.pages.get_mut(page) { + let at = at.min(target.items.len()); + target.items.splice(at..at, drawn); + } + Ok(()) + } + + /// Move the cursor down (margins and spacing: the area still counts as empty). + pub(super) fn advance(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + } + } + + /// Move past something that was placed. + pub(super) fn place(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + c.placed = true; + } + let (_, y, _) = self.cursor(); + self.overflowed(y); + } + + /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms + /// it is inside can't hold it in a part, and are drawn without their border. + pub(super) fn overflowed(&mut self, bottom: f64) { + let (rect, _, _) = self.cursor(); + if bottom > rect.bottom() + 0.01 { + for f in &mut self.frames { + f.lost = true; + } + } + } + + /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow + /// leader): stay, or move to the next content area / page. Returns whether a break happened. + /// Something that fits a fresh area breaks the flowed subforms it is in with it + /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, + /// and those subforms are drawn without their border ([`Self::finish_frame`]). + pub(super) fn make_room(&mut self, h: f64, after: f64) -> Result { + let (rect, y, placed) = self.cursor(); + if y + h <= rect.bottom() + 0.01 || !placed { + return Ok(false); + } + if h <= rect.h + 0.01 { + self.break_frames(h + after, false)?; + } else { + self.next_area()?; + } + Ok(true) + } +} From 0988209798f0ccc9826aab3894865ae6b1dedbed Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:11:40 +0000 Subject: [PATCH 059/175] refactor(xfa): split pdf.rs into pdf/ New modules: helpers, strip. --- crates/xfa/src/pdf.rs | 490 +---------------------------------- crates/xfa/src/pdf/common.rs | 200 ++++++++++++++ crates/xfa/src/pdf/strip.rs | 282 ++++++++++++++++++++ 3 files changed, 492 insertions(+), 480 deletions(-) create mode 100644 crates/xfa/src/pdf/common.rs create mode 100644 crates/xfa/src/pdf/strip.rs diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 23e21fc331..ab5d94bb45 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -12,6 +12,16 @@ use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, Widg use crate::model::Rect; use crate::text::{Face, Family, Span}; +mod common; +mod strip; + +pub use common::jpeg_size; +pub(crate) use common::rounded; +use common::{all_faces, content_hash, empty_form, hex32, jpeg_info, js_action, n, parse_hex32, rgb, rgb_array, standard_font, state_name}; +pub(crate) use strip::USAGE_RIGHTS_REMOVED; +use strip::drop_usage_rights; +pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape}; + /// Private key on generated fields: the template's SOM path, so data can be put back. pub const SOM_KEY: &[u8] = b"PCSom"; /// Private key on the AcroForm: what laying the form out produced, so a reopened file is not @@ -26,209 +36,6 @@ pub const CLICK_KEY: &[u8] = b"PCXfaClick"; /// fields they hold. pub const NODE_KEY: &[u8] = b"PCXfaNode"; -/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT -/// markers). `None` for anything else. -pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { - jpeg_frame(data).map(|(w, h, _)| (w, h)) -} - -fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { - jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) -} - -/// The SHA-256 of a picture's bytes, to find its XObject again. -fn content_hash(data: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(data).into() -} - -fn hex32(h: &[u8; 32]) -> Vec { - h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() -} - -fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { - let mut out = [0u8; 32]; - if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { - return None; - } - for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { - *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; - } - Some(out) -} - -/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs -/// store inverted values). -fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { - if data.get(0..2)? != [0xFF, 0xD8] { - return None; - } - let mut i = 2usize; - let mut guard = 0; - let mut adobe = false; - while i + 4 <= data.len() && guard < 10_000 { - guard += 1; - if *data.get(i)? != 0xFF { - i += 1; - continue; - } - let marker = *data.get(i + 1)?; - if marker == 0xFF { - i += 1; - continue; - } - if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { - i += 2; - continue; - } - let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; - if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { - adobe = true; - } - let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); - if sof { - let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; - let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; - let comps = *data.get(i + 9)?; - return (w > 0 && h > 0).then_some((w, h, comps, adobe)); - } - i = i.checked_add(2)?.checked_add(len.max(2))?; - } - None -} - -/// Format a number for content streams. -fn n(v: f64) -> String { - let v = if v.is_finite() { v } else { 0.0 }; - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn rgb(c: [f64; 3]) -> String { - format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) -} - -fn rgb_array(c: [f64; 3]) -> Object { - Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) -} - -fn standard_font(base: &str) -> Object { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) -} - -/// Every face the content streams or `/DA` strings may name. -fn all_faces() -> Vec<(&'static str, &'static str)> { - let mut out = Vec::new(); - for fam in [Family::Helvetica, Family::Courier, Family::Times] { - for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { - out.push((fam.resource_name(b, i), fam.base_font(b, i))); - } - } - out -} - -/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, -/// then its edge inside it. A side a page break cut has no edge. -pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { - let mut c = String::new(); - if let Some(color) = r.fill { - c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); - } - if let Some((w, color, dashed)) = r.edge { - // The edge's centre line, half its width inside the box, so the edge stays in: a corner - // curving around the box tightens by as much, and one curving into it widens. - let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); - let most = inset.w.min(inset.h) / 2.0; - let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { - (v, _) if v <= 0.0 => 0.0, - (v, true) => (v + w / 2.0).min(most), - (v, false) => (v - w / 2.0).clamp(0.0, most), - }); - let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); - c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); - } - c -} - -/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` -/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when -/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): -/// those are where a page break cut the box. Ends with a space, for the paint operator. -fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { - let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); - let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); - // Corner `i`: its point, then where its curve starts and ends, going clockwise. - let corner = |i: usize| { - let r = radii.get(i).copied().unwrap_or(0.0); - match i { - 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), - 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), - 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), - _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), - } - }; - // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. - let curve = |i: usize| { - if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { - return String::new(); - } - let (p, a, b) = corner(i); - const K: f64 = 0.5523; - let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { - // Centred on the corner itself. - ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) - } else { - ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) - }; - format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) - }; - // Along corners `from`, joining them with straight sides. - let along = |from: &[usize]| { - let mut s = String::new(); - for &i in from { - s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); - } - s - }; - match open { - (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), - (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), - (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), - (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), - } -} - -/// A PDF appearance-state name for a check button's on value. -fn state_name(on: &str) -> String { - let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); - if s.is_empty() || s == "Off" { "Yes".into() } else { s } -} - -fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) -} - -fn js_action(js: &str) -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); - Object::Dict(a) -} - -/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). -pub(crate) const USAGE_RIGHTS_REMOVED: &str = - "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; - pub struct Written { pub pages: usize, pub fields: usize, @@ -1133,283 +940,6 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } -/// What [`strip`] took out, for the user to be told (the packets held more than the fields). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Stripped { - /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). - pub packets: Vec, - /// Data values in the datasets no field held (what the form's data had that its fields - /// didn't show); `None` when the template could not be read to tell. - pub unbound_data: Option, - /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. - pub script_buttons: usize, - pub image_fields: usize, - /// What the layout left out (pictures, rows past the cap), now for good. - pub layout_warnings: Vec, - /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. - pub usage_rights: bool, - /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). - pub field_keys: usize, -} - -impl Stripped { - /// What was dropped, as lines for the user (empty when nothing worth saying). - pub fn notes(&self) -> Vec { - let mut out = Vec::new(); - if !self.packets.is_empty() { - out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); - } - match self.unbound_data { - Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), - None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), - _ => {} - } - if self.script_buttons + self.image_fields > 0 { - out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); - } - for w in &self.layout_warnings { - out.push(format!("Kept as laid out: {w}")); - } - if self.usage_rights { - out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); - } - out - } -} - -/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct XfaShape { - /// `/NeedsRendering true`: the form expects to be laid out from the template. - pub needs_rendering: bool, - /// The AcroForm has fields of its own. - pub has_fields: bool, - /// PdfCraft laid the form out (the layout mark is there). - pub laid_out: bool, -} - -impl XfaShape { - /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out - /// (with their fields), or a static form's own pages and fields. A dynamic form that was - /// never laid out has only placeholder pages: stripping it would leave nothing. - pub fn strippable(self) -> bool { - self.laid_out || (!self.needs_rendering && self.has_fields) - } -} - -/// The document's XFA entry, if any, without decoding a packet. -pub fn xfa_shape(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let catalog = catalog.as_dict()?; - let acro = doc.resolve(catalog.get(b"AcroForm")?); - let acro = acro.as_dict()?; - acro.get(b"XFA")?; - let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); - let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); - let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); - Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) -} - -/// Most field dictionaries walked for their private keys. -const MAX_STRIP_FIELDS: usize = 1_000_000; - -/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out -/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and -/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and -/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) -/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no -/// longer lay out again. What the packets held beyond the fields is counted in the report, -/// so the user can be told; the caller decides whether the form is one to strip -/// ([`XfaShape::strippable`]). A document without XFA is left as it is. -pub fn strip(doc: &mut Document) -> Result { - let mut out = Stripped::default(); - let Some(root) = doc.root() else { return Ok(out) }; - let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; - let acro_obj = catalog.get(b"AcroForm").cloned(); - let Some(acro_obj) = acro_obj else { return Ok(out) }; - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; - // What is about to go, while it can still be read. - out.unbound_data = unbound_data(doc); - out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); - let mut streams: Vec = Vec::new(); - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut i = 0; - while i < items.len() { - let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); - let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; - if let Some(n) = &name { - out.packets.push(n.clone()); - } - if let Some(r) = stream.and_then(Object::as_ref) { - streams.push(r); - } - i += if name.is_some() { 2 } else { 1 }; - } - } - Object::Stream(s) => { - if let Some(r) = xfa.as_ref() { - streams.push(r); - } - // One XDP: the packets it holds are the envelope's children. - let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; - if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH - && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) - { - let root = parsed.root_element(); - if root.tag_name().name() == "xdp" { - out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); - } else { - out.packets.push(root.tag_name().name().to_string()); - } - } - if out.packets.is_empty() { - out.packets.push("xdp".into()); - } - } - _ => {} - } - acro.remove(b"XFA"); - for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { - acro.remove(key); - } - // The private keys on every field and widget, down the tree (each dictionary once). - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut seen = std::collections::HashSet::new(); - let mut todo: Vec = Vec::new(); - for r in fields.iter().filter_map(Object::as_ref) { - if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(r); - } - } - while let Some(r) = todo.pop() { - let Some(d) = doc.get(r).as_dict().cloned() else { continue }; - if d.contains(CLICK_KEY) { - out.script_buttons += 1; - } - if d.contains(IMAGE_FIELD_KEY) { - out.image_fields += 1; - } - let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); - if !private.is_empty() { - out.field_keys += private.len(); - doc.update_dict(r, |d| { - for k in &private { - d.remove(k); - } - })?; - } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - for k in kids.iter().filter_map(Object::as_ref) { - if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(k); - } - } - } - } - match acro_obj.as_ref() { - Some(r) => doc.set(r, Object::Dict(acro)), - None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), - } - catalog.remove(b"NeedsRendering"); - out.usage_rights = drop_usage_rights(doc, &mut catalog); - doc.set(root, Object::Dict(catalog)); - // The packet streams are referenced from nowhere else: freed, so an incremental save - // doesn't carry an unreachable copy of the data. - for r in streams { - doc.free(r); - } - doc.require_full_save(); - Ok(out) -} - -/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose -/// data path no field binds to). `None` when the template can't be read. -fn unbound_data(doc: &Document) -> Option { - let data = crate::script::data_of(doc)?; - let tpl = crate::script::template_of(doc).ok()?; - let bound: std::collections::HashSet = - crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); - fn walk( - n: &crate::data::DataNode, - path: &mut crate::data::DataPath, - bound: &std::collections::HashSet, - count: &mut usize, - depth: usize, - ) { - if depth > 64 { - return; - } - let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for c in &n.children { - let idx = seen.entry(c.name.as_str()).or_insert(0); - path.push((c.name.clone(), *idx)); - *idx += 1; - if c.children.is_empty() { - if !c.text.trim().is_empty() && !bound.contains(path) { - *count += 1; - } - } else { - walk(c, path, bound, count, depth + 1); - } - path.pop(); - } - } - let mut count = 0; - walk(&data, &mut Vec::new(), &bound, &mut count, 0); - Some(count) -} - -/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of -/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its -/// pages or fields change they are void, and a viewer may lock every field of a form whose -/// usage rights are void, so they go, and the caller says so. Whether there were any. -fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { - let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; - let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; - if !perms.contains(b"UR3") && !perms.contains(b"UR") { - return false; - } - perms.remove(b"UR3"); - perms.remove(b"UR"); - match entry.as_ref() { - _ if perms.is_empty() => { - catalog.remove(b"Perms"); - } - Some(r) => doc.set(r, Object::Dict(perms)), - None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), - } - true -} - -/// The layout a document already carries from an earlier pass, if any. -pub fn existing_layout(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); - let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); - let mark = mark.as_dict()?; - let warnings = mark - .get(b"Warnings") - .map(|w| doc.resolve(w)) - .and_then(|w| w.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|w| w.as_string().map(|s| s.to_text())) - .take(100) - .collect(); - Some(crate::Report { - pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, - fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, - template_bytes: 0, - warnings, - }) -} - /// The rectangle of a widget in PDF user space (for tests). pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs new file mode 100644 index 0000000000..e1325b19ae --- /dev/null +++ b/crates/xfa/src/pdf/common.rs @@ -0,0 +1,200 @@ +use super::*; + +/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT +/// markers). `None` for anything else. +pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { + jpeg_frame(data).map(|(w, h, _)| (w, h)) +} + +fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { + jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) +} + +/// The SHA-256 of a picture's bytes, to find its XObject again. +pub(super) fn content_hash(data: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(data).into() +} + +pub(super) fn hex32(h: &[u8; 32]) -> Vec { + h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() +} + +pub(super) fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { + let mut out = [0u8; 32]; + if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { + return None; + } + for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { + *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; + } + Some(out) +} + +/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs +/// store inverted values). +pub(super) fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { + if data.get(0..2)? != [0xFF, 0xD8] { + return None; + } + let mut i = 2usize; + let mut guard = 0; + let mut adobe = false; + while i + 4 <= data.len() && guard < 10_000 { + guard += 1; + if *data.get(i)? != 0xFF { + i += 1; + continue; + } + let marker = *data.get(i + 1)?; + if marker == 0xFF { + i += 1; + continue; + } + if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; + if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { + adobe = true; + } + let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); + if sof { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; + let comps = *data.get(i + 9)?; + return (w > 0 && h > 0).then_some((w, h, comps, adobe)); + } + i = i.checked_add(2)?.checked_add(len.max(2))?; + } + None +} + +/// Format a number for content streams. +pub(super) fn n(v: f64) -> String { + let v = if v.is_finite() { v } else { 0.0 }; + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(super) fn rgb(c: [f64; 3]) -> String { + format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) +} + +pub(super) fn rgb_array(c: [f64; 3]) -> Object { + Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) +} + +pub(super) fn standard_font(base: &str) -> Object { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) +} + +/// Every face the content streams or `/DA` strings may name. +pub(super) fn all_faces() -> Vec<(&'static str, &'static str)> { + let mut out = Vec::new(); + for fam in [Family::Helvetica, Family::Courier, Family::Times] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + out.push((fam.resource_name(b, i), fam.base_font(b, i))); + } + } + out +} + +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + +/// A PDF appearance-state name for a check button's on value. +pub(super) fn state_name(on: &str) -> String { + let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); + if s.is_empty() || s == "Off" { "Yes".into() } else { s } +} + +pub(super) fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); + Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) +} + +pub(super) fn js_action(js: &str) -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); + Object::Dict(a) +} diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs new file mode 100644 index 0000000000..de395e37e9 --- /dev/null +++ b/crates/xfa/src/pdf/strip.rs @@ -0,0 +1,282 @@ +use super::*; + +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + +/// What [`strip`] took out, for the user to be told (the packets held more than the fields). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Stripped { + /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). + pub packets: Vec, + /// Data values in the datasets no field held (what the form's data had that its fields + /// didn't show); `None` when the template could not be read to tell. + pub unbound_data: Option, + /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. + pub script_buttons: usize, + pub image_fields: usize, + /// What the layout left out (pictures, rows past the cap), now for good. + pub layout_warnings: Vec, + /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. + pub usage_rights: bool, + /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). + pub field_keys: usize, +} + +impl Stripped { + /// What was dropped, as lines for the user (empty when nothing worth saying). + pub fn notes(&self) -> Vec { + let mut out = Vec::new(); + if !self.packets.is_empty() { + out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); + } + match self.unbound_data { + Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), + None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), + _ => {} + } + if self.script_buttons + self.image_fields > 0 { + out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); + } + for w in &self.layout_warnings { + out.push(format!("Kept as laid out: {w}")); + } + if self.usage_rights { + out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); + } + out + } +} + +/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct XfaShape { + /// `/NeedsRendering true`: the form expects to be laid out from the template. + pub needs_rendering: bool, + /// The AcroForm has fields of its own. + pub has_fields: bool, + /// PdfCraft laid the form out (the layout mark is there). + pub laid_out: bool, +} + +impl XfaShape { + /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out + /// (with their fields), or a static form's own pages and fields. A dynamic form that was + /// never laid out has only placeholder pages: stripping it would leave nothing. + pub fn strippable(self) -> bool { + self.laid_out || (!self.needs_rendering && self.has_fields) + } +} + +/// The document's XFA entry, if any, without decoding a packet. +pub fn xfa_shape(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let catalog = catalog.as_dict()?; + let acro = doc.resolve(catalog.get(b"AcroForm")?); + let acro = acro.as_dict()?; + acro.get(b"XFA")?; + let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); + let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); + let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); + Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) +} + +/// Most field dictionaries walked for their private keys. +const MAX_STRIP_FIELDS: usize = 1_000_000; + +/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out +/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and +/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and +/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) +/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no +/// longer lay out again. What the packets held beyond the fields is counted in the report, +/// so the user can be told; the caller decides whether the form is one to strip +/// ([`XfaShape::strippable`]). A document without XFA is left as it is. +pub fn strip(doc: &mut Document) -> Result { + let mut out = Stripped::default(); + let Some(root) = doc.root() else { return Ok(out) }; + let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; + let acro_obj = catalog.get(b"AcroForm").cloned(); + let Some(acro_obj) = acro_obj else { return Ok(out) }; + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; + // What is about to go, while it can still be read. + out.unbound_data = unbound_data(doc); + out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); + let mut streams: Vec = Vec::new(); + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut i = 0; + while i < items.len() { + let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); + let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; + if let Some(n) = &name { + out.packets.push(n.clone()); + } + if let Some(r) = stream.and_then(Object::as_ref) { + streams.push(r); + } + i += if name.is_some() { 2 } else { 1 }; + } + } + Object::Stream(s) => { + if let Some(r) = xfa.as_ref() { + streams.push(r); + } + // One XDP: the packets it holds are the envelope's children. + let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; + if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH + && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) + { + let root = parsed.root_element(); + if root.tag_name().name() == "xdp" { + out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); + } else { + out.packets.push(root.tag_name().name().to_string()); + } + } + if out.packets.is_empty() { + out.packets.push("xdp".into()); + } + } + _ => {} + } + acro.remove(b"XFA"); + for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { + acro.remove(key); + } + // The private keys on every field and widget, down the tree (each dictionary once). + let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut todo: Vec = Vec::new(); + for r in fields.iter().filter_map(Object::as_ref) { + if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(r); + } + } + while let Some(r) = todo.pop() { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + if d.contains(CLICK_KEY) { + out.script_buttons += 1; + } + if d.contains(IMAGE_FIELD_KEY) { + out.image_fields += 1; + } + let private: Vec> = + [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + if !private.is_empty() { + out.field_keys += private.len(); + doc.update_dict(r, |d| { + for k in &private { + d.remove(k); + } + })?; + } + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(k); + } + } + } + } + match acro_obj.as_ref() { + Some(r) => doc.set(r, Object::Dict(acro)), + None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), + } + catalog.remove(b"NeedsRendering"); + out.usage_rights = drop_usage_rights(doc, &mut catalog); + doc.set(root, Object::Dict(catalog)); + // The packet streams are referenced from nowhere else: freed, so an incremental save + // doesn't carry an unreachable copy of the data. + for r in streams { + doc.free(r); + } + doc.require_full_save(); + Ok(out) +} + +/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose +/// data path no field binds to). `None` when the template can't be read. +fn unbound_data(doc: &Document) -> Option { + let data = crate::script::data_of(doc)?; + let tpl = crate::script::template_of(doc).ok()?; + let bound: std::collections::HashSet = + crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); + fn walk( + n: &crate::data::DataNode, + path: &mut crate::data::DataPath, + bound: &std::collections::HashSet, + count: &mut usize, + depth: usize, + ) { + if depth > 64 { + return; + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in &n.children { + let idx = seen.entry(c.name.as_str()).or_insert(0); + path.push((c.name.clone(), *idx)); + *idx += 1; + if c.children.is_empty() { + if !c.text.trim().is_empty() && !bound.contains(path) { + *count += 1; + } + } else { + walk(c, path, bound, count, depth + 1); + } + path.pop(); + } + } + let mut count = 0; + walk(&data, &mut Vec::new(), &bound, &mut count, 0); + Some(count) +} + +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +pub(super) fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + +/// The layout a document already carries from an earlier pass, if any. +pub fn existing_layout(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); + let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); + let mark = mark.as_dict()?; + let warnings = mark + .get(b"Warnings") + .map(|w| doc.resolve(w)) + .and_then(|w| w.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|w| w.as_string().map(|s| s.to_text())) + .take(100) + .collect(); + Some(crate::Report { + pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, + fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, + template_bytes: 0, + warnings, + }) +} From f64ee67181ee2807bc80d5455224ce231ba3b29c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:12:15 +0000 Subject: [PATCH 060/175] docs(parity): point evidence at combine_ui's moved tests --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10c46d4dff..a50c905fc4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3051,7 +3051,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui/tests.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui/tests.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui/tests.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] From d84f8baf9d645fa0ef478f67e74c4a0b699583e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:29:44 +0000 Subject: [PATCH 061/175] refactor(engine): split session.rs into session/ New modules: session/view.rs, session/editing.rs, session/save.rs, session/files.rs (all impl Session methods). --- crates/engine/src/session.rs | 1552 +------------------------- crates/engine/src/session/editing.rs | 521 +++++++++ crates/engine/src/session/files.rs | 397 +++++++ crates/engine/src/session/save.rs | 335 ++++++ crates/engine/src/session/view.rs | 304 +++++ 5 files changed, 1562 insertions(+), 1547 deletions(-) create mode 100644 crates/engine/src/session/editing.rs create mode 100644 crates/engine/src/session/files.rs create mode 100644 crates/engine/src/session/save.rs create mode 100644 crates/engine/src/session/view.rs diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c3e34d90bd..1051903bae 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -18,6 +18,11 @@ use crate::{ writable, xfa, xfa_sync_datasets, }; +mod editing; +mod files; +mod save; +mod view; + #[derive(Default)] pub struct Session { pub(crate) docs: Vec, @@ -41,1550 +46,3 @@ pub struct Session { /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, } - -impl Session { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the - /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script - /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the - /// scripts do passes a large factor, so that a busy machine does not stop them. On native - /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so - /// there the factor has no effect. - pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { - self.xfa_time_scale = Some(factor); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - pub(crate) fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - let previous = std::mem::replace(&mut editor.cos, next); - let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); - let previous_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - pub(crate) fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - #[cfg(test)] - check_refresh_panic(); - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// The bytes to write for Save: an incremental update of the file as opened/last saved, - /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - // What the user removed again before saving (a cleared redaction mark) stays out of the file. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; - if !typed.is_empty() && doc.info.xfa.is_some() { - let mut datasets = None; - let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); - } - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - if let Some(p) = path { - doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // How the document is shown is the reader's, not the file's: reading colours and hidden - // comments stay as they were (#3443). `open` built the renderer with the defaults. - if let Some(old) = self.docs.get(old) - && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) - { - doc.config.reading_colors = old.config.reading_colors; - doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - let mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Whether a changed document still needs a recovery snapshot. This does not consume it. - pub fn has_pending_autosave(&self) -> bool { - self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926, #3944): a recovery file - // is written from a copy without them, and none is written if that copy fails. - let bytes = - match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - d.generation += 1; - d.snapshot_generation = d.generation; // its bytes are already in the recovery store - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns - /// `true` if any layer changed; callers must drop cached rasters and text for the document, - /// as for [`Self::set_layer_visible`]. - pub fn reset_layers(&mut self, id: DocId) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let mut changed = false; - for l in &mut doc.info.layers { - changed |= l.visible != l.initial; - l.visible = l.initial; - } - if changed { - use_layer_choices(doc); - } - changed - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. - pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs new file mode 100644 index 0000000000..9b1a8340a5 --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,521 @@ +use super::*; + +impl Session { + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() + } else { + Vec::new() + }; + let previous = std::mem::replace(&mut editor.cos, next); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + pub(super) fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + pub(super) fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + pub(super) fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 0000000000..6d4e11d390 --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,397 @@ +use super::*; + +impl Session { + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium + /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects + /// packed into compressed object streams. Returns the bytes and how many objects were + /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + let mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } +} diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs new file mode 100644 index 0000000000..23994aee37 --- /dev/null +++ b/crates/engine/src/session/save.rs @@ -0,0 +1,335 @@ +use super::*; + +impl Session { + /// The bytes to write for Save: an incremental update of the file as opened/last saved, + /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + if let Some(p) = path { + doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } +} diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs new file mode 100644 index 0000000000..cef539189a --- /dev/null +++ b/crates/engine/src/session/view.rs @@ -0,0 +1,304 @@ +use super::*; + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + pub(super) fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} From b51aee6153b997c38215e48b34dc354adbee6c64 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:33:23 +0000 Subject: [PATCH 062/175] refactor(engine): split edit.rs into edit/ New modules: edit/labels.rs (labels, impl Edit, add_annotation, plural), edit/run.rs (check_permission, EditCtx, run_edit). --- crates/engine/src/edit.rs | 1036 +----------------------------- crates/engine/src/edit/labels.rs | 336 ++++++++++ crates/engine/src/edit/run.rs | 696 ++++++++++++++++++++ 3 files changed, 1038 insertions(+), 1030 deletions(-) create mode 100644 crates/engine/src/edit/labels.rs create mode 100644 crates/engine/src/edit/run.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index faf4712078..c31984ca19 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -9,6 +9,12 @@ use crate::{ image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; +mod labels; +mod run; + +pub use labels::{BATCH_LABELS, EDIT_LABELS}; +pub(crate) use run::{EditCtx, check_permission, run_edit}; + /// Edits that can be applied to a document. Page indices are 0-based. #[derive(Clone, Debug, PartialEq)] pub enum Edit { @@ -545,1033 +551,3 @@ pub enum Edit { edits: Vec, }, } - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - 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::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"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -pub(crate) struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - pub(crate) js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - pub(crate) xfa: Option>, - pub(crate) xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - pub(crate) xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - pub(crate) xfa_ran_away: bool, - /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). - pub(crate) xfa_time_scale: u32, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - pub(crate) xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pub(crate) pages: usize, - pub(crate) date: Option, - /// Today in local time, for date tokens. - pub(crate) today: (i64, u32, u32), - pub(crate) seed: u64, - pub(crate) count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - pub(crate) renamed_fields: Vec<(String, String)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - pub(crate) redaction_overlays: Vec, -} - -impl EditCtx { - pub(crate) fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_time_scale: 1, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - 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)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page, left, top } => { - pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - // As after a script edit: the values the new order gives show at once. - Edit::SetCalculationOrder { names } => { - pdfcraft_forms::set_calculation_order(doc, names)?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs new file mode 100644 index 0000000000..5e25ae465b --- /dev/null +++ b/crates/engine/src/edit/labels.rs @@ -0,0 +1,336 @@ +use super::*; + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + 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::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"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs new file mode 100644 index 0000000000..7901a3b7d8 --- /dev/null +++ b/crates/engine/src/edit/run.rs @@ -0,0 +1,696 @@ +use super::*; + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + 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)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} From 21c1c917a5bc50446b22b63927fef6641ca8c1c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:35:39 +0000 Subject: [PATCH 063/175] test(automation): skip use lines in the file access scan --- crates/automation/src/file_access.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 3fa3a90abd..5003e10343 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -423,7 +423,9 @@ mod tests { let lines: Vec<&str> = code.lines().collect(); for (n, line) in lines.iter().enumerate() { let trimmed = line.trim_start(); + // A `use` line only names a helper; the call is checked where it is made. if trimmed.starts_with("//") + || trimmed.starts_with("use ") || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") From 3370aebb6c72234db2cbb1b8de53a7721b836aa8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:51:58 +0000 Subject: [PATCH 064/175] refactor(automation): split lib.rs into src/ New modules: error, dispatch, documents, assemble, pages, paths, json. lib.rs keeps Automation, its builders, mod lines and pub use lines. --- crates/automation/src/assemble.rs | 424 +++++ crates/automation/src/dispatch.rs | 784 ++++++++ crates/automation/src/documents.rs | 606 ++++++ crates/automation/src/error.rs | 33 + crates/automation/src/json.rs | 361 ++++ crates/automation/src/lib.rs | 2733 +--------------------------- crates/automation/src/pages.rs | 363 ++++ crates/automation/src/paths.rs | 180 ++ crates/automation/src/tests.rs | 3 +- 9 files changed, 2775 insertions(+), 2712 deletions(-) create mode 100644 crates/automation/src/assemble.rs create mode 100644 crates/automation/src/dispatch.rs create mode 100644 crates/automation/src/documents.rs create mode 100644 crates/automation/src/error.rs create mode 100644 crates/automation/src/json.rs create mode 100644 crates/automation/src/pages.rs create mode 100644 crates/automation/src/paths.rs diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs new file mode 100644 index 0000000000..40ae65ea87 --- /dev/null +++ b/crates/automation/src/assemble.rs @@ -0,0 +1,424 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based, summary}; +use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; +use super::*; + +impl Automation { + pub(crate) fn doc_create(&mut self, a: &Args) -> Result { + let (name, bytes) = match a.str("from")? { + "blank" => { + let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; + let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); + ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) + } + "images" => { + let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); + let mut images = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); + } + let name = if images.len() == 1 { + format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) + } else { + "Images.pdf".into() + }; + (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) + } + "text" => { + let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { + (Some(t), _) => ("Text".to_string(), t.to_string()), + (None, Some(p)) => { + let path = self.resolve(p, false)?; + let t = read_input(&path)?; + (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) + } + (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), + }; + // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with + // its line) by `pdfcraft_create::from_text`, as for every other caller. + let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { + pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), + e => failed(e), + })?; + (format!("{title}.pdf"), bytes) + } + other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), + }; + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); + let id = self.session.open_new(name, bytes).map_err(failed)?; + Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) + } + + pub(crate) fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + + pub(crate) fn page_set_box(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{BoxSpec, PageBox}; + let doc = self.doc(a)?; + let n = doc.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let which = match a.opt_str("box")? { + None => PageBox::Crop, + Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, + }; + let nums = |key: &str| -> Result> { + a.get(key) + .map(|v| { + let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) + }) + .transpose() + }; + let spec = match (nums("margins")?, nums("rect")?) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), + (Some(m), None) => BoxSpec::Margins(m), + (None, Some(r)) => { + // Top-left-origin points on the displayed page → user space (one page at a time). + if pages.len() != 1 { + return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); + } + let p = &doc.info.pages[pages[0]]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + (None, None) => BoxSpec::Remove, + }; + let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; + out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); + Ok(out) + } + + pub(crate) fn insert_blank(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let at = self.position(a, "at")?; + // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. + let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); + let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); + let width = a.opt_num("width")?.unwrap_or(w); + let height = a.opt_num("height")?.unwrap_or(h); + if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { + return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); + } + self.apply(a, Edit::InsertBlankPage { at, width, height }) + } + + pub(crate) fn insert_file(&mut self, a: &Args) -> Result { + let at = self.position(a, "at")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) + } + + /// Write freshly created bytes to `out` and/or open them as a new document. + pub(crate) fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { + let mut result = json!({ "bytes": bytes.len() }); + let out = a.opt_str("out")?; + if let Some(out) = out { + let path = self.resolve(out, true)?; + write_atomic(&path, &bytes)?; + result["path"] = json!(path.to_string_lossy()); + } + if a.opt_bool("open")?.unwrap_or(out.is_none()) { + let id = self.session.open_new(name, bytes).map_err(failed)?; + result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); + } + Ok(result) + } + + pub(crate) fn doc_create_multiple(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.is_empty() { + return Err(ToolError::InvalidArgs("paths must list at least one file".into())); + } + let separate = match a.opt_str("mode")? { + None | Some("combine") => false, + Some("separate") => true, + Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), + }; + if separate { + return self.create_separate(a, &paths); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + // Each file is read, converted and copied in turn, so a long list holds one file at a time. + let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; + let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((title, pdf, range)) + }); + let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; + self.deliver(a, "Combined", bytes) + } + + /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. + fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for &p in paths { + let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; + Ok((kind, stem, pdf)) + }); + out.push(match converted { + Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), + Ok((_, stem, pdf)) => { + let target = unused(&dir, &stem); + write_atomic(&target, &pdf)?; + json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + pub(crate) fn page_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, name) = (doc.id, format!("{} (extract)", doc.name)); + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); + let pages = self.pages(a, "pages")?; + let mut out = if a.opt_bool("separate")?.unwrap_or(false) { + // Each page as its own file. + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + for &p in &pages { + let bytes = self.session.extract(id, &[p]).map_err(failed)?; + let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); + write_atomic(&path, &bytes)?; + files.push(path.to_string_lossy().into_owned()); + } + json!({ "files": files }) + } else { + let bytes = self.session.extract(id, &pages).map_err(failed)?; + self.deliver(a, &name, bytes)? + }; + if a.opt_bool("delete")?.unwrap_or(false) { + let deleted = self.apply(a, Edit::DeletePages { pages })?; + out["original"] = deleted; + } + Ok(out) + } + + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.len() < 2 { + return Err(ToolError::InvalidArgs("combine needs at least two files".into())); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::Null => Ok(None), + Value::String(range) => Ok(Some(range.clone())), + _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), + }) + .collect::>()?, + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + let passwords: Vec> = match a.get("passwords") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { + v.iter().map(|x| x.as_str().map(str::to_owned)).collect() + } + Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), + }; + // Entries sharing a group number are one file split into parts placed apart. + let groups: Option>> = match a.get("groups") { + None | Some(Value::Null) => None, + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { + Some(v.iter().map(Value::as_u64).collect()) + } + Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), + }; + let field_conflicts = match a.opt_str("field_conflicts")? { + None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, + Some("merge") => pdfcraft_organize::FieldConflicts::Merge, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); + } + }; + let mut sources: Vec = Vec::new(); + let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); + for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { + let path = self.resolve(p, false)?; + let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); + let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); + let bytes = match first { + Some((g, first_path, at)) => { + if *first_path != path { + return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); + } + sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? + } + None => { + if let Some(g) = group { + firsts.push((g, path.clone(), sources.len())); + } + let bytes = Arc::new(read_input(&path)?); + // Images and text files are made into PDFs first (#569), as in the Combine files tab. + let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.session.as_pdf(&file_name, &bytes).map_err(failed)? + } + }; + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((name, bytes, range)); + } + let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); + let combined = match groups { + None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), + Some(groups) => { + // A path without a group is a file of its own: give it a key no group uses. + let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); + let mut keys = Vec::with_capacity(groups.len()); + for g in &groups { + keys.push(match g { + Some(g) => *g, + None => { + let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; + next = own.checked_add(1); + own + } + }); + } + self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) + } + } + .map_err(failed)?; + let mut result = self.deliver(a, "Combined", combined.bytes)?; + if !combined.renamed_fields.is_empty() { + result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); + } + Ok(result) + } + + pub(crate) fn doc_split(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); + let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); + let max_mb = a.opt_num("max_mb")?; + let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); + if chosen != 1 { + return Err(ToolError::InvalidArgs( + "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), + )); + } + let mut titles: Vec<(usize, String)> = Vec::new(); + let parts = if let Some(mb) = max_mb { + if !mb.is_finite() || mb <= 0.0 { + return Err(ToolError::InvalidArgs("max_mb must be positive".into())); + } + self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? + } else { + let by = match (a.opt_int("every")?, a.opt_ints("before")?) { + (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), + (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), + _ if bookmarks => { + titles = self.session.bookmark_splits(id); + if titles.is_empty() { + return Err(failed("the document has no top-level bookmarks")); + } + pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) + } + _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), + }; + self.session.split(id, &by).map_err(failed)? + }; + let dir = self.resolve(a.str("out_dir")?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); + for (i, (first, last, bytes)) in parts.iter().enumerate() { + let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), + }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file; + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{base}-{n}.pdf"); + } + let path = child(&dir, &unique); + write_atomic(&path, bytes)?; + files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); + } + Ok(json!({ "files": files })) + } +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs new file mode 100644 index 0000000000..26e1f95365 --- /dev/null +++ b/crates/automation/src/dispatch.rs @@ -0,0 +1,784 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; +use super::paths::{read_input, write_atomic}; +use super::*; + +/// One piece of a tool's result. +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + /// Structured data (MCP: a text block with the JSON plus `structuredContent`). + Json(Value), + /// A PNG image. + Png { data: Vec, width: u32, height: u32 }, +} + +impl Automation { + /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). + pub fn call(&mut self, name: &str, args: &Value) -> Result> { + let empty = json!({}); + let args = if args.is_null() { &empty } else { args }; + if !args.is_object() { + return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); + } + let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; + tools::check_args(def, args)?; + let a = Args(args); + let out = match name { + "doc_open" => self.doc_open(&a)?, + "doc_batch" => self.doc_batch(&a)?, + "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), + "doc_info" => { + let id = self.doc(&a)?.id; + self.session.refresh_fonts(id); + info(self.doc(&a)?) + } + "doc_close" => self.doc_close(&a)?, + "doc_save" => self.doc_save(&a)?, + "doc_set_info" => { + let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; + self.apply(&a, edit)? + } + "page_render" => return self.page_render(&a).map(|c| vec![c]), + "comment_image_preview" => return self.comment_image_preview(&a), + "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, + "text_find" => self.text_find(&a)?, + "page_rotate" => { + let degrees = a.int("degrees")?; + if degrees % 90 != 0 { + return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); + } + let doc = self.doc(&a)?; + let base = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let parity = match a.opt_str("subset")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageParity::Both, + "even" => pdfcraft_engine::PageParity::Even, + "odd" => pdfcraft_engine::PageParity::Odd, + s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), + }; + let orientation = match a.opt_str("orientation")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageOrientation::Both, + "landscape" => pdfcraft_engine::PageOrientation::Landscape, + "portrait" => pdfcraft_engine::PageOrientation::Portrait, + o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), + }; + let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); + if pages.is_empty() { + return Err(failed("no pages match the filters")); + } + let n = pages.len(); + let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; + out["rotated"] = json!(n); + out + } + "page_enhance_camera" => { + let page = self.page(&a)?; + let given = a.nums::<8>("corners")?; + let doc = self.doc(&a)?; + let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); + let corners = match given { + Some(c) => [0, 1, 2, 3].map(|i| { + let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); + [x as f64, y as f64] + }), + None => self.session.detect_camera_corners(id, page).ok_or_else(|| { + failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") + })?, + }; + let level = match a.opt_int("level")? { + None => 0, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let look = match a.opt_str("mode")? { + None => pdfcraft_engine::camera_clean::Look::Colour, + Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, + }; + self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; + let r = |v: f32| (v * 100.0).round() / 100.0; + let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); + let mut out = summary(self.doc(&a)?); + out["corners"] = json!(used); + out["detected"] = json!(given.is_none()); + out + } + "page_delete" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DeletePages { pages })? + } + "page_move" => { + let pages = self.pages(&a, "pages")?; + let to = self.position(&a, "to")?; + self.apply(&a, Edit::MovePages { pages, to })? + } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } + "page_insert_blank" => self.insert_blank(&a)?, + "page_insert_file" => self.insert_file(&a)?, + "page_extract" => self.page_extract(&a)?, + "doc_combine" => self.doc_combine(&a)?, + "doc_create_multiple" => self.doc_create_multiple(&a)?, + "doc_split" => self.doc_split(&a)?, + "edit_undo" => { + let id = self.doc(&a)?.id; + let label = self.session.undo(id).map_err(failed)?.to_string(); + json!({ "undone": label, "document": summary(self.doc(&a)?) }) + } + "edit_redo" => { + let id = self.doc(&a)?.id; + let label = self.session.redo(id).map_err(failed)?.to_string(); + json!({ "redone": label, "document": summary(self.doc(&a)?) }) + } + "command_list" => self.command_list(&a)?, + "command_run" => return self.command_run(&a), + "command_batch" => self.command_batch(&a)?, + "doc_inspect" => self.doc_inspect(&a)?, + "render_preview" => return self.render_preview(&a).map(|c| vec![c]), + "page_number" => { + use pdfcraft_organize::LabelStyle as L; + let n = self.doc(&a)?.info.pages.len(); + let (from, to) = (a.int("from")?, a.int("to")?); + if from < 1 || to < from || to as usize > n { + return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); + } + let style = match a.opt_str("style")?.unwrap_or("decimal") { + "decimal" => L::Decimal, + "upper-roman" => L::UpperRoman, + "lower-roman" => L::LowerRoman, + "upper-alpha" => L::UpperAlpha, + "lower-alpha" => L::LowerAlpha, + "none" => L::None, + other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), + }; + let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); + let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; + let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; + out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); + out + } + "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_add" => { + let page = self.page(&a)?; + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + let title = a.str("title")?.to_string(); + let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); + let [left, top] = match self.doc(&a)?.info.pages.get(page) { + Some(p) => p.xyz_for_view(left, top), + None => [None, None], + }; + let (left, top) = (left.map(f64::from), top.map(f64::from)); + self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? + } + "bookmark_rename" => { + let (path, title) = (a.path("path")?, a.str("title")?.to_string()); + self.apply(&a, Edit::RenameBookmark { path, title })? + } + "bookmark_delete" => { + let path = a.path("path")?; + self.apply(&a, Edit::DeleteBookmark { path })? + } + "bookmark_move" => { + let from = a.path("path")?; + let to_parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? + } + "bookmark_set_page" => { + let (path, page) = (a.path("path")?, self.page(&a)?); + self.apply(&a, Edit::SetBookmarkPage { path, page })? + } + "bookmark_set_style" => { + let path = a.path("path")?; + // Left out keeps the colour; [0, 0, 0] is the default black. + let color = match a.get("color") { + None => None, + Some(v) => { + let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), + } + } + }; + let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); + if color.is_none() && bold.is_none() && italic.is_none() { + return Err(ToolError::InvalidArgs("give color, bold or italic".into())); + } + let mut edits = Vec::new(); + if let Some(color) = color { + edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); + } + if bold.is_some() || italic.is_some() { + edits.push(Edit::SetBookmarkStyle { path, bold, italic }); + } + // A colour and a style given together are one step to undo, applied all or nothing. + match edits.len() { + 1 => self.apply(&a, edits.remove(0))?, + _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, + } + } + "bookmark_from_structure" => { + let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } + "doc_protect" => self.doc_protect(&a)?, + "page_replace" => { + let pages = self.pages(&a, "pages")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = Arc::new(read_input(&path)?); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let src_pages = match a.opt_ints("from_pages")? { + Some(p) => one_based(&p)?, + None => (0..pages.len()).collect(), + }; + self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? + } + "page_duplicate" => { + let pages = self.pages(&a, "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)?, + "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, + "doc_flatten" => { + let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); + if !comments && !fields { + return Err(ToolError::InvalidArgs("nothing to flatten".into())); + } + self.apply(&a, Edit::Flatten { comments, fields })? + } + "doc_reduce" => { + let id = self.doc(&a)?.id; + let before = self.doc(&a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + } + "doc_optimize" => self.doc_optimize(&a)?, + "doc_initial_view" => self.doc_initial_view(&a)?, + "doc_revisions" => self.doc_revisions(&a)?, + "text_lines" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let lines: Vec = doc + .text_lines(page) + .iter() + .enumerate() + .map(|(i, l)| { + let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); + json!({ + "line": i + 1, + "text": l.text, + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": l.base_font, + "size": (l.size * 100.0).round() / 100.0, + }) + }) + .collect(); + json!({ "page": page + 1, "count": lines.len(), "lines": lines }) + } + "image_hit" => { + let page = self.page(&a)?; + let point = a + .get("point") + .and_then(Value::as_array) + .filter(|p| p.len() == 2) + .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) + .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; + let doc = self.doc(&a)?; + let p = &doc.info.pages[page]; + let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); + let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); + json!({"page":page+1,"images":images}) + } + "page_images" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let list: Vec = doc + .page_images(page) + .iter() + .enumerate() + .map(|(i, im)| { + let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); + let visible = im.visible_rect.map(|rect| { + let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); + [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] + }); + json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + }) + .collect(); + json!({ "page": page + 1, "count": list.len(), "images": list }) + } + "image_edit" | "image_save" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.page_images(page).len(); + let k = a.int("image")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); + } + let index = k as usize - 1; + if name == "image_save" { + let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; + let mut path = self.resolve(a.str("path")?, true)?; + if path.is_dir() { + // Adding the extension to a folder's name would write beside it: "." is the + // root, so that would land outside it. + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + if path.extension().is_none() { + path.set_extension(ext); + } + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) + } else { + use pdfcraft_engine::ImageEdit; + let change = match a.str("action")? { + "move" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; + // Top-left-origin points → user space. + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "rotate_by" => ImageEdit::RotateBy( + a.get("degrees") + .and_then(Value::as_f64) + .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, + ), + "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "flip_horizontal" => ImageEdit::Flip { horizontal: true }, + "flip_vertical" => ImageEdit::Flip { horizontal: false }, + "delete" => ImageEdit::Delete, + "replace" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + ImageEdit::Replace { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + } + } + other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), + }; + self.apply(&a, Edit::EditPageImage { page, index, change })? + } + } + "text_paragraphs" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let current = doc.text_blocks(page); + let id = doc.id; + let blocks: Vec = current + .iter() + .enumerate() + .map(|(i, b)| { + let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); + json!({ + "paragraph": i + 1, + "text": b.text, + "editor_text": b.editor_text, + "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), + "lines": b.lines.iter().map(|l| l + 1).collect::>(), + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": b.base_font, + "size": (b.size * 100.0).round() / 100.0, + }) + }) + .collect(); + self.record_listing(id, page, ¤t); + json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) + } + "text_font_files" => { + let roots = if let Some(paths) = a.get("directories") { + let paths = paths + .as_array() + .filter(|p| p.len() <= 16) + .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; + paths + .iter() + .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) + .collect::>>()? + } else if let Some(roots) = &self.roots { + // The installed fonts are outside the roots: look in the read root instead. + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; + vec![self.resolve(&read.to_string_lossy(), false)?] + } else if let Some(root) = &self.root { + vec![root.clone()] + } else { + pdfcraft_engine::text_fonts::installed_directories() + }; + json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) + } + "text_font_import" => { + let page = self.page(&a)?; + let path = self.resolve(a.str("path")?, false)?; + let file = std::fs::File::open(&path).map_err(failed)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; + if bytes.len() > 16 << 20 { + return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); + } + let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); + let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; + let added = self + .doc(&a)? + .text_editor_fonts(page) + .into_iter() + .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); + if let Some(f) = added { + out["resource"] = json!(f.resource); + out["font"] = json!(f.face); + } + out + } + "text_fonts" => { + let page = self.page(&a)?; + json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) + } + "text_edit_layout" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let k = a.int("paragraph")?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; + let text = a.opt_str("text")?.unwrap_or(&source.editor_text); + let style = pdfcraft_engine::BlockStyle { + size: a.opt_num("size")?, + width: a.opt_num("width")?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + ranges: parse_style_ranges(a.get("ranges"))?, + ..Default::default() + }; + let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; + let info = &doc.info.pages[page]; + let view_rect = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }; + let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); + json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), + "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) + } + "text_edit" if a.get("paragraph").is_some() => { + let page = self.page(&a)?; + let k = a.int("paragraph")?; + let (id, blocks) = { + let doc = self.doc(&a)?; + (doc.id, doc.text_blocks(page)) + }; + let index = self.paragraph_index(id, page, k, &blocks)?; + let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; + let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let mut style = pdfcraft_engine::BlockStyle { + ranges: parse_style_ranges(a.get("ranges"))?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + bold: a.opt_bool("bold")?, + size: a.opt_num("size")?, + underline: a.opt_bool("underline")?, + line_spacing: a.opt_num("line_spacing")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + width: a.opt_num("width")?, + ..Default::default() + }; + let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); + if dx.is_some() || dy.is_some() { + style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); + } + if let Some(f) = a.opt_str("font")? { + let family = match f { + "helvetica" => pdfcraft_engine::FontFamily::Helvetica, + "times" => pdfcraft_engine::FontFamily::Times, + "courier" => pdfcraft_engine::FontFamily::Courier, + other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), + }; + style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); + } + if let Some(c) = a.opt_str("color")? { + style.color = Some(comments::parse_color(c)?); + } + if let Some(al) = a.opt_str("align")? { + style.align = Some(match al { + "left" => pdfcraft_engine::TextAlign::Left, + "center" => pdfcraft_engine::TextAlign::Center, + "right" => pdfcraft_engine::TextAlign::Right, + "justify" => pdfcraft_engine::TextAlign::Justify, + other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), + }); + } + // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; + let wanted: String = text.split_whitespace().collect(); + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); + // Only a paragraph that now shows exactly the requested text is the edited one. A + // neighbour that overlaps the old box (overlaid text, a text layer) must not take + // its number, and neither must a paragraph the edit merged it into: rewriting that + // one by this number would drop the neighbour's text. + let edited = block_overlapping(&after, moved) + .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); + if let Some(b) = edited.and_then(|i| after.get(i)) { + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } + } + let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); + if !stale.is_empty() { + out["stale_paragraphs"] = json!(stale); + } + out + } + "text_edit" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.text_lines(page).len(); + let line = a.int("line")?; + if line < 1 || line as usize > n { + return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); + } + let text = a.str("text")?.to_owned(); + let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; + let after = self.doc(&a)?.text_lines(page); + if let Some(l) = after.get(line as usize - 1) { + out["line"] = json!({ "text": l.text, "font": l.base_font }); + } + out + } + "doc_audit_space" => { + let rows: Vec = self + .doc(&a)? + .audit_space() + .iter() + .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) + .collect(); + json!({ "categories": rows }) + } + "doc_open_revision" => { + let id = self.doc(&a)?.id; + let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); + let new = self.session.open_revision(id, n).map_err(failed)?; + summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) + } + "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_unprotect" => { + let mut out = self.apply(&a, Edit::RemoveProtection)?; + out["security"] = security(self.doc(&a)?); + out + } + "form_fields" => self.form_fields(&a)?, + "form_fill" => self.form_fill(&a)?, + "form_set_image" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? + } + "form_reset" => self.form_reset(&a)?, + "form_add_field" => self.form_add_field(&a)?, + "form_add_field_table" => self.form_add_field_table(&a)?, + "form_set_props" => self.form_set_props(&a)?, + "form_delete_field" => self.form_delete_field(&a)?, + "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, + "doc_export_data" => self.doc_export_data(&a)?, + "doc_import_data" => self.doc_import_data(&a)?, + "redact_mark" => self.redact_mark(&a)?, + "redact_apply" => self.redact_apply(&a)?, + "redact_clear" => self.redact_clear(&a)?, + "doc_hidden_info" => self.doc_hidden_info(&a)?, + "printers" => self.printers()?, + "printer_options" => self.printer_options(&a)?, + "link_list" => self.link_list(&a)?, + "link_open" => self.link_open(&a)?, + "link_add" => self.link_add(&a)?, + "link_edit" => self.link_edit(&a)?, + "link_delete" => self.link_delete(&a)?, + "links_from_urls" => self.links_from_urls(&a)?, + "links_remove" => self.links_remove(&a)?, + "object_list" => self.object_list(&a)?, + "object_move" => self.object_move(&a)?, + "content_list" => self.content_list(&a)?, + "page_add_text" => self.page_add_text(&a)?, + "page_add_image" => self.page_add_image(&a)?, + "content_update" => self.content_update(&a)?, + "content_delete" => self.content_delete(&a)?, + "content_copy" => self.content_copy(&a)?, + "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + // Only the document-level keys: an annotation attachment of the same name is not the new one. + let before = document_attachment_keys(self.doc(&a)?); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } + "doc_remove_hidden" => self.doc_remove_hidden(&a)?, + "doc_audit_redactions" => self.doc_audit_redactions(&a)?, + "fill_sign_add" => self.fill_sign_add(&a)?, + "fill_sign_date_format" => self.fill_sign_date_format(&a)?, + "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, + "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, + "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, + "measure_info" => self.measurement_info(&a)?, + "measure_list" => self.measurement_list(&a)?, + "measure_scale" => self.measurement_scale(&a)?, + "measure_snap" => self.measurement_snap(&a)?, + "measure_export" => self.measurement_export(&a)?, + "comment_list" => self.comment_list(&a)?, + "comment_add" => self.comment_add(&a)?, + "stamp_custom" => self.stamp_custom(&a)?, + "comment_reply" => self.comment_reply(&a)?, + "comment_set_status" => self.comment_set_status(&a)?, + "sign_list" => self.sign_list(&a)?, + "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, + "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, + "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, + "ocr_recognize" => self.ocr_recognize(&a)?, + "js_run" => self.js_run(&a)?, + "js_document_scripts" => self.js_document_scripts(&a)?, + "js_set_document_script" => self.js_set_document_script(&a)?, + "js_enabled" => self.js_enabled(&a)?, + "form_set_script" => self.form_set_script(&a)?, + "form_merge_data" => self.form_merge_data(&a)?, + "form_actions" => self.form_actions(&a)?, + "form_detect_fields" => self.form_detect_fields(&a)?, + "doc_compare" => self.doc_compare(&a)?, + "action_list" => self.action_list()?, + "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, + "doc_export_office" => self.doc_export_office(&a)?, + "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } + "action_run" => self.action_run(&a)?, + "doc_compare_report" => self.doc_compare_report(&a)?, + "doc_compare_mark" => self.doc_compare_mark(&a)?, + "form_set_actions" => self.form_set_actions(&a)?, + "ocr_status" => self.ocr_status()?, + "ocr_recognize_files" => self.ocr_recognize_files(&a)?, + "accessibility_report" => self.a11y_report(&a)?, + "accessibility_review_record" => self.a11y_review_record(&a)?, + "accessibility_review_save" => self.a11y_review_save(&a)?, + "accessibility_review_load" => self.a11y_review_load(&a)?, + "accessibility_review_html" => self.a11y_review_html(&a)?, + "accessibility_fix" => self.a11y_fix(&a)?, + "accessibility_figures" => self.accessibility_figures(&a)?, + "accessibility_set_alt" => self.accessibility_set_alt(&a)?, + "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, + "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_move_tag" => self.accessibility_move_tag(&a)?, + "sign_id_create" => self.sign_id_create(&a)?, + "sign_document" => self.sign_document(&a)?, + "sign_keychain_ids" => { + #[cfg(target_os = "macos")] + let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) + .map_err(failed)? + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "macos"))] + let ids: Vec = Vec::new(); + json!({ "count": ids.len(), "ids": ids }) + } + "sign_windows_ids" => { + #[cfg(target_os = "windows")] + let (ids, unusable): (Vec, Vec) = { + let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; + let ids = listing + .ids + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + let unusable = listing + .unusable + .iter() + .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) + .collect(); + (ids, unusable) + }; + #[cfg(not(target_os = "windows"))] + let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); + json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) + } + "sign_trust" => self.sign_trust(&a)?, + "comment_mark" => self.comment_mark(&a)?, + "comment_lock" => self.comment_lock(&a)?, + "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, + "view_reading_colors" => self.view_reading_colors(&a)?, + "comments_summarize" => self.comments_summarize(&a)?, + "comment_edit" => self.comment_edit(&a)?, + "comment_delete" => self.comment_delete(&a)?, + other => return Err(ToolError::UnknownTool(other.into())), + }; + Ok(vec![Content::Json(out)]) + } +} diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs new file mode 100644 index 0000000000..26addfd80b --- /dev/null +++ b/crates/automation/src/documents.rs @@ -0,0 +1,606 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, nest_rows, security, summary}; +use super::paths::{child, read_input, write_atomic}; +use super::*; + +/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). +const WATERMARK_MAX_OFFSET: f64 = 20_000.0; + +/// Bookmarks in one page of the bookmark list, by default and at most. +const BOOKMARK_PAGE: usize = 100; + +const MAX_BOOKMARK_PAGE: usize = 1000; + +impl Automation { + // ---- documents --------------------------------------------------------------------------- + + /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). + pub(crate) fn bookmark_listing(&self, a: &Args) -> Result { + let id = a.int("doc")?; + let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); + let offset = a.opt_int("offset")?.unwrap_or(0); + let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; + let page = self + .session + .bookmark_page(id, offset, limit) + .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) + } + + pub(crate) fn doc(&self, a: &Args) -> Result<&Document> { + let id = a.int("doc")?; + let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; + self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) + } + + /// Most pages whose paragraph listing is remembered. + const MAX_LISTINGS: usize = 256; + + /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be + /// resolved against that listing. + pub(crate) fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { + if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { + self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); + self.listed.clear(); + } + self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); + } + + /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). + /// + /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is + /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a + /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph + /// and lose its text (#2516). So is any number on a page of a document whose listings were + /// dropped at the cap, until the page is listed again. + pub(crate) fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { + let keys = self.listed.get(&(doc, page)); + if keys.is_none() && self.evicted.contains(&doc) { + return Err(ToolError::InvalidArgs(format!( + "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", + page + 1 + ))); + } + let n = keys.map_or(blocks.len(), Vec::len); + let i = usize::try_from(k) + .ok() + .and_then(|k| k.checked_sub(1)) + .filter(|i| *i < n) + .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; + let Some(keys) = keys else { return Ok(i) }; + keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { + ToolError::InvalidArgs(format!( + "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", + page + 1 + )) + }) + } + + /// Update the listing after paragraph `k` was edited into `edited`, one of the page's + /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. + pub(crate) fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { + let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; + if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { + *key = BlockKey::of(edited); + } + keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() + } + + /// The document, if its security allows copying text and images out of it (Table 22, bit 5). + pub(crate) fn doc_to_copy(&self, a: &Args) -> Result<&Document> { + let doc = self.doc(a)?; + doc.check_copying().map_err(failed)?; + Ok(doc) + } + + pub(crate) fn doc_open(&mut self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; + Ok(summary(doc)) + } + + pub(crate) fn doc_close(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); + if doc.dirty && !discard_changes { + return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); + } + self.session.close(id); + self.renderers.remove(&id); + self.texts.remove(&id); + self.listed.retain(|(d, _), _| *d != id); + self.evicted.remove(&id); + Ok(json!({ "closed": id.0 })) + } + + pub(crate) fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let target = match a.opt_str("path")? { + Some(p) => self.resolve(p, true)?, + None => { + // Saving in place writes where the file was opened from, which the read root + // allows and the write root may not. + let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; + let own_path = PathBuf::from(&own); + self.check_roots(&own, &own_path, file_access::Access::Write)?; + own_path + } + }; + let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + // Saving to a new file is a full rewrite unless asked otherwise, like Save As. + let full = a.opt_bool("full")?.unwrap_or(!same_file); + let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); + if flatten_fill_sign { + self.apply(a, Edit::FlattenFillSign)?; + } + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; + // A save that adds or removes encryption, or must not keep the old revision (Sanitize, + // redaction), is rewritten in full even when an incremental save was asked for. + let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + pub(crate) fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { + self.session.check_save(id, &bytes).map_err(failed)?; + write(target, &bytes)?; + let path = target.to_string_lossy().into_owned(); + self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) + } + + pub(crate) fn doc_revisions(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let ends = doc.revision_ends(); + let sigs = doc.signatures.clone(); + let list: Vec = ends + .iter() + .enumerate() + .map(|(i, end)| { + let start = if i == 0 { 0 } else { ends[i - 1] }; + // A signature signs the revision its byte range ends in. + let signed: Vec<&str> = + sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); + json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) + }) + .collect(); + Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) + } + + pub(crate) fn doc_initial_view(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let bad = |m: String| ToolError::InvalidArgs(m); + let mut v = self.doc(a)?.initial_view(); + let before = v.clone(); + if let Some(n) = a.opt_str("navigation")? { + v.navigation = match n { + "page" => N::PageOnly, + "bookmarks" => N::Bookmarks, + "pages" => N::Pages, + "attachments" => N::Attachments, + "layers" => N::Layers, + o => return Err(bad(format!("unknown navigation {o:?}"))), + }; + } + if let Some(l) = a.opt_str("layout")? { + v.layout = match l { + "default" => L::Default, + "single" => L::SinglePage, + "continuous" => L::SinglePageContinuous, + "two_up" => L::TwoUp, + "two_up_continuous" => L::TwoUpContinuous, + "two_up_cover" => L::TwoUpCoverPage, + "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, + o => return Err(bad(format!("unknown layout {o:?}"))), + }; + } + match a.get("magnification") { + None => {} + Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), + Some(Value::String(m)) => { + v.magnification = match m.as_str() { + "default" => M::Default, + "actual" => M::ActualSize, + "fit_page" => M::FitPage, + "fit_width" => M::FitWidth, + "fit_height" => M::FitHeight, + "fit_visible" => M::FitVisible, + o => return Err(bad(format!("unknown magnification {o:?}"))), + } + } + Some(_) => return Err(bad("magnification is a name or a percentage".into())), + } + if let Some(p) = a.opt_int("page")? { + v.page = (p.max(1) - 1) as usize; + } + for (k, f) in [ + ("fit_window", &mut v.fit_window), + ("center_window", &mut v.center_window), + ("full_screen", &mut v.full_screen), + ("display_title", &mut v.display_title), + ("hide_menubar", &mut v.hide_menubar), + ("hide_toolbar", &mut v.hide_toolbar), + ("hide_window_ui", &mut v.hide_window_ui), + ] { + if let Some(b) = a.opt_bool(k)? { + *f = b; + } + } + if let Some(l) = a.opt_str("language")? { + v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); + } + if let Some(b) = a.opt_str("binding")? { + v.right_to_left = match b { + "left" => false, + "right" => true, + o => return Err(bad(format!("binding is left or right, not {o:?}"))), + }; + } + let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; + out["initial_view"] = json!({ + "navigation": format!("{:?}", v.navigation), + "layout": format!("{:?}", v.layout), + "magnification": format!("{:?}", v.magnification), + "page": v.page + 1, + "fit_window": v.fit_window, + "center_window": v.center_window, + "full_screen": v.full_screen, + "display_title": v.display_title, + "hide_menubar": v.hide_menubar, + "hide_toolbar": v.hide_toolbar, + "hide_window_ui": v.hide_window_ui, + "language": v.language, + "binding": if v.right_to_left { "right" } else { "left" }, + }); + Ok(out) + } + + pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + let id = self.doc(a)?.id; + let before = self.doc(a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } + let images = |key: &str, base: ImageSettings| -> Result { + let Some(v) = a.get(key) else { return Ok(base) }; + let mut s = base; + if let Some(d) = v.get("downsample").and_then(Value::as_bool) { + s.downsample = d; + } + if let Some(p) = v.get("ppi").and_then(Value::as_f64) { + s.target_ppi = p.clamp(9.0, 2400.0); + } + if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { + s.above_ppi = p.clamp(9.0, 2400.0); + } + s.above_ppi = s.above_ppi.max(s.target_ppi); + if let Some(c) = v.get("compression").and_then(Value::as_str) { + let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; + s.compression = match c { + "jpeg" => Compression::Jpeg(q), + "zip" | "flate" => Compression::Flate, + "retain" => Compression::Retain, + other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), + }; + } + Ok(s) + }; + settings.color = images("color", settings.color)?; + settings.gray = images("gray", settings.gray)?; + for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), + ("discard_thumbnails", &mut settings.discard_thumbnails), + ("discard_alternate_images", &mut settings.discard_alternate_images), + ("discard_tags", &mut settings.discard_tags), + ("discard_print_settings", &mut settings.discard_print_settings), + ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), + ("lzw_to_flate", &mut settings.lzw_to_flate), + ("remove_invalid_links", &mut settings.remove_invalid_links), + ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ] { + if let Some(b) = a.opt_bool(key)? { + *flag = b; + } + } + let discard: Vec = match a.get("discard") { + None => Vec::new(), + Some(v) => v + .as_array() + .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? + .iter() + .map(|x| { + x.as_str() + .and_then(pdfcraft_engine::Hidden::from_id) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) + }) + .collect::>()?, + }; + let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + write_atomic(&path, &bytes)?; + fn valid_version(v: &[u8]) -> Option<&str> { + if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } + } + let catalog_version = (settings.compatibility == Compatibility::Retain) + .then(|| { + let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; + let root = doc.root()?; + doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + }) + .flatten(); + let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); + let o = &r.optimize; + Ok(json!({ + "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), + "bytes_before": before, + "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, + "images": o.images, + "images_resampled": o.images_resampled, + "images_recompressed": o.images_recompressed, + "image_bytes_before": o.image_bytes_before, + "image_bytes_after": o.image_bytes_after, + "thumbnails": o.thumbnails, + "alternate_images": o.alternate_images, + "tags_removed": o.tags_removed, + "streams_compressed": o.streams_compressed, + "lzw_streams": o.lzw_streams, + "invalid_links": o.invalid_links, + "invalid_bookmarks": o.invalid_bookmarks, + "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, + "merged_objects": r.merged, + "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), + })) + } + + /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). + fn mark_file(&self, a: &Args) -> Result> { + let Some(p) = a.opt_str("file")? else { return Ok(None) }; + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; + Ok(Some(pdfcraft_engine::MarkFile { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + page, + })) + } + + pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let doc = self.doc(a)?; + let mut out = summary(doc); + // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. + if !doc.edit_notices.is_empty() { + out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); + } + Ok(out) + } + + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { + use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let color = + |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; + let replace = a.opt_bool("replace")?.unwrap_or(false); + let edit = match tool { + "doc_header_footer" => { + let mut hf = HeaderFooter::default(); + for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { + hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); + } + if let Some(s) = a.opt_num("font_size")? { + hf.font_size = s; + } + hf.font = match a.opt_str("font")? { + None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, + Some("Times") => pdfcraft_engine::FontFamily::Times, + Some("Courier") => pdfcraft_engine::FontFamily::Courier, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); + } + }; + hf.color = color("color", hf.color)?; + if let Some(m) = a.get("margins").and_then(Value::as_array) { + let m: Vec = m.iter().filter_map(Value::as_f64).collect(); + hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; + } + if let Some(s) = a.opt_int("start_number")? { + hf.start_number = s.clamp(1, u32::MAX as i64) as u32; + } + hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); + Edit::AddHeaderFooter { pages, settings: hf, replace } + } + "doc_watermark" => { + let d = Watermark::default(); + let file = self.mark_file(a)?; + let text = match &file { + Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), + None => a.str("text")?.to_string(), + }; + let wm = Watermark { + text, + source: None, + scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), + font_size: a.opt_num("font_size")?.unwrap_or(0.0), + color: color("color", d.color)?, + opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), + rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), + behind: a.opt_bool("behind")?.unwrap_or(false), + offset: [ + a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + ], + }; + Edit::AddWatermark { pages, settings: wm, replace, file } + } + "doc_background" => Edit::AddBackground { + pages, + settings: Background { + color: color("color", [1.0; 3])?, + opacity: a.opt_num("opacity")?.unwrap_or(1.0), + scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + ..Background::default() + }, + replace, + file: self.mark_file(a)?, + }, + _ => { + let kind = match a.str("kind")? { + "header_footer" => MarkKind::HeaderFooter, + "watermark" => MarkKind::Watermark, + "background" => MarkKind::Background, + other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), + }; + Edit::RemoveMarks { kind } + } + }; + self.apply(a, edit) + } + + pub(crate) fn export(&mut self, tool: &str, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let mut ex = pdfcraft_engine::export::Exporter::new(doc); + if tool == "doc_export_text" { + let path = self.resolve(a.str("path")?, true)?; + let text = ex.text_of(&pages).map_err(failed)?; + write_atomic(&path, text.as_bytes())?; + return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); + } + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; + if tool == "doc_export_all_images" { + let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); + let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; + let mut files = Vec::new(); + for (k, img) in out.images.iter().enumerate() { + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); + write_atomic(&path, &img.data)?; + files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); + } + let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); + return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); + } + let dpi = a.opt_num("dpi")?.unwrap_or(150.0); + let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; + let format = match a.opt_str("format")?.unwrap_or("png") { + "png" => pdfcraft_engine::export::ImageFormat::Png, + "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, + "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, + f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), + }; + let mut files = Vec::new(); + let mut lowered = Vec::new(); + for p in pages { + let img = ex.image(p, dpi, format).map_err(failed)?; + let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); + write_atomic(&path, &img)?; + files.push(path.to_string_lossy().into_owned()); + // A page too large for the renderer at `dpi` is drawn at the most it allows. + let used = ex.dpi_used(p, dpi); + if used < dpi.clamp(18.0, 1200.0) - 0.5 { + lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); + } + } + if lowered.is_empty() { + Ok(json!({ "count": files.len(), "files": files })) + } else { + Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) + } + } + + pub(crate) fn doc_protect(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; + let d = Protection::default(); + // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is + // enforced against the owner password; without one everything stays allowed). Refuse a + // restriction that could not take effect instead of writing an unrestricted file (#134). + let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); + if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { + return Err(ToolError::InvalidArgs(format!( + "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" + ))); + } + let p = Protection { + open_password: a.opt_str("open_password")?.map(str::to_owned), + permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), + printing: match a.opt_str("printing")? { + None => d.printing, + Some("none") => Printing::None, + Some("low") => Printing::Low, + Some("high") => Printing::High, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), + }, + changes: match a.opt_str("changes")? { + None => d.changes, + Some("none") => Changes::None, + Some("pages") => Changes::Pages, + Some("fill-sign") => Changes::FillSign, + Some("comment-fill-sign") => Changes::CommentFillSign, + Some("any-except-extract") => Changes::AnyExceptExtract, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), + }, + copy: a.opt_bool("copy")?.unwrap_or(d.copy), + accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), + algorithm: match a.opt_str("compatibility")? { + None | Some("aes-256") => Algorithm::Aes256, + Some("aes-128") => Algorithm::Aes128, + Some("rc4-128") => Algorithm::Rc4_128, + Some("rc4-40") => Algorithm::Rc4_40, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), + }, + encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), + }; + let mut out = self.apply(a, Edit::Protect(p))?; + out["security"] = security(self.doc(a)?); + Ok(out) + } +} diff --git a/crates/automation/src/error.rs b/crates/automation/src/error.rs new file mode 100644 index 0000000000..18e58939ed --- /dev/null +++ b/crates/automation/src/error.rs @@ -0,0 +1,33 @@ +/// Why a tool call failed. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolError { + /// No tool has this name. + UnknownTool(String), + /// The arguments don't match the tool's schema. + InvalidArgs(String), + /// The tool ran and failed (the message is for the agent to read). + Failed(String), +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), + ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for ToolError {} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn failed(e: impl std::fmt::Display) -> ToolError { + ToolError::Failed(e.to_string()) +} + +impl From for ToolError { + fn from(e: pdfcraft_engine::EditError) -> Self { + failed(e) + } +} diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs new file mode 100644 index 0000000000..c8710aa509 --- /dev/null +++ b/crates/automation/src/json.rs @@ -0,0 +1,361 @@ +use super::error::{Result, ToolError}; +use super::*; + +/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at +/// most 14 400 across). +const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; + +// ---- JSON helpers ---------------------------------------------------------------------------- + +pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + +/// Typed access to validated arguments. +pub(crate) struct Args<'a>(pub(crate) &'a Value); + +impl Args<'_> { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + pub(crate) fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + pub(crate) fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + pub(crate) fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + pub(crate) fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + pub(crate) fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + pub(crate) fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + pub(crate) fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + /// A 1-based bookmark path → 0-based indices. + pub(crate) fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + pub(crate) fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } + /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error + /// when it is not a number or lies absurdly far off the page. + pub(crate) fn bookmark_coordinate(&self, key: &str) -> Result> { + match self.opt_num(key)? { + None => Ok(None), + Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), + Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), + } + } + pub(crate) fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +pub(crate) fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } +} + +pub(crate) fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// The document's security as the next save writes it (never includes passwords). +pub(crate) fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + +/// What every tool that changes a document returns. +pub(crate) fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +pub(crate) fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), + // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). + "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // A script of the form ran too long and was abandoned: its scripts no longer run in this + // document (calculations, buttons) until it is opened again. + "xfa_scripts_off": d.xfa_scripts_off(), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "outline_more": i.outline_more, + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), + "target": match &l.target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + +fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bf44c9653..e1ae0141c4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -45,73 +45,32 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -/// One piece of a tool's result. -#[derive(Clone, Debug, PartialEq)] -pub enum Content { - /// Structured data (MCP: a text block with the JSON plus `structuredContent`). - Json(Value), - /// A PNG image. - Png { data: Vec, width: u32, height: u32 }, -} - -/// Why a tool call failed. -#[derive(Clone, Debug, PartialEq)] -pub enum ToolError { - /// No tool has this name. - UnknownTool(String), - /// The arguments don't match the tool's schema. - InvalidArgs(String), - /// The tool ran and failed (the message is for the agent to read). - Failed(String), -} - -impl std::fmt::Display for ToolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), - ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), - } - } -} - -impl std::error::Error for ToolError {} - -type Result = std::result::Result; - -fn failed(e: impl std::fmt::Display) -> ToolError { - ToolError::Failed(e.to_string()) -} - -impl From for ToolError { - fn from(e: pdfcraft_engine::EditError) -> Self { - failed(e) - } -} - -/// Default and maximum resolution for `page_render`. -const DEFAULT_DPI: f64 = 96.0; -const MAX_DPI: f64 = 600.0; -/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). -const WATERMARK_MAX_OFFSET: f64 = 20_000.0; - -/// Bookmarks in one page of the bookmark list, by default and at most. -const BOOKMARK_PAGE: usize = 100; -const MAX_BOOKMARK_PAGE: usize = 1000; -/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at -/// most 14 400 across). -const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; - -/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a -/// match near the start returns without touching the rest of a long document (#971). -const FIND_FIRST_BATCH: usize = 8; -const FIND_MAX_BATCH: usize = 128; - use file_access::foreign_share; use text_cache::TextCache; -type PageTexts = Vec<(usize, std::result::Result, String>)>; -const FIND_MAX_WARNINGS: usize = 100; -const FIND_MAX_WARNING_BYTES: usize = 1024; +mod assemble; +mod dispatch; +mod documents; +mod error; +mod json; +mod pages; +mod paths; + +pub use dispatch::Content; +pub use error::ToolError; +use error::{Result, failed}; +pub(crate) use json::destination; +pub use json::view_rect; +use json::{Args, info, summary}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png}; +#[cfg(test)] +use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; +#[cfg(test)] +pub(crate) use paths::MAX_INPUT; +use paths::{child, lexical, unused, write_atomic}; +pub(crate) use paths::{read_input, read_input_bytes}; +#[cfg(test)] +use paths::{write_atomic_synced, write_atomic_with}; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -205,2652 +164,6 @@ impl Automation { } Ok(target) } - - /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). - pub fn call(&mut self, name: &str, args: &Value) -> Result> { - let empty = json!({}); - let args = if args.is_null() { &empty } else { args }; - if !args.is_object() { - return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); - } - let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; - tools::check_args(def, args)?; - let a = Args(args); - let out = match name { - "doc_open" => self.doc_open(&a)?, - "doc_batch" => self.doc_batch(&a)?, - "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), - "doc_info" => { - let id = self.doc(&a)?.id; - self.session.refresh_fonts(id); - info(self.doc(&a)?) - } - "doc_close" => self.doc_close(&a)?, - "doc_save" => self.doc_save(&a)?, - "doc_set_info" => { - let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; - self.apply(&a, edit)? - } - "page_render" => return self.page_render(&a).map(|c| vec![c]), - "comment_image_preview" => return self.comment_image_preview(&a), - "text_extract" => self.text_extract(&a)?, - "text_word_count" => self.text_word_count(&a)?, - "text_find" => self.text_find(&a)?, - "page_rotate" => { - let degrees = a.int("degrees")?; - if degrees % 90 != 0 { - return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); - } - let doc = self.doc(&a)?; - let base = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let parity = match a.opt_str("subset")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageParity::Both, - "even" => pdfcraft_engine::PageParity::Even, - "odd" => pdfcraft_engine::PageParity::Odd, - s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), - }; - let orientation = match a.opt_str("orientation")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageOrientation::Both, - "landscape" => pdfcraft_engine::PageOrientation::Landscape, - "portrait" => pdfcraft_engine::PageOrientation::Portrait, - o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), - }; - let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); - if pages.is_empty() { - return Err(failed("no pages match the filters")); - } - let n = pages.len(); - let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; - out["rotated"] = json!(n); - out - } - "page_enhance_camera" => { - let page = self.page(&a)?; - let given = a.nums::<8>("corners")?; - let doc = self.doc(&a)?; - let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); - let corners = match given { - Some(c) => [0, 1, 2, 3].map(|i| { - let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); - [x as f64, y as f64] - }), - None => self.session.detect_camera_corners(id, page).ok_or_else(|| { - failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") - })?, - }; - let level = match a.opt_int("level")? { - None => 0, - Some(l @ 0..=100) => l as u8, - Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), - }; - let look = match a.opt_str("mode")? { - None => pdfcraft_engine::camera_clean::Look::Colour, - Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, - }; - self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; - let r = |v: f32| (v * 100.0).round() / 100.0; - let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); - let mut out = summary(self.doc(&a)?); - out["corners"] = json!(used); - out["detected"] = json!(given.is_none()); - out - } - "page_delete" => { - let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DeletePages { pages })? - } - "page_move" => { - let pages = self.pages(&a, "pages")?; - let to = self.position(&a, "to")?; - self.apply(&a, Edit::MovePages { pages, to })? - } - "page_reverse" => { - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..self.doc(&a)?.info.pages.len()).collect(), - }; - self.apply(&a, Edit::ReversePages { pages })? - } - "page_insert_blank" => self.insert_blank(&a)?, - "page_insert_file" => self.insert_file(&a)?, - "page_extract" => self.page_extract(&a)?, - "doc_combine" => self.doc_combine(&a)?, - "doc_create_multiple" => self.doc_create_multiple(&a)?, - "doc_split" => self.doc_split(&a)?, - "edit_undo" => { - let id = self.doc(&a)?.id; - let label = self.session.undo(id).map_err(failed)?.to_string(); - json!({ "undone": label, "document": summary(self.doc(&a)?) }) - } - "edit_redo" => { - let id = self.doc(&a)?.id; - let label = self.session.redo(id).map_err(failed)?.to_string(); - json!({ "redone": label, "document": summary(self.doc(&a)?) }) - } - "command_list" => self.command_list(&a)?, - "command_run" => return self.command_run(&a), - "command_batch" => self.command_batch(&a)?, - "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), - "page_number" => { - use pdfcraft_organize::LabelStyle as L; - let n = self.doc(&a)?.info.pages.len(); - let (from, to) = (a.int("from")?, a.int("to")?); - if from < 1 || to < from || to as usize > n { - return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); - } - let style = match a.opt_str("style")?.unwrap_or("decimal") { - "decimal" => L::Decimal, - "upper-roman" => L::UpperRoman, - "lower-roman" => L::LowerRoman, - "upper-alpha" => L::UpperAlpha, - "lower-alpha" => L::LowerAlpha, - "none" => L::None, - other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), - }; - let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); - let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; - let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; - out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); - out - } - "bookmark_list" => self.bookmark_listing(&a)?, - "bookmark_add" => { - let page = self.page(&a)?; - let parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - let title = a.str("title")?.to_string(); - let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); - let [left, top] = match self.doc(&a)?.info.pages.get(page) { - Some(p) => p.xyz_for_view(left, top), - None => [None, None], - }; - let (left, top) = (left.map(f64::from), top.map(f64::from)); - self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? - } - "bookmark_rename" => { - let (path, title) = (a.path("path")?, a.str("title")?.to_string()); - self.apply(&a, Edit::RenameBookmark { path, title })? - } - "bookmark_delete" => { - let path = a.path("path")?; - self.apply(&a, Edit::DeleteBookmark { path })? - } - "bookmark_move" => { - let from = a.path("path")?; - let to_parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? - } - "bookmark_set_page" => { - let (path, page) = (a.path("path")?, self.page(&a)?); - self.apply(&a, Edit::SetBookmarkPage { path, page })? - } - "bookmark_set_style" => { - let path = a.path("path")?; - // Left out keeps the colour; [0, 0, 0] is the default black. - let color = match a.get("color") { - None => None, - Some(v) => { - let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); - match rgb.as_deref() { - Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), - _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), - } - } - }; - let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); - if color.is_none() && bold.is_none() && italic.is_none() { - return Err(ToolError::InvalidArgs("give color, bold or italic".into())); - } - let mut edits = Vec::new(); - if let Some(color) = color { - edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); - } - if bold.is_some() || italic.is_some() { - edits.push(Edit::SetBookmarkStyle { path, bold, italic }); - } - // A colour and a style given together are one step to undo, applied all or nothing. - match edits.len() { - 1 => self.apply(&a, edits.remove(0))?, - _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, - } - } - "bookmark_from_structure" => { - let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; - let listing = self.bookmark_listing(&a)?; - out["bookmarks"] = listing["bookmarks"].clone(); - out["next"] = listing["next"].clone(); - out - } - "doc_protect" => self.doc_protect(&a)?, - "page_replace" => { - let pages = self.pages(&a, "pages")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(read_input(&path)?); - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let src_pages = match a.opt_ints("from_pages")? { - Some(p) => one_based(&p)?, - None => (0..pages.len()).collect(), - }; - self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? - } - "page_duplicate" => { - let pages = self.pages(&a, "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)?, - "doc_create" => self.doc_create(&a)?, - "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, - "doc_flatten" => { - let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); - if !comments && !fields { - return Err(ToolError::InvalidArgs("nothing to flatten".into())); - } - self.apply(&a, Edit::Flatten { comments, fields })? - } - "doc_reduce" => { - let id = self.doc(&a)?.id; - let before = self.doc(&a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - // As in the app, a copy that isn't smaller is not written (#490). - let written = bytes.len() < before; - if written { - write_atomic(&path, &bytes)?; - } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) - } - "doc_optimize" => self.doc_optimize(&a)?, - "doc_initial_view" => self.doc_initial_view(&a)?, - "doc_revisions" => self.doc_revisions(&a)?, - "text_lines" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let lines: Vec = doc - .text_lines(page) - .iter() - .enumerate() - .map(|(i, l)| { - let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - json!({ - "line": i + 1, - "text": l.text, - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": l.base_font, - "size": (l.size * 100.0).round() / 100.0, - }) - }) - .collect(); - json!({ "page": page + 1, "count": lines.len(), "lines": lines }) - } - "image_hit" => { - let page = self.page(&a)?; - let point = a - .get("point") - .and_then(Value::as_array) - .filter(|p| p.len() == 2) - .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) - .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) - .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; - let doc = self.doc(&a)?; - let p = &doc.info.pages[page]; - let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); - let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); - json!({"page":page+1,"images":images}) - } - "page_images" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let list: Vec = doc - .page_images(page) - .iter() - .enumerate() - .map(|(i, im)| { - let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); - let visible = im.visible_rect.map(|rect| { - let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); - [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] - }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) - }) - .collect(); - json!({ "page": page + 1, "count": list.len(), "images": list }) - } - "image_edit" | "image_save" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.page_images(page).len(); - let k = a.int("image")?; - if k < 1 || k as usize > n { - return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); - } - let index = k as usize - 1; - if name == "image_save" { - let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; - let mut path = self.resolve(a.str("path")?, true)?; - if path.is_dir() { - // Adding the extension to a folder's name would write beside it: "." is the - // root, so that would land outside it. - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - if path.extension().is_none() { - path.set_extension(ext); - } - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) - } else { - use pdfcraft_engine::ImageEdit; - let change = match a.str("action")? { - "move" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; - // Top-left-origin points → user space. - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "rotate_by" => ImageEdit::RotateBy( - a.get("degrees") - .and_then(Value::as_f64) - .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, - ), - "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), - "crop" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = - <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, - "delete" => ImageEdit::Delete, - "replace" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - ImageEdit::Replace { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - } - } - other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), - }; - self.apply(&a, Edit::EditPageImage { page, index, change })? - } - } - "text_paragraphs" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let current = doc.text_blocks(page); - let id = doc.id; - let blocks: Vec = current - .iter() - .enumerate() - .map(|(i, b)| { - let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); - json!({ - "paragraph": i + 1, - "text": b.text, - "editor_text": b.editor_text, - "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), - "lines": b.lines.iter().map(|l| l + 1).collect::>(), - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": b.base_font, - "size": (b.size * 100.0).round() / 100.0, - }) - }) - .collect(); - self.record_listing(id, page, ¤t); - json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) - } - "text_font_files" => { - let roots = if let Some(paths) = a.get("directories") { - let paths = paths - .as_array() - .filter(|p| p.len() <= 16) - .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; - paths - .iter() - .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) - .collect::>>()? - } else if let Some(roots) = &self.roots { - // The installed fonts are outside the roots: look in the read root instead. - let read = roots - .read_root() - .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; - vec![self.resolve(&read.to_string_lossy(), false)?] - } else if let Some(root) = &self.root { - vec![root.clone()] - } else { - pdfcraft_engine::text_fonts::installed_directories() - }; - json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) - } - "text_font_import" => { - let page = self.page(&a)?; - let path = self.resolve(a.str("path")?, false)?; - let file = std::fs::File::open(&path).map_err(failed)?; - use std::io::Read; - let mut bytes = Vec::new(); - file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; - if bytes.len() > 16 << 20 { - return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); - } - let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); - let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; - let added = self - .doc(&a)? - .text_editor_fonts(page) - .into_iter() - .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); - if let Some(f) = added { - out["resource"] = json!(f.resource); - out["font"] = json!(f.face); - } - out - } - "text_fonts" => { - let page = self.page(&a)?; - json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) - } - "text_edit_layout" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let k = a.int("paragraph")?; - let blocks = doc.text_blocks(page); - let index = self.paragraph_index(doc.id, page, k, &blocks)?; - let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; - let text = a.opt_str("text")?.unwrap_or(&source.editor_text); - let style = pdfcraft_engine::BlockStyle { - size: a.opt_num("size")?, - width: a.opt_num("width")?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - ranges: parse_style_ranges(a.get("ranges"))?, - ..Default::default() - }; - let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; - let info = &doc.info.pages[page]; - let view_rect = |r: [f64; 4]| { - let a = info.user_to_view(r[0] as f32, r[1] as f32); - let b = info.user_to_view(r[2] as f32, r[3] as f32); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - }; - let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); - json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), - "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) - } - "text_edit" if a.get("paragraph").is_some() => { - let page = self.page(&a)?; - let k = a.int("paragraph")?; - let (id, blocks) = { - let doc = self.doc(&a)?; - (doc.id, doc.text_blocks(page)) - }; - let index = self.paragraph_index(id, page, k, &blocks)?; - let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); - let mut style = pdfcraft_engine::BlockStyle { - ranges: parse_style_ranges(a.get("ranges"))?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - bold: a.opt_bool("bold")?, - size: a.opt_num("size")?, - underline: a.opt_bool("underline")?, - line_spacing: a.opt_num("line_spacing")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - width: a.opt_num("width")?, - ..Default::default() - }; - let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); - if dx.is_some() || dy.is_some() { - style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); - } - if let Some(f) = a.opt_str("font")? { - let family = match f { - "helvetica" => pdfcraft_engine::FontFamily::Helvetica, - "times" => pdfcraft_engine::FontFamily::Times, - "courier" => pdfcraft_engine::FontFamily::Courier, - other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), - }; - style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); - } - if let Some(c) = a.opt_str("color")? { - style.color = Some(comments::parse_color(c)?); - } - if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); - } - // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; - let wanted: String = text.split_whitespace().collect(); - let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let doc = self.doc(&a)?; - let after = doc.text_blocks(page); - // Without `text` the paragraph keeps the document's own words: not echoed when - // the document doesn't allow copying them. - let copy = doc.allows_copying(); - // Only a paragraph that now shows exactly the requested text is the edited one. A - // neighbour that overlaps the old box (overlaid text, a text layer) must not take - // its number, and neither must a paragraph the edit merged it into: rewriting that - // one by this number would drop the neighbour's text. - let edited = block_overlapping(&after, moved) - .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); - if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); - if copy { - out["paragraph"]["text"] = json!(b.text); - } - } - let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); - if !stale.is_empty() { - out["stale_paragraphs"] = json!(stale); - } - out - } - "text_edit" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.text_lines(page).len(); - let line = a.int("line")?; - if line < 1 || line as usize > n { - return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); - } - let text = a.str("text")?.to_owned(); - let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; - let after = self.doc(&a)?.text_lines(page); - if let Some(l) = after.get(line as usize - 1) { - out["line"] = json!({ "text": l.text, "font": l.base_font }); - } - out - } - "doc_audit_space" => { - let rows: Vec = self - .doc(&a)? - .audit_space() - .iter() - .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) - .collect(); - json!({ "categories": rows }) - } - "doc_open_revision" => { - let id = self.doc(&a)?.id; - let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); - let new = self.session.open_revision(id, n).map_err(failed)?; - summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) - } - "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, - "doc_unprotect" => { - let mut out = self.apply(&a, Edit::RemoveProtection)?; - out["security"] = security(self.doc(&a)?); - out - } - "form_fields" => self.form_fields(&a)?, - "form_fill" => self.form_fill(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } - "form_reset" => self.form_reset(&a)?, - "form_add_field" => self.form_add_field(&a)?, - "form_add_field_table" => self.form_add_field_table(&a)?, - "form_set_props" => self.form_set_props(&a)?, - "form_delete_field" => self.form_delete_field(&a)?, - "form_tab_order" => self.form_tab_order(&a)?, - "form_calculation_order" => self.form_calculation_order(&a)?, - "doc_export_data" => self.doc_export_data(&a)?, - "doc_import_data" => self.doc_import_data(&a)?, - "redact_mark" => self.redact_mark(&a)?, - "redact_apply" => self.redact_apply(&a)?, - "redact_clear" => self.redact_clear(&a)?, - "doc_hidden_info" => self.doc_hidden_info(&a)?, - "printers" => self.printers()?, - "printer_options" => self.printer_options(&a)?, - "link_list" => self.link_list(&a)?, - "link_open" => self.link_open(&a)?, - "link_add" => self.link_add(&a)?, - "link_edit" => self.link_edit(&a)?, - "link_delete" => self.link_delete(&a)?, - "links_from_urls" => self.links_from_urls(&a)?, - "links_remove" => self.links_remove(&a)?, - "object_list" => self.object_list(&a)?, - "object_move" => self.object_move(&a)?, - "content_list" => self.content_list(&a)?, - "page_add_text" => self.page_add_text(&a)?, - "page_add_image" => self.page_add_image(&a)?, - "content_update" => self.content_update(&a)?, - "content_delete" => self.content_delete(&a)?, - "content_copy" => self.content_copy(&a)?, - "doc_print" => self.doc_print(&a)?, - "attachment_add" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = match a.opt_str("name")? { - Some(n) => n.to_string(), - None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - }; - let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); - let description = a.opt_str("description")?.map(str::to_string); - // Only the document-level keys: an annotation attachment of the same name is not the new one. - let before = document_attachment_keys(self.doc(&a)?); - let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; - let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); - json!({ "name": key, "document": out }) - } - "attachment_delete" => { - let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; - self.apply(&a, edit)? - } - "doc_remove_hidden" => self.doc_remove_hidden(&a)?, - "doc_audit_redactions" => self.doc_audit_redactions(&a)?, - "fill_sign_add" => self.fill_sign_add(&a)?, - "fill_sign_date_format" => self.fill_sign_date_format(&a)?, - "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, - "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, - "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, - "measure_info" => self.measurement_info(&a)?, - "measure_list" => self.measurement_list(&a)?, - "measure_scale" => self.measurement_scale(&a)?, - "measure_snap" => self.measurement_snap(&a)?, - "measure_export" => self.measurement_export(&a)?, - "comment_list" => self.comment_list(&a)?, - "comment_add" => self.comment_add(&a)?, - "stamp_custom" => self.stamp_custom(&a)?, - "comment_reply" => self.comment_reply(&a)?, - "comment_set_status" => self.comment_set_status(&a)?, - "sign_list" => self.sign_list(&a)?, - "accessibility_check" => self.a11y_check(&a)?, - "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, - "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, - "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, - "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, - "ocr_recognize" => self.ocr_recognize(&a)?, - "js_run" => self.js_run(&a)?, - "js_document_scripts" => self.js_document_scripts(&a)?, - "js_set_document_script" => self.js_set_document_script(&a)?, - "js_enabled" => self.js_enabled(&a)?, - "form_set_script" => self.form_set_script(&a)?, - "form_merge_data" => self.form_merge_data(&a)?, - "form_actions" => self.form_actions(&a)?, - "form_detect_fields" => self.form_detect_fields(&a)?, - "doc_compare" => self.doc_compare(&a)?, - "action_list" => self.action_list()?, - "pdfa_verify" => self.pdfa_verify(&a)?, - "print_readiness" => self.print_readiness(&a)?, - "doc_export_office" => self.doc_export_office(&a)?, - "pdfa_convert" => self.pdfa_convert(&a)?, - "standards_remove_id" => { - let standard = match a.str("standard")? { - "pdfa" => pdfcraft_engine::Standard::PdfA, - "pdfx" => pdfcraft_engine::Standard::PdfX, - other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), - }; - self.apply(&a, Edit::RemoveStandardId { standard })? - } - "action_run" => self.action_run(&a)?, - "doc_compare_report" => self.doc_compare_report(&a)?, - "doc_compare_mark" => self.doc_compare_mark(&a)?, - "form_set_actions" => self.form_set_actions(&a)?, - "ocr_status" => self.ocr_status()?, - "ocr_recognize_files" => self.ocr_recognize_files(&a)?, - "accessibility_report" => self.a11y_report(&a)?, - "accessibility_review_record" => self.a11y_review_record(&a)?, - "accessibility_review_save" => self.a11y_review_save(&a)?, - "accessibility_review_load" => self.a11y_review_load(&a)?, - "accessibility_review_html" => self.a11y_review_html(&a)?, - "accessibility_fix" => self.a11y_fix(&a)?, - "accessibility_figures" => self.accessibility_figures(&a)?, - "accessibility_set_alt" => self.accessibility_set_alt(&a)?, - "accessibility_tags" => self.accessibility_tags(&a)?, - "accessibility_tag_content" => self.accessibility_tag_content(&a)?, - "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, - "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, - "accessibility_move_tag" => self.accessibility_move_tag(&a)?, - "sign_id_create" => self.sign_id_create(&a)?, - "sign_document" => self.sign_document(&a)?, - "sign_keychain_ids" => { - #[cfg(target_os = "macos")] - let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) - .map_err(failed)? - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - #[cfg(not(target_os = "macos"))] - let ids: Vec = Vec::new(); - json!({ "count": ids.len(), "ids": ids }) - } - "sign_windows_ids" => { - #[cfg(target_os = "windows")] - let (ids, unusable): (Vec, Vec) = { - let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; - let ids = listing - .ids - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - let unusable = listing - .unusable - .iter() - .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) - .collect(); - (ids, unusable) - }; - #[cfg(not(target_os = "windows"))] - let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); - json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) - } - "sign_trust" => self.sign_trust(&a)?, - "comment_mark" => self.comment_mark(&a)?, - "comment_lock" => self.comment_lock(&a)?, - "comments_hide" => self.comments_hide(&a)?, - "layer_set" => self.layer_set(&a)?, - "view_reading_colors" => self.view_reading_colors(&a)?, - "comments_summarize" => self.comments_summarize(&a)?, - "comment_edit" => self.comment_edit(&a)?, - "comment_delete" => self.comment_delete(&a)?, - other => return Err(ToolError::UnknownTool(other.into())), - }; - Ok(vec![Content::Json(out)]) - } - - // ---- documents --------------------------------------------------------------------------- - - /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). - fn bookmark_listing(&self, a: &Args) -> Result { - let id = a.int("doc")?; - let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); - let offset = a.opt_int("offset")?.unwrap_or(0); - let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; - let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); - let limit = usize::try_from(limit) - .ok() - .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) - .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; - let page = self - .session - .bookmark_page(id, offset, limit) - .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - let pages = &self.doc(a)?.info.pages; - Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) - } - - fn doc(&self, a: &Args) -> Result<&Document> { - let id = a.int("doc")?; - let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; - self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) - } - - /// Most pages whose paragraph listing is remembered. - const MAX_LISTINGS: usize = 256; - - /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be - /// resolved against that listing. - fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { - if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { - self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); - self.listed.clear(); - } - self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); - } - - /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). - /// - /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is - /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a - /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph - /// and lose its text (#2516). So is any number on a page of a document whose listings were - /// dropped at the cap, until the page is listed again. - fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { - let keys = self.listed.get(&(doc, page)); - if keys.is_none() && self.evicted.contains(&doc) { - return Err(ToolError::InvalidArgs(format!( - "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", - page + 1 - ))); - } - let n = keys.map_or(blocks.len(), Vec::len); - let i = usize::try_from(k) - .ok() - .and_then(|k| k.checked_sub(1)) - .filter(|i| *i < n) - .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; - let Some(keys) = keys else { return Ok(i) }; - keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { - ToolError::InvalidArgs(format!( - "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", - page + 1 - )) - }) - } - - /// Update the listing after paragraph `k` was edited into `edited`, one of the page's - /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. - fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { - let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; - if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { - *key = BlockKey::of(edited); - } - keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() - } - - /// The document, if its security allows copying text and images out of it (Table 22, bit 5). - fn doc_to_copy(&self, a: &Args) -> Result<&Document> { - let doc = self.doc(a)?; - doc.check_copying().map_err(failed)?; - Ok(doc) - } - - fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) - } - - fn doc_close(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); - if doc.dirty && !discard_changes { - return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); - } - self.session.close(id); - self.renderers.remove(&id); - self.texts.remove(&id); - self.listed.retain(|(d, _), _| *d != id); - self.evicted.remove(&id); - Ok(json!({ "closed": id.0 })) - } - - fn doc_save(&mut self, a: &Args) -> Result { - self.doc_save_with(a, write_atomic) - } - - fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let target = match a.opt_str("path")? { - Some(p) => self.resolve(p, true)?, - None => { - // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. - let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path - } - }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); - // Saving to a new file is a full rewrite unless asked otherwise, like Save As. - let full = a.opt_bool("full")?.unwrap_or(!same_file); - let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); - if flatten_fill_sign { - self.apply(a, Edit::FlattenFillSign)?; - } - let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; - // A save that adds or removes encryption, or must not keep the old revision (Sanitize, - // redaction), is rewritten in full even when an incremental save was asked for. - let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) - } - - /// Validate the prepared bytes before publishing them or marking the document saved. - fn publish_save( - &mut self, - id: DocId, - target: &Path, - bytes: Arc>, - incremental: bool, - write: impl FnOnce(&Path, &[u8]) -> Result<()>, - ) -> Result { - self.session.check_save(id, &bytes).map_err(failed)?; - write(target, &bytes)?; - let path = target.to_string_lossy().into_owned(); - self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) - } - - fn doc_revisions(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let ends = doc.revision_ends(); - let sigs = doc.signatures.clone(); - let list: Vec = ends - .iter() - .enumerate() - .map(|(i, end)| { - let start = if i == 0 { 0 } else { ends[i - 1] }; - // A signature signs the revision its byte range ends in. - let signed: Vec<&str> = - sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); - json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) - }) - .collect(); - Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) - } - - fn doc_initial_view(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let bad = |m: String| ToolError::InvalidArgs(m); - let mut v = self.doc(a)?.initial_view(); - let before = v.clone(); - if let Some(n) = a.opt_str("navigation")? { - v.navigation = match n { - "page" => N::PageOnly, - "bookmarks" => N::Bookmarks, - "pages" => N::Pages, - "attachments" => N::Attachments, - "layers" => N::Layers, - o => return Err(bad(format!("unknown navigation {o:?}"))), - }; - } - if let Some(l) = a.opt_str("layout")? { - v.layout = match l { - "default" => L::Default, - "single" => L::SinglePage, - "continuous" => L::SinglePageContinuous, - "two_up" => L::TwoUp, - "two_up_continuous" => L::TwoUpContinuous, - "two_up_cover" => L::TwoUpCoverPage, - "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, - o => return Err(bad(format!("unknown layout {o:?}"))), - }; - } - match a.get("magnification") { - None => {} - Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), - Some(Value::String(m)) => { - v.magnification = match m.as_str() { - "default" => M::Default, - "actual" => M::ActualSize, - "fit_page" => M::FitPage, - "fit_width" => M::FitWidth, - "fit_height" => M::FitHeight, - "fit_visible" => M::FitVisible, - o => return Err(bad(format!("unknown magnification {o:?}"))), - } - } - Some(_) => return Err(bad("magnification is a name or a percentage".into())), - } - if let Some(p) = a.opt_int("page")? { - v.page = (p.max(1) - 1) as usize; - } - for (k, f) in [ - ("fit_window", &mut v.fit_window), - ("center_window", &mut v.center_window), - ("full_screen", &mut v.full_screen), - ("display_title", &mut v.display_title), - ("hide_menubar", &mut v.hide_menubar), - ("hide_toolbar", &mut v.hide_toolbar), - ("hide_window_ui", &mut v.hide_window_ui), - ] { - if let Some(b) = a.opt_bool(k)? { - *f = b; - } - } - if let Some(l) = a.opt_str("language")? { - v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); - } - if let Some(b) = a.opt_str("binding")? { - v.right_to_left = match b { - "left" => false, - "right" => true, - o => return Err(bad(format!("binding is left or right, not {o:?}"))), - }; - } - let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; - out["initial_view"] = json!({ - "navigation": format!("{:?}", v.navigation), - "layout": format!("{:?}", v.layout), - "magnification": format!("{:?}", v.magnification), - "page": v.page + 1, - "fit_window": v.fit_window, - "center_window": v.center_window, - "full_screen": v.full_screen, - "display_title": v.display_title, - "hide_menubar": v.hide_menubar, - "hide_toolbar": v.hide_toolbar, - "hide_window_ui": v.hide_window_ui, - "language": v.language, - "binding": if v.right_to_left { "right" } else { "left" }, - }); - Ok(out) - } - - fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; - let id = self.doc(a)?.id; - let before = self.doc(a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let mut settings = match a.opt_str("preset")? { - None => Preset::Standard.settings(), - Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), - }; - if let Some(c) = a.opt_str("compatibility")? { - settings.compatibility = Compatibility::from_name(c) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; - } - let images = |key: &str, base: ImageSettings| -> Result { - let Some(v) = a.get(key) else { return Ok(base) }; - let mut s = base; - if let Some(d) = v.get("downsample").and_then(Value::as_bool) { - s.downsample = d; - } - if let Some(p) = v.get("ppi").and_then(Value::as_f64) { - s.target_ppi = p.clamp(9.0, 2400.0); - } - if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { - s.above_ppi = p.clamp(9.0, 2400.0); - } - s.above_ppi = s.above_ppi.max(s.target_ppi); - if let Some(c) = v.get("compression").and_then(Value::as_str) { - let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; - s.compression = match c { - "jpeg" => Compression::Jpeg(q), - "zip" | "flate" => Compression::Flate, - "retain" => Compression::Retain, - other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), - }; - } - Ok(s) - }; - settings.color = images("color", settings.color)?; - settings.gray = images("gray", settings.gray)?; - for (key, flag) in [ - ("merge_fragments", &mut settings.merge_fragments), - ("discard_thumbnails", &mut settings.discard_thumbnails), - ("discard_alternate_images", &mut settings.discard_alternate_images), - ("discard_tags", &mut settings.discard_tags), - ("discard_print_settings", &mut settings.discard_print_settings), - ("flate_unencoded", &mut settings.flate_unencoded), - ("object_streams", &mut settings.object_streams), - ("lzw_to_flate", &mut settings.lzw_to_flate), - ("remove_invalid_links", &mut settings.remove_invalid_links), - ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), - ] { - if let Some(b) = a.opt_bool(key)? { - *flag = b; - } - } - let discard: Vec = match a.get("discard") { - None => Vec::new(), - Some(v) => v - .as_array() - .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? - .iter() - .map(|x| { - x.as_str() - .and_then(pdfcraft_engine::Hidden::from_id) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) - }) - .collect::>()?, - }; - let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; - write_atomic(&path, &bytes)?; - fn valid_version(v: &[u8]) -> Option<&str> { - if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } - } - let catalog_version = (settings.compatibility == Compatibility::Retain) - .then(|| { - let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; - let root = doc.root()?; - doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) - }) - .flatten(); - let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); - // Viewers read the later of the two (object streams raise the header past a lower catalog - // entry). Both are "d.d", so text order is version order. - let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); - let o = &r.optimize; - Ok(json!({ - "path": path.to_string_lossy(), - "pdf_version": pdf_version, - "object_streams": settings.effective_object_streams(), - "bytes_before": before, - "bytes_after": bytes.len(), - "merged_images": o.merged_images, - "fragments_merged": o.fragments_merged, - "images": o.images, - "images_resampled": o.images_resampled, - "images_recompressed": o.images_recompressed, - "image_bytes_before": o.image_bytes_before, - "image_bytes_after": o.image_bytes_after, - "thumbnails": o.thumbnails, - "alternate_images": o.alternate_images, - "tags_removed": o.tags_removed, - "streams_compressed": o.streams_compressed, - "lzw_streams": o.lzw_streams, - "invalid_links": o.invalid_links, - "invalid_bookmarks": o.invalid_bookmarks, - "unreferenced_dests": o.unreferenced_dests, - "unused_xobjects": o.unused_xobjects, - "merged_objects": r.merged, - "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), - })) - } - - /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). - fn mark_file(&self, a: &Args) -> Result> { - let Some(p) = a.opt_str("file")? else { return Ok(None) }; - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; - Ok(Some(pdfcraft_engine::MarkFile { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - page, - })) - } - - fn apply(&mut self, a: &Args, edit: Edit) -> Result { - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let doc = self.doc(a)?; - let mut out = summary(doc); - // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. - if !doc.edit_notices.is_empty() { - out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); - } - Ok(out) - } - - fn marks(&mut self, tool: &str, a: &Args) -> Result { - use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let color = - |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; - let replace = a.opt_bool("replace")?.unwrap_or(false); - let edit = match tool { - "doc_header_footer" => { - let mut hf = HeaderFooter::default(); - for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { - hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); - } - if let Some(s) = a.opt_num("font_size")? { - hf.font_size = s; - } - hf.font = match a.opt_str("font")? { - None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, - Some("Times") => pdfcraft_engine::FontFamily::Times, - Some("Courier") => pdfcraft_engine::FontFamily::Courier, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); - } - }; - hf.color = color("color", hf.color)?; - if let Some(m) = a.get("margins").and_then(Value::as_array) { - let m: Vec = m.iter().filter_map(Value::as_f64).collect(); - hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; - } - if let Some(s) = a.opt_int("start_number")? { - hf.start_number = s.clamp(1, u32::MAX as i64) as u32; - } - hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); - Edit::AddHeaderFooter { pages, settings: hf, replace } - } - "doc_watermark" => { - let d = Watermark::default(); - let file = self.mark_file(a)?; - let text = match &file { - Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), - None => a.str("text")?.to_string(), - }; - let wm = Watermark { - text, - source: None, - scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), - font_size: a.opt_num("font_size")?.unwrap_or(0.0), - color: color("color", d.color)?, - opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), - rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), - behind: a.opt_bool("behind")?.unwrap_or(false), - offset: [ - a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - ], - }; - Edit::AddWatermark { pages, settings: wm, replace, file } - } - "doc_background" => Edit::AddBackground { - pages, - settings: Background { - color: color("color", [1.0; 3])?, - opacity: a.opt_num("opacity")?.unwrap_or(1.0), - scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), - ..Background::default() - }, - replace, - file: self.mark_file(a)?, - }, - _ => { - let kind = match a.str("kind")? { - "header_footer" => MarkKind::HeaderFooter, - "watermark" => MarkKind::Watermark, - "background" => MarkKind::Background, - other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), - }; - Edit::RemoveMarks { kind } - } - }; - self.apply(a, edit) - } - - fn export(&mut self, tool: &str, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); - let mut ex = pdfcraft_engine::export::Exporter::new(doc); - if tool == "doc_export_text" { - let path = self.resolve(a.str("path")?, true)?; - let text = ex.text_of(&pages).map_err(failed)?; - write_atomic(&path, text.as_bytes())?; - return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); - } - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; - if tool == "doc_export_all_images" { - let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); - let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; - let mut files = Vec::new(); - for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); - write_atomic(&path, &img.data)?; - files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); - } - let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); - return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); - } - let dpi = a.opt_num("dpi")?.unwrap_or(150.0); - let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; - let format = match a.opt_str("format")?.unwrap_or("png") { - "png" => pdfcraft_engine::export::ImageFormat::Png, - "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, - "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, - f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), - }; - let mut files = Vec::new(); - let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; - let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; - files.push(path.to_string_lossy().into_owned()); - // A page too large for the renderer at `dpi` is drawn at the most it allows. - let used = ex.dpi_used(p, dpi); - if used < dpi.clamp(18.0, 1200.0) - 0.5 { - lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); - } - } - if lowered.is_empty() { - Ok(json!({ "count": files.len(), "files": files })) - } else { - Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) - } - } - - fn doc_create(&mut self, a: &Args) -> Result { - let (name, bytes) = match a.str("from")? { - "blank" => { - let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; - let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); - ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) - } - "images" => { - let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); - let mut images = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); - } - let name = if images.len() == 1 { - format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) - } else { - "Images.pdf".into() - }; - (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) - } - "text" => { - let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { - (Some(t), _) => ("Text".to_string(), t.to_string()), - (None, Some(p)) => { - let path = self.resolve(p, false)?; - let t = read_input(&path)?; - (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) - } - (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), - }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. - let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { - pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), - e => failed(e), - })?; - (format!("{title}.pdf"), bytes) - } - other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), - }; - let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); - let id = self.session.open_new(name, bytes).map_err(failed)?; - Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) - } - - fn page_transition_set(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Transition, TransitionSpec}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let transition = match a.opt_str("style")? { - None | Some("none") => None, - Some(style) => { - let mut t = Transition::new(style); - if let Some(d) = a.opt_num("duration")? { - t.duration = d as f32; - } - if let Some(dm) = a.opt_str("dimension")? { - t.horizontal = match dm { - "horizontal" => true, - "vertical" => false, - other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), - }; - } - if let Some(m) = a.opt_str("motion")? { - t.inward = match m { - "inward" => true, - "outward" => false, - other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), - }; - } - match a.get("direction") { - None => {} - Some(Value::String(s)) if s == "none" => t.direction = None, - Some(v) => { - t.direction = Some( - v.as_u64() - .and_then(|d| u16::try_from(d).ok()) - .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, - ); - } - } - if let Some(s) = a.opt_num("scale")? { - t.scale = s as f32; - } - if let Some(b) = a.opt_bool("opaque")? { - t.opaque = b; - } - Some(t) - } - }; - let display = a.opt_num("display")?.map(|s| s as f32); - self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) - } - - fn page_set_box(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{BoxSpec, PageBox}; - let doc = self.doc(a)?; - let n = doc.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let which = match a.opt_str("box")? { - None => PageBox::Crop, - Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, - }; - let nums = |key: &str| -> Result> { - a.get(key) - .map(|v| { - let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) - }) - .transpose() - }; - let spec = match (nums("margins")?, nums("rect")?) { - (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), - (Some(m), None) => BoxSpec::Margins(m), - (None, Some(r)) => { - // Top-left-origin points on the displayed page → user space (one page at a time). - if pages.len() != 1 { - return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); - } - let p = &doc.info.pages[pages[0]]; - let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); - BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - (None, None) => BoxSpec::Remove, - }; - let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; - out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); - Ok(out) - } - - fn doc_protect(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; - let d = Protection::default(); - // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is - // enforced against the owner password; without one everything stays allowed). Refuse a - // restriction that could not take effect instead of writing an unrestricted file (#134). - let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); - if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { - return Err(ToolError::InvalidArgs(format!( - "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" - ))); - } - let p = Protection { - open_password: a.opt_str("open_password")?.map(str::to_owned), - permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), - printing: match a.opt_str("printing")? { - None => d.printing, - Some("none") => Printing::None, - Some("low") => Printing::Low, - Some("high") => Printing::High, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), - }, - changes: match a.opt_str("changes")? { - None => d.changes, - Some("none") => Changes::None, - Some("pages") => Changes::Pages, - Some("fill-sign") => Changes::FillSign, - Some("comment-fill-sign") => Changes::CommentFillSign, - Some("any-except-extract") => Changes::AnyExceptExtract, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), - }, - copy: a.opt_bool("copy")?.unwrap_or(d.copy), - accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), - algorithm: match a.opt_str("compatibility")? { - None | Some("aes-256") => Algorithm::Aes256, - Some("aes-128") => Algorithm::Aes128, - Some("rc4-128") => Algorithm::Rc4_128, - Some("rc4-40") => Algorithm::Rc4_40, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), - }, - encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), - }; - let mut out = self.apply(a, Edit::Protect(p))?; - out["security"] = security(self.doc(a)?); - Ok(out) - } - - fn insert_blank(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let at = self.position(a, "at")?; - // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. - let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); - let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); - let width = a.opt_num("width")?.unwrap_or(w); - let height = a.opt_num("height")?.unwrap_or(h); - if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { - return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); - } - self.apply(a, Edit::InsertBlankPage { at, width, height }) - } - - fn insert_file(&mut self, a: &Args) -> Result { - let at = self.position(a, "at")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) - } - - /// Write freshly created bytes to `out` and/or open them as a new document. - fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { - let mut result = json!({ "bytes": bytes.len() }); - let out = a.opt_str("out")?; - if let Some(out) = out { - let path = self.resolve(out, true)?; - write_atomic(&path, &bytes)?; - result["path"] = json!(path.to_string_lossy()); - } - if a.opt_bool("open")?.unwrap_or(out.is_none()) { - let id = self.session.open_new(name, bytes).map_err(failed)?; - result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); - } - Ok(result) - } - - fn doc_create_multiple(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.is_empty() { - return Err(ToolError::InvalidArgs("paths must list at least one file".into())); - } - let separate = match a.opt_str("mode")? { - None | Some("combine") => false, - Some("separate") => true, - Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), - }; - if separate { - return self.create_separate(a, &paths); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - // Each file is read, converted and copied in turn, so a long list holds one file at a time. - let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; - let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - Ok((title, pdf, range)) - }); - let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; - self.deliver(a, "Combined", bytes) - } - - /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. - fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut out = Vec::new(); - for &p in paths { - let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; - let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; - Ok((kind, stem, pdf)) - }); - out.push(match converted { - Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), - Ok((_, stem, pdf)) => { - let target = unused(&dir, &stem); - write_atomic(&target, &pdf)?; - json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - fn page_extract(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let (id, name) = (doc.id, format!("{} (extract)", doc.name)); - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); - let pages = self.pages(a, "pages")?; - let mut out = if a.opt_bool("separate")?.unwrap_or(false) { - // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) - } else { - let bytes = self.session.extract(id, &pages).map_err(failed)?; - self.deliver(a, &name, bytes)? - }; - if a.opt_bool("delete")?.unwrap_or(false) { - let deleted = self.apply(a, Edit::DeletePages { pages })?; - out["original"] = deleted; - } - Ok(out) - } - - fn doc_combine(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.len() < 2 { - return Err(ToolError::InvalidArgs("combine needs at least two files".into())); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v - .iter() - .enumerate() - .map(|(index, value)| match value { - Value::Null => Ok(None), - Value::String(range) => Ok(Some(range.clone())), - _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), - }) - .collect::>()?, - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - let passwords: Vec> = match a.get("passwords") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { - v.iter().map(|x| x.as_str().map(str::to_owned)).collect() - } - Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), - }; - // Entries sharing a group number are one file split into parts placed apart. - let groups: Option>> = match a.get("groups") { - None | Some(Value::Null) => None, - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { - Some(v.iter().map(Value::as_u64).collect()) - } - Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), - }; - let field_conflicts = match a.opt_str("field_conflicts")? { - None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, - Some("merge") => pdfcraft_organize::FieldConflicts::Merge, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); - } - }; - let mut sources: Vec = Vec::new(); - let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); - for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { - let path = self.resolve(p, false)?; - let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); - let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); - let bytes = match first { - Some((g, first_path, at)) => { - if *first_path != path { - return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); - } - sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? - } - None => { - if let Some(g) = group { - firsts.push((g, path.clone(), sources.len())); - } - let bytes = Arc::new(read_input(&path)?); - // Images and text files are made into PDFs first (#569), as in the Combine files tab. - let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.session.as_pdf(&file_name, &bytes).map_err(failed)? - } - }; - let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - sources.push((name, bytes, range)); - } - let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); - let combined = match groups { - None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), - Some(groups) => { - // A path without a group is a file of its own: give it a key no group uses. - let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); - let mut keys = Vec::with_capacity(groups.len()); - for g in &groups { - keys.push(match g { - Some(g) => *g, - None => { - let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; - next = own.checked_add(1); - own - } - }); - } - self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) - } - } - .map_err(failed)?; - let mut result = self.deliver(a, "Combined", combined.bytes)?; - if !combined.renamed_fields.is_empty() { - result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); - } - Ok(result) - } - - fn doc_split(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); - let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); - let max_mb = a.opt_num("max_mb")?; - let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); - if chosen != 1 { - return Err(ToolError::InvalidArgs( - "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), - )); - } - let mut titles: Vec<(usize, String)> = Vec::new(); - let parts = if let Some(mb) = max_mb { - if !mb.is_finite() || mb <= 0.0 { - return Err(ToolError::InvalidArgs("max_mb must be positive".into())); - } - self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? - } else { - let by = match (a.opt_int("every")?, a.opt_ints("before")?) { - (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), - (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), - _ if bookmarks => { - titles = self.session.bookmark_splits(id); - if titles.is_empty() { - return Err(failed("the document has no top-level bookmarks")); - } - pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) - } - _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), - }; - self.session.split(id, &by).map_err(failed)? - }; - let dir = self.resolve(a.str("out_dir")?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - let mut used = std::collections::HashSet::new(); - for (i, (first, last, bytes)) in parts.iter().enumerate() { - let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}", safe(t)), - None => format!("{stem}-part{}", i + 1), - }; - // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. - if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { - base.truncate(end); - } - let file = format!("{base}.pdf"); - // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file; - let mut n = 1; - while !used.insert(unique.to_lowercase()) { - n += 1; - unique = format!("{base}-{n}.pdf"); - } - let path = child(&dir, &unique); - write_atomic(&path, bytes)?; - files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); - } - Ok(json!({ "files": files })) - } - - // ---- pages and text ---------------------------------------------------------------------- - - /// 1-based page list → validated 0-based indices. - fn pages(&self, a: &Args, key: &str) -> Result> { - let n = self.doc(a)?.info.pages.len(); - let pages = one_based(&a.ints(key)?)?; - if pages.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); - } - if let Some(p) = pages.iter().find(|p| **p >= n) { - return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); - } - Ok(pages) - } - - /// 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 { - 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> { - let Some(at) = a.opt_int(key)? else { return Ok(None) }; - let n = self.doc(a)?.info.pages.len(); - 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(Some(at as usize - 1)) - } - - fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - // The document's own view settings: layer visibility, hidden comments, reading colours - // (tools that change one drop this cache so the next render reads it again). - let config = - self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); - let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); - let entry = self.renderers.entry(id).or_insert_with(fresh); - if !Arc::ptr_eq(&entry.0, &doc.display) { - *entry = fresh(); - } - Ok(&mut entry.1) - } - - fn page_render(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let page = self.page(a)?; - let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); - if !(1.0..=MAX_DPI).contains(&dpi) { - return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); - } - let scale = (dpi / 72.0) as f32; - let block = a.opt_int("hide_text_block")?; - let added = a.opt_int("hide_added_text")?; - if block.is_some() && added.is_some() { - return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); - } - let out = if let Some(block) = block { - if block < 1 { - return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); - } - // The number names the paragraph text_paragraphs listed, as it does for text_edit. - let doc = self.doc(a)?; - let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; - doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else if let Some(added) = added { - let index = usize::try_from(added) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; - self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else { - self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) - }; - if let Some(e) = out.error { - return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); - } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) - } - - /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels - /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: - /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted - /// work on every scroll. It uses the same renderer, and the same per-document cache, as - /// `page_render`. - pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { - if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { - return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); - } - let id = DocId(doc); - let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); - if page < 1 || page > n { - return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); - } - let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); - if let Some(e) = out.error { - return Err(failed(format!("page {page} could not be rendered: {e}"))); - } - Ok((out.width, out.height, out.rgba)) - } - - fn page(&self, a: &Args) -> Result { - let n = self.doc(a)?.info.pages.len(); - let p = a.int("page")?; - if p < 1 || p as usize > n { - return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); - } - Ok(p as usize - 1) - } - - /// The text of `pages` (0-based), from the cache or extracted in parallel. - fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { - self.page_text_results(id, pages, &mut extract_parallel)? - .into_iter() - .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) - .collect() - } - - /// Keep per-page failures until the caller chooses strict or partial-result semantics. - fn page_text_results( - &mut self, - id: DocId, - pages: &[usize], - extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, - ) -> Result { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); - let password: Option> = doc.password.as_deref().map(Arc::from); - self.texts.prepare(id, &bytes); - let mut results = HashMap::new(); - let mut missing = Vec::new(); - for &p in pages { - if p >= n { - results.insert(p, Err("page is out of range".to_owned())); - } else if let Some(text) = self.texts.get(id, p) { - results.insert(p, Ok(text)); - } else { - missing.push(p); - } - } - missing.sort_unstable(); - missing.dedup(); - if !missing.is_empty() { - for (p, text) in extract(&bytes, password, &missing) { - if let Ok(text) = &text { - self.texts.insert(id, p, &bytes, text.clone()); - } - results.insert(p, text); - } - } - // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A - // worker that fails to return a page is an explicit failure, never an empty success. - Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) - } - - fn text_word_count(&self, a: &Args) -> Result { - use pdfcraft_engine::word_count::{self, Counts, Options}; - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let options = Options { - exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), - exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), - exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), - }; - let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; - let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); - Ok(json!({ - "total": counts(report.total), - "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), - "pages_without_text": report.pages_without_text, - "unclassified_structure": report.unclassified_structure, - })) - } - - fn text_extract(&mut self, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let id = doc.id; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - // Column select (#740): only the text inside the rectangle, row by row. - let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); - let texts = self.page_texts(id, &pages)?; - let text = |t: &PageText| match rect { - Some(r) => t.column_text(&t.glyphs_in(r)), - None => t.plain_text(), - }; - let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); - Ok(json!({ "pages": out })) - } - - /// Matches in document order, extracting page text in growing batches and stopping as soon as - /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document - /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported - /// as bounded warnings; `complete` requires both no continuation and no failed pages. - fn text_find(&mut self, a: &Args) -> Result { - self.text_find_with(a, &mut extract_parallel) - } - - fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { - let doc = self.doc(a)?; - let (id, n) = (doc.id, doc.info.pages.len()); - let query = a.str("query")?; - let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); - let start = match a.opt_int("start_page")? { - None => 0, - Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), - Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), - }; - let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); - let mut matches = Vec::new(); - let mut next = None; - let mut warnings = Vec::new(); - let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); - 'search: while first < n { - let end = first.saturating_add(batch).min(n); - let pages: Vec = (first..end).collect(); - let texts = self.page_text_results(id, &pages, extract)?; - for (p, text) in texts { - let text = match text { - Ok(text) => text, - Err(mut error) => { - warning_count += 1; - if warnings.len() < FIND_MAX_WARNINGS { - let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); - let truncated = end < error.len(); - error.truncate(end); - warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); - } - continue; - } - }; - let skip_here = if p == start { skip } else { 0 }; - // One extra match proves that the caller needs a continuation. Include skipped - // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); - for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { - if matches.len() == limit { - next = Some(json!({ "start_page": p + 1, "skip": k })); - break 'search; - } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); - } - } - first = end; - batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); - } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, - "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); - } - Ok(out) - } - - fn command_list(&self, a: &Args) -> Result { - let active = match a.opt_int("doc")? { - Some(_) => Some(self.doc(a)?.id), - None => None, - }; - let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); - let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); - let list: Vec = commands::COMMANDS - .iter() - .map(|c| { - json!({ - "id": c.id, - "label": commands::current_label(c, &self.session, active), - "menu": c.menu, - "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), - "enabled": commands::is_enabled(c, &self.session, active), - "tool": tools::tool_for_command(c.id), - "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), - }) - }) - .collect(); - let list: Vec = list - .into_iter() - .filter(|c| { - (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) - && (filter.is_empty() - || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) - }) - .collect(); - Ok(json!({ "commands": list })) - } - - // ---- paths ------------------------------------------------------------------------------- - - /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that - /// does not exist yet (its nearest existing ancestor must be inside the root). - /// - /// Every path outside the root gets the same refusal, so a confined client can't learn what - /// exists out there (#136): `..` is resolved by name first, another network share or device - /// namespace is refused without touching it, and the deepest existing ancestor (links - /// followed) must be inside the root before anything about the rest is reported. - fn resolve(&self, path: &str, for_write: bool) -> Result { - let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; - self.resolve_for(path, access, for_write) - } - - /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that - /// is only read may still be missing (a path checked up front, whose absence is reported per - /// file later). The automation roots, if any, get the last word. - fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { - let target = self.resolve_in_root(path, may_be_missing)?; - self.check_roots(path, &target, access)?; - Ok(target) - } - - /// Hold `target`, which `path` named, to the automation read or write root. Always passes - /// without roots. - fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { - match &self.roots { - Some(roots) => roots.check_path(path, target, access).map_err(failed), - None => Ok(()), - } - } - - fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { - let p = Path::new(path); - let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; - let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); - let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { - return Err(outside()); - } - let mut existing = joined.as_path(); - let mut rest = Vec::new(); - while !existing.exists() { - if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { - return Err(outside()); // a broken link: going on would tell whether its target exists - } - match (existing.file_name(), existing.parent()) { - (Some(name), Some(parent)) => { - rest.push(name); - existing = parent; - } - _ => return Err(outside()), // not even a drive or share that exists - } - } - let mut real = existing.canonicalize().map_err(|_| outside())?; - if !real.starts_with(root) { - return Err(outside()); - } - if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say where it was looked for, so a path - // written for another working directory can be corrected (#1890), and otherwise - // why, as the system reports it. `joined` is under the root, so naming it reveals - // nothing outside. - real = joined.canonicalize().map_err(|e| { - let (looked, root) = (joined.display(), root.display()); - match e.kind() { - std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), - _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), - } - })?; - if !real.starts_with(root) { - return Err(outside()); // it appeared, as a link out, since the check above - } - return Ok(real); - } - real.extend(rest.iter().rev()); - Ok(real) - } -} - -/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does -/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb -/// above the start of the path. -fn lexical(path: &Path) -> Option { - use std::path::Component; - let mut parts: Vec = Vec::new(); - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => match parts.last() { - Some(Component::Normal(_)) => { - parts.pop(); - } - _ => return None, - }, - other => parts.push(other), - } - } - Some(parts.iter().collect()) -} - -// ---- JSON helpers ---------------------------------------------------------------------------- - -fn parse_style_ranges(value: Option<&Value>) -> Result> { - let Some(value) = value else { return Ok(Vec::new()) }; - let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; - values - .iter() - .map(|v| { - let a = Args(v); - let bounds = a - .get("range") - .and_then(Value::as_array) - .filter(|v| v.len() == 2) - .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; - let start = bounds - .first() - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let end = bounds - .get(1) - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let family = a - .opt_str("font")? - .map(|f| match f { - "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), - "times" => Ok(pdfcraft_engine::FontFamily::Times), - "courier" => Ok(pdfcraft_engine::FontFamily::Courier), - _ => Err(Args::wrong("font", "helvetica, times or courier")), - }) - .transpose()? - .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) - .transpose()?; - Ok(pdfcraft_engine::RangeStyle { - range: start..end, - family, - resource: a.opt_str("font_resource")?.map(str::to_owned), - size: a.opt_num("size")?, - color: a.opt_str("color")?.map(comments::parse_color).transpose()?, - underline: a.opt_bool("underline")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - }) - }) - .collect() -} - -/// Typed access to validated arguments. -struct Args<'a>(&'a Value); - -impl Args<'_> { - fn get(&self, key: &str) -> Option<&Value> { - self.0.get(key).filter(|v| !v.is_null()) - } - fn missing(key: &str) -> ToolError { - ToolError::InvalidArgs(format!("missing argument {key}")) - } - fn wrong(key: &str, what: &str) -> ToolError { - ToolError::InvalidArgs(format!("{key} must be {what}")) - } - fn str(&self, key: &str) -> Result<&str> { - self.opt_str(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_str(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() - } - fn int(&self, key: &str) -> Result { - self.opt_int(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_int(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() - } - fn opt_num(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() - } - fn opt_bool(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() - } - fn ints(&self, key: &str) -> Result> { - self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_ints(&self, key: &str) -> Result>> { - let Some(v) = self.get(key) else { return Ok(None) }; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; - arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) - } - /// A 1-based bookmark path → 0-based indices. - fn path(&self, key: &str) -> Result> { - let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; - if p.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); - } - Ok(p) - } - fn opt_path(&self, key: &str) -> Result>> { - let Some(v) = self.opt_ints(key)? else { return Ok(None) }; - v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) - } - /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error - /// when it is not a number or lies absurdly far off the page. - fn bookmark_coordinate(&self, key: &str) -> Result> { - match self.opt_num(key)? { - None => Ok(None), - Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), - Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), - } - } - fn strs(&self, key: &str) -> Result> { - let v = self.get(key).ok_or_else(|| Self::missing(key))?; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; - arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() - } -} - -/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. -fn document_attachment_keys(doc: &Document) -> Vec { - doc.info - .attachments - .iter() - .filter_map(|x| match &x.source { - pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), - pdfcraft_render::AttachmentSource::Annotation { .. } => None, - }) - .collect() -} - -/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the -/// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { - // The open rows, outermost first, with their depth and the children gathered so far. - let mut open: Vec<(usize, Value, Vec)> = Vec::new(); - let mut roots = Vec::new(); - for row in rows { - let depth = row.path.len().saturating_sub(1); - while open.last().is_some_and(|(d, _, _)| *d >= depth) { - close_row(&mut open, &mut roots); - } - let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); - // Only what differs from the defaults (black, regular), as the PDF itself stores it. - if let Some(color) = row.color { - fields["color"] = json!(color); - } - if row.bold { - fields["bold"] = json!(true); - } - if row.italic { - fields["italic"] = json!(true); - } - if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { - fields["destination"] = destination; - } - open.push((depth, fields, Vec::new())); - } - while !open.is_empty() { - close_row(&mut open, &mut roots); - } - roots -} - -/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page -/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its -/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the -/// reader's position and zoom). `None` for a destination without a type. -pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { - use pdfcraft_render::DestView; - let points = |left: Option, top: Option| { - let [x, y] = page.dest_fraction(left, top, 0); - let round = |v: f32| f64::from((v * 100.0).round() / 100.0); - (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) - }; - Some(match view { - DestView::Top => return None, - DestView::Xyz { left, top, zoom } => { - let (x, y) = points(*left, *top); - json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) - } - DestView::Fit => json!({ "fit": "Fit" }), - DestView::FitH { top } => { - let (x, y) = points(None, *top); - json!({ "fit": "FitH", "left": x, "top": y }) - } - DestView::FitV { left } => { - let (x, y) = points(*left, None); - json!({ "fit": "FitV", "left": x, "top": y }) - } - DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), - }) -} - -/// Closes the innermost open row: it joins the row above it as a child, or the top level. -fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { - if let Some((_, mut fields, children)) = open.pop() { - fields["children"] = Value::Array(children); - match open.last_mut() { - Some((_, _, siblings)) => siblings.push(fields), - None => roots.push(fields), - } - } -} - -fn one_based(pages: &[i64]) -> Result> { - pages - .iter() - .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) - .collect() -} - -/// The document's security as the next save writes it (never includes passwords). -fn security(d: &Document) -> Value { - match d.security_summary() { - None => json!({ "protected": false }), - Some(s) => { - let p = s.permissions; - json!({ - "protected": true, - "method": s.method, - "pending": s.pending, - "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), - "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), - }) - } - } -} - -/// What every tool that changes a document returns. -fn summary(d: &Document) -> Value { - json!({ - "doc": d.id.0, - "name": d.name, - "path": d.path, - "pages": d.info.pages.len(), - "dirty": d.dirty, - "editable": d.read_only_reason.is_none(), - "read_only_reason": d.read_only_reason, - "encrypted": d.info.encrypted, - "undo": d.can_undo(), - "redo": d.can_redo(), - }) -} - -fn info(d: &Document) -> Value { - let i = &d.info; - let page1 = |p: usize| p + 1; - let mut security = security(d); - if let Some(s) = d.security_summary() { - security["opened_as_owner"] = json!(s.owner); - } - json!({ - "document": summary(d), - "pdf_version": i.pdf_version, - "file_size": i.file_size, - "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, - // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the - // ones the spec names, set and cleared with doc_set_info. - "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, - "tagged": i.tagged, - "has_javascript": i.has_javascript, - // Where the document opens (/OpenAction, named destinations resolved): 1-based page and - // destination, null without one. - "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), - // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" - // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). - "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), - // A script of the form ran too long and was abandoned: its scripts no longer run in this - // document (calculations, buttons) until it is opened again. - "xfa_scripts_off": d.xfa_scripts_off(), - // What was rewritten from, or could not be written to, the XFA data. - "xfa_warnings": d.xfa_warnings, - "security": security, - "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ - "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, - })).collect::>(), - "outline": outline(&i.outline), - "outline_more": i.outline_more, - // Rectangles use the tools' convention (top-left of the displayed page, like - // comment_list and link_list), not raw PDF user space, so they can be fed back to - // geometry-taking tools (#129). - "annotations": i.annotations.iter().map(|a| json!({ - "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, - "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), - })).collect::>(), - "fields": i.fields.iter().map(|f| json!({ - "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), - "tooltip": f.tooltip, "has_actions": f.has_actions, - })).collect::>(), - "links": i.links.iter().map(|l| json!({ - "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, - })).collect::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), - // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with - // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. - "transitions": i.transitions.iter().map(|t| json!({ - "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, - "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, - "display": t.display, - })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), - // `substitute` names the face standing in for a program the document does not embed, and - // `substitution` says why. Both are null for an embedded font: nothing stands in. A null - // `substitute` with a non-null `substitution` means no face was available at all, so - // nothing is drawn for that font -- which is the answer to "why is my text missing?". - "fonts": i.fonts.iter().map(|f| json!({ - "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, - "substitute": f.substitute, "substitution": f.substitution, - })).collect::>(), - "warnings": i.warnings, - "repairs": d.repair_log(), - }) -} - -/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the -/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports -/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). -/// Public so `pdfcraft-cli` reports the same rectangles as the tools. -pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { - i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) -} - -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) -} - -/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). -fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) -} - -/// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { - let extract = |r: &mut PageRenderer, p: usize| { - let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - match out.error { - Some(e) => Err(e), - None => Ok(out.text.unwrap_or_default()), - } - }; - let config = RenderConfig { password, ..Default::default() }; - let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; - // Small jobs aren't worth a second parse of the document. - if workers == 1 || pages.len() < 8 { - let mut r = PageRenderer::new(bytes.clone(), config); - return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); - } - let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: PageTexts = std::thread::scope(|s| { - let handles: Vec<_> = (0..workers) - .map(|_| { - s.spawn(|| { - let mut r = PageRenderer::new(bytes.clone(), config.clone()); - let mut done = Vec::new(); - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(p) = pages.get(i) else { break }; - done.push((*p, extract(&mut r, *p))); - } - done - }) - }) - .collect(); - handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() - }); - out.sort_by_key(|(p, _)| *p); - out -} - -/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, -/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots -/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. -fn child(dir: &Path, name: &str) -> PathBuf { - let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); - if safe.chars().all(|c| c == '.') { - safe.insert(0, '_'); - } - dir.join(safe) -} - -/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. -fn unused(dir: &Path, stem: &str) -> PathBuf { - let first = child(dir, &format!("{stem}.pdf")); - if !first.exists() { - return first; - } - (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). -pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; - -/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. -pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { - use std::io::Read as _; - let file = std::fs::File::open(path)?; - if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - let mut bytes = Vec::new(); - file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_INPUT { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - Ok(bytes) -} - -pub(crate) fn read_input(path: &Path) -> Result> { - read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) -} - -/// Write and sync a temporary file in the same directory, then rename over the target. -/// This flushes staged contents; it does not promise parent-directory durability after a -/// power loss. Filesystems that refuse sync cause the save to fail before publication. -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - write_atomic_with(path, bytes, staging_suffixes()) -} - -/// [`write_atomic`], trying the staging names that `suffixes` give. -fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { - write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) -} - -fn write_atomic_synced( - path: &Path, - bytes: &[u8], - suffixes: impl IntoIterator, - sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, -) -> Result<()> { - // A folder can't be replaced, and staging beside it could land outside the root: "." names - // the root itself, whose parent isn't ours to write in. - if path.is_dir() { - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; - std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // The writer itself is the platform crate's, shared with the desktop app and the CLI - // (#2166): the staging file is written, flushed (delayed writeback failures surface before - // the destination is replaced or a document marked clean), closed and renamed; a failure at - // any step removes it and leaves the destination as it was. - pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) - .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs new file mode 100644 index 0000000000..d9ea9e5c4f --- /dev/null +++ b/crates/automation/src/pages.rs @@ -0,0 +1,363 @@ +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based}; +use super::*; + +/// Default and maximum resolution for `page_render`. +pub(crate) const DEFAULT_DPI: f64 = 96.0; + +pub(crate) const MAX_DPI: f64 = 600.0; + +/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a +/// match near the start returns without touching the rest of a long document (#971). +pub(crate) const FIND_FIRST_BATCH: usize = 8; + +const FIND_MAX_BATCH: usize = 128; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; + +pub(crate) const FIND_MAX_WARNINGS: usize = 100; + +pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; + +impl Automation { + // ---- pages and text ---------------------------------------------------------------------- + + /// 1-based page list → validated 0-based indices. + pub(crate) fn pages(&self, a: &Args, key: &str) -> Result> { + let n = self.doc(a)?.info.pages.len(); + let pages = one_based(&a.ints(key)?)?; + if pages.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); + } + if let Some(p) = pages.iter().find(|p| **p >= n) { + return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); + } + Ok(pages) + } + + /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. + pub(crate) fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + pub(crate) fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; + let n = self.doc(a)?.info.pages.len(); + 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(Some(at as usize - 1)) + } + + pub(crate) fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); + let entry = self.renderers.entry(id).or_insert_with(fresh); + if !Arc::ptr_eq(&entry.0, &doc.display) { + *entry = fresh(); + } + Ok(&mut entry.1) + } + + pub(crate) fn page_render(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let page = self.page(a)?; + let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); + if !(1.0..=MAX_DPI).contains(&dpi) { + return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); + } + let scale = (dpi / 72.0) as f32; + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { + if block < 1 { + return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); + } + // The number names the paragraph text_paragraphs listed, as it does for text_edit. + let doc = self.doc(a)?; + let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; + doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else { + self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) + }; + if let Some(e) = out.error { + return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); + } + let data = encode_png(out.width, out.height, &out.rgba)?; + Ok(Content::Png { data, width: out.width, height: out.height }) + } + + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + + pub(crate) fn page(&self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let p = a.int("page")?; + if p < 1 || p as usize > n { + return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); + } + Ok(p as usize - 1) + } + + /// The text of `pages` (0-based), from the cache or extracted in parallel. + pub(crate) fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + pub(crate) fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); + let password: Option> = doc.password.as_deref().map(Arc::from); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); + } + } + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) + } + + pub(crate) fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + + pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let id = doc.id; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + // Column select (#740): only the text inside the rectangle, row by row. + let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); + let texts = self.page_texts(id, &pages)?; + let text = |t: &PageText| match rect { + Some(r) => t.column_text(&t.glyphs_in(r)), + None => t.plain_text(), + }; + let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); + Ok(json!({ "pages": out })) + } + + /// Matches in document order, extracting page text in growing batches and stopping as soon as + /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. + pub(crate) fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + pub(crate) fn text_find_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let query = a.str("query")?; + let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); + let start = match a.opt_int("start_page")? { + None => 0, + Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), + Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), + }; + let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let mut matches = Vec::new(); + let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; + let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + 'search: while first < n { + let end = first.saturating_add(batch).min(n); + let pages: Vec = (first..end).collect(); + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; + // One extra match proves that the caller needs a continuation. Include skipped + // matches on the starting page when bounding the temporary per-page result. + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { + if matches.len() == limit { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + } + } + first = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); + if let (Some(next), Some(o)) = (next, out.as_object_mut()) { + o.insert("next".into(), next); + } + Ok(out) + } + + pub(crate) fn command_list(&self, a: &Args) -> Result { + let active = match a.opt_int("doc")? { + Some(_) => Some(self.doc(a)?.id), + None => None, + }; + let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); + let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); + let list: Vec = commands::COMMANDS + .iter() + .map(|c| { + json!({ + "id": c.id, + "label": commands::current_label(c, &self.session, active), + "menu": c.menu, + "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), + "enabled": commands::is_enabled(c, &self.session, active), + "tool": tools::tool_for_command(c.id), + "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), + }) + }) + .collect(); + let list: Vec = list + .into_iter() + .filter(|c| { + (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) + && (filter.is_empty() + || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) + }) + .collect(); + Ok(json!({ "commands": list })) + } +} + +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). +pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) +} + +/// Extract the text of `pages` (0-based) with one renderer per worker thread. +pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { + let extract = |r: &mut PageRenderer, p: usize| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + match out.error { + Some(e) => Err(e), + None => Ok(out.text.unwrap_or_default()), + } + }; + let config = RenderConfig { password, ..Default::default() }; + let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; + // Small jobs aren't worth a second parse of the document. + if workers == 1 || pages.len() < 8 { + let mut r = PageRenderer::new(bytes.clone(), config); + return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); + } + let next = std::sync::atomic::AtomicUsize::new(0); + let mut out: PageTexts = std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + s.spawn(|| { + let mut r = PageRenderer::new(bytes.clone(), config.clone()); + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(p) = pages.get(i) else { break }; + done.push((*p, extract(&mut r, *p))); + } + done + }) + }) + .collect(); + handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() + }); + out.sort_by_key(|(p, _)| *p); + out +} diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs new file mode 100644 index 0000000000..fb514df69f --- /dev/null +++ b/crates/automation/src/paths.rs @@ -0,0 +1,180 @@ +use super::error::{Result, failed}; +use super::*; + +impl Automation { + // ---- paths ------------------------------------------------------------------------------- + + /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that + /// does not exist yet (its nearest existing ancestor must be inside the root). + /// + /// Every path outside the root gets the same refusal, so a confined client can't learn what + /// exists out there (#136): `..` is resolved by name first, another network share or device + /// namespace is refused without touching it, and the deepest existing ancestor (links + /// followed) must be inside the root before anything about the rest is reported. + pub(crate) fn resolve(&self, path: &str, for_write: bool) -> Result { + let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; + self.resolve_for(path, access, for_write) + } + + /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that + /// is only read may still be missing (a path checked up front, whose absence is reported per + /// file later). The automation roots, if any, get the last word. + pub(crate) fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { + let target = self.resolve_in_root(path, may_be_missing)?; + self.check_roots(path, &target, access)?; + Ok(target) + } + + /// Hold `target`, which `path` named, to the automation read or write root. Always passes + /// without roots. + pub(crate) fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { + match &self.roots { + Some(roots) => roots.check_path(path, target, access).map_err(failed), + None => Ok(()), + } + } + + fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { + let p = Path::new(path); + let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; + let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); + let joined = lexical(&root.join(p)).ok_or_else(outside)?; + if foreign_share(&joined, root) { + return Err(outside()); + } + let mut existing = joined.as_path(); + let mut rest = Vec::new(); + while !existing.exists() { + if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { + return Err(outside()); // a broken link: going on would tell whether its target exists + } + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + rest.push(name); + existing = parent; + } + _ => return Err(outside()), // not even a drive or share that exists + } + } + let mut real = existing.canonicalize().map_err(|_| outside())?; + if !real.starts_with(root) { + return Err(outside()); + } + if !rest.is_empty() && !for_write { + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; + if !real.starts_with(root) { + return Err(outside()); // it appeared, as a link out, since the check above + } + return Ok(real); + } + real.extend(rest.iter().rev()); + Ok(real) + } +} + +/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does +/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb +/// above the start of the path. +pub(crate) fn lexical(path: &Path) -> Option { + use std::path::Component; + let mut parts: Vec = Vec::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => match parts.last() { + Some(Component::Normal(_)) => { + parts.pop(); + } + _ => return None, + }, + other => parts.push(other), + } + } + Some(parts.iter().collect()) +} + +/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, +/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots +/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. +pub(crate) fn child(dir: &Path, name: &str) -> PathBuf { + let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); + if safe.chars().all(|c| c == '.') { + safe.insert(0, '_'); + } + dir.join(safe) +} + +/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. +pub(crate) fn unused(dir: &Path, stem: &str) -> PathBuf { + let first = child(dir, &format!("{stem}.pdf")); + if !first.exists() { + return first; + } + (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + +/// Write and sync a temporary file in the same directory, then rename over the target. +/// This flushes staged contents; it does not promise parent-directory durability after a +/// power loss. Filesystems that refuse sync cause the save to fail before publication. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + write_atomic_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomic`], trying the staging names that `suffixes` give. +pub(crate) fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +pub(crate) fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> Result<()> { + // A folder can't be replaced, and staging beside it could land outside the root: "." names + // the root itself, whose parent isn't ours to write in. + if path.is_dir() { + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 4a5651b54d..fd4efa79da 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -207,8 +207,7 @@ fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { #[test] fn names_from_documents_become_one_plain_file_name() { let dir = std::env::temp_dir().join("out"); - for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] - { + for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] { let p = child(&dir, name); assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display()); assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display()); From a891dfebb2bcf0c6abf3b3c68122b7cd104d3116 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:13:58 +0000 Subject: [PATCH 065/175] refactor(automation): split a11y.rs into a11y/ New modules: a11y/ocr, a11y/documents, a11y/form_tools (JS and form-action tools). --- crates/automation/src/a11y.rs | 527 +---------------------- crates/automation/src/a11y/documents.rs | 219 ++++++++++ crates/automation/src/a11y/form_tools.rs | 163 +++++++ crates/automation/src/a11y/ocr.rs | 146 +++++++ 4 files changed, 532 insertions(+), 523 deletions(-) create mode 100644 crates/automation/src/a11y/documents.rs create mode 100644 crates/automation/src/a11y/form_tools.rs create mode 100644 crates/automation/src/a11y/ocr.rs diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 20243dcdae..1f0c9cb8a5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -8,6 +8,10 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +mod documents; +mod form_tools; +mod ocr; + fn status_id(s: Status) -> &'static str { match s { Status::Passed => "passed", @@ -574,511 +578,6 @@ impl Automation { } } -impl Automation { - /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused - /// unless the models read it) and `skip_text_pages`, each left at its default when absent. - pub(crate) fn ocr_settings(&self, a: &Args) -> Result { - let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); - if let Some(d) = a.opt_num("dpi")? { - settings.dpi = d.clamp(72.0, 600.0) as f32; - } - if let Some(l) = a.opt_str("language")? { - if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { - return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); - } - settings.language = l.into(); - } - if let Some(s) = a.opt_bool("skip_text_pages")? { - settings.skip_text_pages = s; - } - Ok(settings) - } - - /// Recognizes text in each of `paths` and writes the result under the same file name in - /// `folder`, reporting per file its output, word count and skipped pages, or its error. A - /// path outside the root refuses the whole call before any work starts. - pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let folder = self.resolve(a.str("folder")?, true)?; - // A path that leaves the root refuses the whole call before any work starts, like the - // other tools (the write check passes missing files, which stay per-file errors below). - let paths = a.strs("paths")?; - for p in &paths { - self.resolve_for(p, crate::file_access::Access::Read, true)?; - } - // Two inputs that would write the same output file overwrite each other while both report - // success, so the whole batch is refused here: before the folder is created, anything is - // read, or the OCR engines load. - let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); - let shared = shared_file_names(&targets); - if !shared.is_empty() { - return Err(ToolError::InvalidArgs(format!( - "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", - shared.join(", ") - ))); - } - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; - let mut out = Vec::new(); - for p in paths { - let name = input_file_name(p); - let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( - |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), - ); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - write_atomic(&target, &r.bytes)?; - let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); - json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - /// Recognizes text on the open document's `pages` (every page when absent) and adds it, - /// reporting the total word count and, per page, why it was skipped or what it found. - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let doc = self.doc(a)?; - // Recognizing needs only permission to change the document; the words it found are - // returned only when copying is allowed too. - let (id, copy) = (doc.id, doc.allows_copying()); - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; - let list: Vec = found - .iter() - .map(|p| match &p.skipped { - Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), - }) - .collect(); - Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) - } - - /// Whether text recognition's models are installed, the directories searched for them (in - /// order), and the languages it reads, each as a code and a name. - pub(crate) fn ocr_status(&self) -> Result { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); - Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) - } -} - -/// The file name an input is written under: its own file name, or `document.pdf` when it has none. -fn input_file_name(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) -} - -/// The output file names that two or more of `targets` share, each spelling listed once, in input -/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on -/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode -/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are -/// not caught. The folder is the same for every target, so only the names are compared. -fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { - let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); - for target in targets { - let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let key = if cfg!(windows) { - name.trim_end_matches(['.', ' ']).to_lowercase() - } else if cfg!(target_os = "macos") { - // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). - name.to_lowercase() - } else { - name.clone() - }; - let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); - *count += 1; - if !spellings.contains(&name) { - spellings.push(name); - } - } - groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() -} - -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. -fn request_json(r: &pdfcraft_engine::js::Request) -> Value { - use pdfcraft_engine::js::Request as R; - match r { - R::Reset(n) => json!({ "reset": n }), - R::Print => json!({ "print": true }), - R::GoToPage(p) => json!({ "page": p + 1 }), - R::LaunchUrl(u) => json!({ "url": u }), - R::Submit(u) => json!({ "submit": u }), - R::Focus(f) => json!({ "focus": f }), - R::Beep => json!({ "beep": true }), - R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": p.id() }), - } -} - -impl Automation { - pub(crate) fn js_run(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; - let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok( - json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), - ) - } - - pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { - let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); - Ok(json!({ "scripts": list })) - } - - pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) - } - - pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetFieldScript { - name: a.str("field")?.into(), - event: a.str("event")?.into(), - script: a.opt_str("script")?.map(str::to_string), - }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); - Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) - } - - pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { - let mut files = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); - } - let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; - let target = self.resolve(a.str("path")?, true)?; - write_atomic(&target, csv.as_bytes())?; - let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); - Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) - } - - pub(crate) fn doc_export_office(&self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, true)?; - let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let export = self.doc_to_copy(a)?.export_office_checked(format); - write_atomic(&path, &export.bytes)?; - // Text left out to keep a hostile page bounded is said, not silent (#3120). - let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); - let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), - "truncated": !truncated.is_empty(), "truncated_pages": truncated }); - if !export.truncated_pages.is_empty() { - use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; - out["warnings"] = json!([format!( - "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" - )]); - } - Ok(out) - } - - fn pdfa_level(&self, a: &Args) -> Result { - let l = a.opt_str("level")?.unwrap_or("2b"); - pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) - } - - pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = doc - .pdfa_verify(level) - .iter() - .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) - .collect(); - let d = doc.standards(); - Ok(json!({ - "level": level.label(), - "compliant": issues.is_empty(), - "issues": issues, - "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, - })) - } - - pub(crate) fn print_readiness(&self, a: &Args) -> Result { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = report - .pages - .iter() - .map(|p| { - json!({ - "page": p.page, - "media_mm": p.media_mm, - "crop_mm": p.crop_mm, - "trim_mm": p.trim_mm, - "bleed_mm": p.bleed_mm, - "trim_set": p.trim_set, - "bleed_set": p.bleed_set, - "bleed_margin_mm": p.bleed_margin_mm, - }) - }) - .collect(); - let images: Vec = report - .images - .iter() - .map(|image| { - json!({ - "page": image.page, - "name": image.name, - "ppi": image.ppi, - "pixels": image.pixels, - "size_mm": image.size_mm, - }) - }) - .collect(); - let pdfa: Vec = report - .pdfa_issues - .iter() - .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) - .collect(); - Ok(json!({ - "text": report.text(), - "findings": findings, - "pages": pages, - "images": images, - "output_intents": report.output_intents, - "fonts_not_embedded": report.fonts_not_embedded, - "pdfa_issues": pdfa, - "resolution_unknown": report.resolution_unknown, - })) - } - - pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) - } - - pub(crate) fn action_list(&self) -> Result { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); - Ok(json!({ "actions": actions, "steps": steps })) - } - - pub(crate) fn action_run(&mut self, a: &Args) -> Result { - use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; - let action = if let Some(name) = a.opt_str("action")? { - builtin() - .into_iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? - } else { - let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; - let steps = items - .iter() - .map(|it| { - let id = it["step"].as_str().unwrap_or(""); - Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) - }) - .collect::>>()?; - Action { name: "Custom".into(), description: String::new(), steps, builtin: false } - }; - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let mut out = Vec::new(); - for p in a.strs("paths")? { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = self - .resolve(p, false) - .map_err(|e| e.to_string()) - .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; - json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "action": action.name, "files": out })) - } - - fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { - let new = self.doc(a)?.id; - let other = a.int("other")?; - let old = u64::try_from(other) - .ok() - .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) - .map(|d| d.id) - .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; - Ok((old, new)) - } - - pub(crate) fn doc_compare(&self, a: &Args) -> Result { - use pdfcraft_engine::compare::Kind; - let (old, new) = self.compare_ids(a)?; - let c = self.session.compare(old, new).map_err(failed)?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); - let side = - |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); - let list: Vec = c - .changes - .iter() - .take(limit) - .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) - .collect(); - let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { - self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() - } else { - Vec::new() - }; - Ok(json!({ - "visual": visual, - "identical": c.identical(), - "replaced": c.count(Kind::Replaced), - "inserted": c.count(Kind::Inserted), - "deleted": c.count(Kind::Deleted), - "old_words": c.old_words, - "new_words": c.new_words, - "changes": list, - })) - } - - pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let bytes = self.session.compare_report(old, new).map_err(failed)?; - let path = self.resolve(a.str("path")?, true)?; - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) - } - - pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) - } - - pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.detect_fields(id, &pages); - let list: Vec = found - .iter() - .map(|(p, c)| { - let (kind, comb) = match c.kind { - pdfcraft_engine::detect::Kind::Text => ("text", None), - pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), - pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), - }; - let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); - if let Some(cells) = comb { - v["comb"] = json!(cells); - } - v - }) - .collect(); - if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; - } - Ok(json!({ "fields": list })) - } - - pub(crate) fn form_actions(&self, a: &Args) -> Result { - use pdfcraft_engine::FieldAction as A; - let list: Vec = self - .doc(a)? - .field_actions(a.str("field")?) - .iter() - .map(|(t, act)| { - let mut v = match act { - A::JavaScript(j) => json!({ "javascript": j }), - A::Uri(u) => json!({ "url": u }), - A::Reset(f) => json!({ "reset": f }), - A::Named(n) => json!({ "menu": n }), - A::GoTo(p) => json!({ "page": p + 1 }), - A::ShowHide { fields, hide: true } => json!({ "hide": fields }), - A::ShowHide { fields, hide: false } => json!({ "show": fields }), - A::Submit(u) => json!({ "submit": u }), - A::Other(s) => json!({ "other": s }), - }; - v["trigger"] = json!(t.id()); - v - }) - .collect(); - Ok(json!({ "field": a.str("field")?, "actions": list })) - } - - pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; - let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; - let bad = |m: String| ToolError::InvalidArgs(m); - let names = |v: &Value| -> Vec { - v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() - }; - let mut acts = Vec::new(); - for it in items { - let tid = it["trigger"].as_str().unwrap_or(""); - let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; - let act = if let Some(j) = it["javascript"].as_str() { - A::JavaScript(j.into()) - } else if let Some(u) = it["url"].as_str() { - A::Uri(u.into()) - } else if it.get("reset").is_some() { - A::Reset(names(&it["reset"])) - } else if let Some(m) = it["menu"].as_str() { - A::Named(m.into()) - } else if let Some(p) = it["page"].as_u64() { - A::GoTo((p.max(1) - 1) as usize) - } else if it.get("show").is_some() { - A::ShowHide { fields: names(&it["show"]), hide: false } - } else if it.get("hide").is_some() { - A::ShowHide { fields: names(&it["hide"]), hide: true } - } else if let Some(u) = it["submit"].as_str() { - A::Submit(u.into()) - } else { - return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); - }; - acts.push((t, act)); - } - let name = a.str("field")?.to_string(); - let edit = - pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) - } - - pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { - if let Some(on) = a.opt_bool("enabled")? { - self.session.set_javascript(on); - } - Ok(json!({ "enabled": self.session.javascript() })) - } -} - #[cfg(test)] mod review_html_tests { use super::*; @@ -1098,21 +597,3 @@ mod review_html_tests { assert!(!html.contains("