mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge main into feat/redacted-file-name
This commit is contained in:
23 files changed
+1256
-149
No files matched your search
@@ -7699,6 +7699,35 @@ fn retained_pdf_version_reports_the_header_raised_for_object_streams() {
|
||||
assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}");
|
||||
}
|
||||
|
||||
/// #3227: retaining the version, the later of the written header and the catalog `/Version` is
|
||||
/// reported: a catalog below the header cannot hide it, and equal versions are reported as is.
|
||||
#[test]
|
||||
fn retained_pdf_version_is_the_later_of_header_and_catalog() {
|
||||
let dir = workdir("optimize-retain-version-later");
|
||||
let mut a = auto(&dir);
|
||||
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" }));
|
||||
let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap();
|
||||
let root = cos.root().unwrap();
|
||||
for (label, header, catalog, saved, reported) in [
|
||||
("higher-header", "1.7", "1.4", "1.7", "1.7"),
|
||||
// PDF versions compare as major/minor numbers: 2.0 is newer than 1.7.
|
||||
// Retain keeps the header. The catalog /Version only sets the reported version.
|
||||
("higher-catalog", "1.7", "2.0", "1.7", "2.0"),
|
||||
("equal", "1.4", "1.4", "1.4", "1.4"),
|
||||
] {
|
||||
cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name(catalog))).unwrap();
|
||||
cos.set_version(header);
|
||||
let input = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
|
||||
std::fs::write(dir.join(format!("{label}-input.pdf")), input).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": format!("{label}-input.pdf") }))["doc"].as_u64().unwrap();
|
||||
let out = format!("{label}-output.pdf");
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": out, "compatibility": "retain", "object_streams": false }));
|
||||
assert!(std::fs::read(dir.join(&out)).unwrap().starts_with(format!("%PDF-{saved}\n").as_bytes()), "{label}: written header");
|
||||
assert_eq!(r["pdf_version"].as_str(), Some(reported), "{label}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for
|
||||
/// fewer than 250 bytes, before the code width grows.
|
||||
fn lzw_literals(data: &[u8]) -> Vec<u8> {
|
||||
|
||||
@@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun
|
||||
// No qpdf check here: qpdf counts every offset from the header in a file with bytes before
|
||||
// it, so it already reconstructs this input before anything is appended.
|
||||
}
|
||||
|
||||
/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2
|
||||
/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4),
|
||||
/// both through indirect objects that nothing else references.
|
||||
fn pdf2_fixture() -> Vec<u8> {
|
||||
Pdf::new()
|
||||
.version("2.0")
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>")
|
||||
.stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET")
|
||||
.obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>")
|
||||
.obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>")
|
||||
.obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>")
|
||||
.obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// What `pdf2_fixture` declares, read back through the catalog.
|
||||
fn assert_dpart_and_namespaces(doc: &Document) {
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let catalog = catalog.as_dict().unwrap();
|
||||
let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot"));
|
||||
let root = root.as_dict().unwrap();
|
||||
let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept"));
|
||||
let node = node.as_dict().unwrap();
|
||||
assert_eq!(node.name(b"Type"), Some(&b"DPart"[..]));
|
||||
// Both ends of the part are still the one page, found through /Pages (references are
|
||||
// renumbered by a full save, so they are compared inside this document).
|
||||
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
|
||||
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
|
||||
let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid");
|
||||
assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page");
|
||||
assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page");
|
||||
assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some());
|
||||
let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap());
|
||||
let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept"));
|
||||
let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]);
|
||||
let namespace = namespace.as_dict().unwrap();
|
||||
assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn");
|
||||
assert!(namespace.get(b"Schema").is_some(), "/Schema is retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() {
|
||||
let original = Arc::new(pdf2_fixture());
|
||||
let doc = Document::open(original.clone()).unwrap();
|
||||
assert_dpart_and_namespaces(&doc);
|
||||
|
||||
for object_streams in [true, false] {
|
||||
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
|
||||
let full = write_full(&doc, &opts).unwrap();
|
||||
let back = Document::open(Arc::new(full.clone())).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_dpart_and_namespaces(&back);
|
||||
// A second generation, rewritten from the rewritten file.
|
||||
let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap();
|
||||
assert!(second.repair_log().is_empty(), "{:?}", second.repair_log());
|
||||
assert_dpart_and_namespaces(&second);
|
||||
}
|
||||
|
||||
// An incremental save of an unrelated edit leaves the file's bytes, and so both, alone.
|
||||
let mut edited = Document::open(original.clone()).unwrap();
|
||||
edited.add(Object::Int(1));
|
||||
let updated = write_incremental(&edited, &SaveOptions::default()).unwrap();
|
||||
assert!(updated.starts_with(&original));
|
||||
let reopened = Document::open(Arc::new(updated)).unwrap();
|
||||
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
|
||||
assert_dpart_and_namespaces(&reopened);
|
||||
}
|
||||
@@ -81,29 +81,49 @@ fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Axis-aligned `[min_x, min_y, max_x, max_y]` bounding box of finite `points`.
|
||||
fn bounds(points: &[Point]) -> Option<[f64; 4]> {
|
||||
let &[first, ref rest @ ..] = points else { return None };
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.iter().fold([first[0], first[1], first[0], first[1]], |[x0, y0, x1, y1], &[x, y]| [x0.min(x), y0.min(y), x1.max(x), y1.max(y)]))
|
||||
}
|
||||
|
||||
/// The four corners in reading order for a y-down space (view or raster pixels): top-left,
|
||||
/// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they
|
||||
/// form a convex quadrilateral with a non-negligible area.
|
||||
pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> {
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
let [min_x, min_y, max_x, max_y] = bounds(&points)?;
|
||||
let span = max_x - min_x;
|
||||
let rise = max_y - min_y;
|
||||
let size = span.max(rise);
|
||||
if span <= 0.0 || rise <= 0.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cx = points.iter().map(|p| p[0]).sum::<f64>() / 4.0;
|
||||
let cy = points.iter().map(|p| p[1]).sum::<f64>() / 4.0;
|
||||
let norm = |p: Point| [(p[0] - min_x) / size, (p[1] - min_y) / size];
|
||||
let cx = points.iter().map(|p| norm(*p)[0]).sum::<f64>() / 4.0;
|
||||
let cy = points.iter().map(|p| norm(*p)[1]).sum::<f64>() / 4.0;
|
||||
let mut q = points;
|
||||
// Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left.
|
||||
q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx)));
|
||||
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?;
|
||||
q.sort_by(|a, b| {
|
||||
let (na, nb) = (norm(*a), norm(*b));
|
||||
(na[1] - cy).atan2(na[0] - cx).total_cmp(&(nb[1] - cy).atan2(nb[0] - cx))
|
||||
});
|
||||
// Score each candidate rotation by its normalised left-edge x and top-edge y.
|
||||
let score = |i: usize| {
|
||||
let (tl, tr, bl) = (q[i], q[(i + 1) % 4], q[(i + 3) % 4]);
|
||||
((tl[0] - min_x) + (bl[0] - min_x)) / span + ((tl[1] - min_y) + (tr[1] - min_y)) / rise
|
||||
};
|
||||
let first = (0..4).min_by(|&a, &b| score(a).total_cmp(&score(b)))?;
|
||||
q.rotate_left(first);
|
||||
// Convex, with every turn the same way, and not a sliver.
|
||||
let nq = q.map(norm);
|
||||
let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
let turns: Vec<f64> = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect();
|
||||
let turns: [f64; 4] = std::array::from_fn(|i| cross(nq[i], nq[(i + 1) % 4], nq[(i + 2) % 4]));
|
||||
let area = turns.iter().sum::<f64>() / 2.0;
|
||||
let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min);
|
||||
let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min);
|
||||
let size = span.max(rise);
|
||||
let convex = turns.iter().all(|&t| t > 1e-9 * size * size);
|
||||
(convex && area > 1e-6 * size * size).then_some(q)
|
||||
let convex = turns.iter().all(|&t| t > 1e-9);
|
||||
(convex && area > 1e-6).then_some(q)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -129,6 +149,10 @@ mod tests {
|
||||
let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]];
|
||||
let h = Homography::from_points(r, r).unwrap();
|
||||
assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0]));
|
||||
|
||||
let narrow = [[0.0, 0.0], [1e10, 0.0], [1e10, 1.0], [0.0, 1.0]];
|
||||
let h_narrow = Homography::from_points(narrow, narrow).unwrap();
|
||||
assert!(close(h_narrow.apply([5e9, 0.5]).unwrap(), [5e9, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -144,6 +168,8 @@ mod tests {
|
||||
fn collinear_or_non_finite_points_have_no_transform() {
|
||||
let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none());
|
||||
assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 1.0], [2.75, 2.75], [3.0, 3.0]]).is_none());
|
||||
assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 1.0]]).is_none());
|
||||
assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none());
|
||||
assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
@@ -155,7 +181,7 @@ mod tests {
|
||||
let tr = [200.0, 5.0];
|
||||
let br = [210.0, 300.0];
|
||||
let bl = [0.0, 290.0];
|
||||
for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] {
|
||||
for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tl, bl, br, tr], [tr, bl, tl, br]] {
|
||||
assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}");
|
||||
}
|
||||
}
|
||||
@@ -164,8 +190,33 @@ mod tests {
|
||||
fn concave_degenerate_or_non_finite_quads_are_refused() {
|
||||
// A dart: one corner inside the triangle of the other three.
|
||||
assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None);
|
||||
assert_eq!(order_quad([[5.0, 5.0]; 4]), None);
|
||||
assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreshortened_tilted_and_large_scale_quads_order_correctly() {
|
||||
// A landscape page photographed from below has a foreshortened top edge where
|
||||
// bl[0] + bl[1] (500) < tl[0] + tl[1] (550), yet tl is still top-left.
|
||||
let (tl, tr, br, bl) = ([250.0, 300.0], [550.0, 300.0], [800.0, 500.0], [0.0, 500.0]);
|
||||
assert_eq!(order_quad([br, tl, bl, tr]), Some([tl, tr, br, bl]));
|
||||
|
||||
// A narrow portrait strip tilted counter-clockwise has tr[0] + tr[1] (90) < tl[0] + tl[1] (120),
|
||||
// yet tl is still top-left.
|
||||
let (tl, tr, br, bl) = ([20.0, 100.0], [80.0, 10.0], [140.0, 400.0], [80.0, 490.0]);
|
||||
assert_eq!(order_quad([tr, bl, tl, br]), Some([tl, tr, br, bl]));
|
||||
|
||||
// A foreshortened portrait quad where bl sits at x = 0 and y = 60 while tl sits at [40, 0]
|
||||
// still orders [40, 0] as top-left.
|
||||
let (tl, tr, br, bl) = ([40.0, 0.0], [100.0, 10.0], [100.0, 200.0], [0.0, 60.0]);
|
||||
assert_eq!(order_quad([bl, br, tr, tl]), Some([tl, tr, br, bl]));
|
||||
|
||||
// Large finite coordinates where `size * size` would overflow f64 still order cleanly.
|
||||
let s = 1e160;
|
||||
let (tl, tr, br, bl) = ([0.0, 0.0], [s, 0.0], [s, s], [0.0, s]);
|
||||
assert_eq!(order_quad([bl, tr, br, tl]), Some([tl, tr, br, bl]));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) {
|
||||
/// Day numbers (and years) beyond this are refused rather than computed.
|
||||
pub(super) const MAX_DAYS: f64 = 1e8;
|
||||
|
||||
/// The century split of a two-digit year (`YY`), XFA picture clauses' default: `00` to `29` are
|
||||
/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference,
|
||||
/// p. 1171, which makes 30 the default and lets the century split be set otherwise).
|
||||
const CENTURY_SPLIT: i64 = 30;
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …).
|
||||
@@ -89,7 +94,7 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
let v: i64 = digits.parse().ok()?;
|
||||
match c {
|
||||
'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }),
|
||||
'Y' => y = Some(if run <= 2 { if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v } } else { v }),
|
||||
'M' => m = Some(v as u32),
|
||||
_ => d = Some(v as u32),
|
||||
}
|
||||
|
||||
@@ -1346,6 +1346,16 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1");
|
||||
}
|
||||
|
||||
/// A `YY` year in a date picture splits its century at 30, the XFA picture clause default:
|
||||
/// `00` to `29` are 2000 to 2029 and `30` to `99` are 1930 to 1999.
|
||||
#[test]
|
||||
fn a_two_digit_year_splits_its_century_at_30() {
|
||||
for (text, iso) in [("12/31/29", "2029-12-31"), ("01/01/30", "1930-01-01"), ("12/31/49", "1949-12-31"), ("01/01/50", "1950-01-01")] {
|
||||
assert_eq!(calc(&format!(r#"Num2Date(Date2Num("{text}", "MM/DD/YY"), "YYYY-MM-DD")"#)), iso, "{text}");
|
||||
assert_eq!(calc(&format!(r#"Parse("MM/DD/YY", "{text}")"#)), iso, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_flow_variables_and_functions_work() {
|
||||
assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10");
|
||||
|
||||
+35
-11
@@ -177,7 +177,7 @@ impl Scale {
|
||||
pub fn read(doc: &Document, object: &Object) -> Result<Self> {
|
||||
let o = doc.resolve(object);
|
||||
let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?;
|
||||
if d.name(b"Subtype").is_some_and(|n| n != b"RL") {
|
||||
if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) {
|
||||
return Err(invalid("only rectilinear measurement scales are supported"));
|
||||
}
|
||||
let format = |key: &[u8]| -> Result<(String, f64, u8)> {
|
||||
@@ -188,20 +188,25 @@ impl Scale {
|
||||
}
|
||||
let o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?);
|
||||
let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?;
|
||||
let precision = match f.name(b"F") {
|
||||
let fmt = f.get(b"F").map(|o| doc.resolve(o));
|
||||
let precision = match fmt.as_deref().and_then(Object::as_name) {
|
||||
Some(b"R") => 0,
|
||||
None | Some(b"D") => {
|
||||
let denom = f.int(b"D").unwrap_or(100);
|
||||
let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100);
|
||||
(0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))?
|
||||
}
|
||||
_ => return Err(invalid("fractional measurement formats are not supported yet")),
|
||||
};
|
||||
Ok((text(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8))
|
||||
Ok((
|
||||
text(doc, f, b"U"),
|
||||
f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?,
|
||||
precision as u8,
|
||||
))
|
||||
};
|
||||
let (unit, x, _) = format(b"X")?;
|
||||
let y = if d.contains(b"Y") {
|
||||
let (_, y, _) = format(b"Y")?;
|
||||
y * d.get(b"CYX").and_then(Object::as_f64).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))?
|
||||
y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))?
|
||||
} else {
|
||||
x
|
||||
};
|
||||
@@ -210,7 +215,7 @@ impl Scale {
|
||||
return Err(invalid("different coordinate and distance units are not supported yet"));
|
||||
}
|
||||
let (area_unit, area_factor, _) = format(b"A")?;
|
||||
let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(d, b"R") };
|
||||
let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") };
|
||||
out.validate()?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -279,12 +284,19 @@ pub struct Reading {
|
||||
pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result<Reading> {
|
||||
check_points(points)?;
|
||||
scale.validate()?;
|
||||
let points = if kind == Kind::Area { open_polygon(points) } else { points };
|
||||
let mut length = 0.0;
|
||||
for w in points.windows(2) {
|
||||
if let [a, b] = w {
|
||||
length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Area
|
||||
&& points.len() >= 3
|
||||
&& let (Some(a), Some(b)) = (points.last(), points.first())
|
||||
{
|
||||
length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y);
|
||||
}
|
||||
let area = if points.len() >= 3 {
|
||||
let origin = points.first().copied().unwrap_or_default();
|
||||
points
|
||||
@@ -340,7 +352,9 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> {
|
||||
Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")),
|
||||
_ => {}
|
||||
}
|
||||
if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) {
|
||||
if points.windows(2).any(|p| matches!(p, [a, b] if distance(*a, *b) < 1e-6))
|
||||
|| (kind == Kind::Area && matches!((points.first(), points.last()), (Some(a), Some(b)) if distance(*a, *b) < 1e-6))
|
||||
{
|
||||
return Err(invalid("adjacent measurement vertices must be distinct"));
|
||||
}
|
||||
if kind == Kind::Area {
|
||||
@@ -357,8 +371,8 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn text(d: &Dict, k: &[u8]) -> String {
|
||||
d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default()
|
||||
fn text(doc: &Document, d: &Dict, k: &[u8]) -> String {
|
||||
d.get(k).and_then(|o| doc.resolve(o).as_string().map(PdfString::to_text)).unwrap_or_default()
|
||||
}
|
||||
fn nums(v: impl IntoIterator<Item = f64>) -> Object {
|
||||
Object::Array(v.into_iter().map(Object::Real).collect())
|
||||
@@ -491,7 +505,16 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us
|
||||
let points: Vec<Point> = v.as_chunks::<2>().0.to_vec();
|
||||
validate_geometry(kind, &points)?;
|
||||
let reading = reading(kind, &points, &scale)?;
|
||||
Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading })
|
||||
Ok(Measurement {
|
||||
page,
|
||||
index,
|
||||
id: text(doc, d, b"NM"),
|
||||
author: text(doc, d, b"T"),
|
||||
label: text(doc, d, b"PCMeasureLabel"),
|
||||
points,
|
||||
scale,
|
||||
reading,
|
||||
})
|
||||
}
|
||||
/// Every measurement annotation. One that can't be read is reported in `unsupported`
|
||||
/// rather than failing the whole list.
|
||||
@@ -520,7 +543,8 @@ pub fn list(doc: &Document) -> Listing {
|
||||
}
|
||||
let o = doc.resolve(a);
|
||||
let Some(d) = o.as_dict() else { continue };
|
||||
let kind = match d.name(b"IT") {
|
||||
let it = d.get(b"IT").map(|o| doc.resolve(o));
|
||||
let kind = match it.as_deref().and_then(Object::as_name) {
|
||||
Some(b"LineDimension") => Kind::Distance,
|
||||
Some(b"PolyLineDimension") => Kind::Perimeter,
|
||||
Some(b"PolygonDimension") => Kind::Area,
|
||||
|
||||
@@ -549,3 +549,86 @@ fn perimeters_are_charged_linear_work() {
|
||||
assert!(!l.truncated, "{} listed", l.measurements.len());
|
||||
assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_scale_fields_and_closed_area_edges_are_handled() {
|
||||
let mut d = fixture("", "", &[]);
|
||||
let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]]);
|
||||
m.scale = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() };
|
||||
add(&mut d, &m, &Meta { date: None, id: "area-1".into() }).unwrap();
|
||||
|
||||
// Open and closed vertex lists for the same rectangle produce identical readings (including perimeter length).
|
||||
let open = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]], &m.scale).unwrap();
|
||||
let closed = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]], &m.scale).unwrap();
|
||||
close(open.length, 36.0);
|
||||
close(closed.length, 36.0);
|
||||
|
||||
// A doubled closing vertex leaves a zero-length closing edge after stripping one closing duplicate,
|
||||
// and an area with fewer than 3 distinct vertices or a NaN coordinate is also rejected.
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err());
|
||||
assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]]), &Meta::default()).is_err());
|
||||
assert!(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]], &m.scale).is_err());
|
||||
|
||||
// Indirect PDF objects inside /Measure, /NumberFormat, and the annotation dictionary still resolve cleanly.
|
||||
let (r, mut annot_dict) = annot(&d, 0, 0).unwrap();
|
||||
let it_ref = d.add(Object::name("PolygonDimension"));
|
||||
let nm_ref = d.add(PdfString::text("area-indirect"));
|
||||
let author_ref = d.add(PdfString::text("Architect"));
|
||||
let label_ref = d.add(PdfString::text("Floor Plan"));
|
||||
let rl_ref = d.add(Object::name("RL"));
|
||||
let ratio_ref = d.add(PdfString::text("1 pt = 2 m"));
|
||||
let cyx_ref = d.add(Object::Real(1.5));
|
||||
let unit_ref = d.add(PdfString::text("m"));
|
||||
let area_unit_ref = d.add(PdfString::text("m^2"));
|
||||
let factor_x_ref = d.add(Object::Real(2.0));
|
||||
let factor_y_ref = d.add(Object::Real(2.0));
|
||||
let factor_d_ref = d.add(Object::Real(1.0));
|
||||
let factor_a_ref = d.add(Object::Real(1.0));
|
||||
let fmt_d_ref = d.add(Object::name("D"));
|
||||
let denom_ref = d.add(Object::Int(1000));
|
||||
|
||||
let nf = |u: ObjRef, c: ObjRef| {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("NumberFormat"));
|
||||
f.set(b"U".to_vec(), Object::Ref(u));
|
||||
f.set(b"C".to_vec(), Object::Ref(c));
|
||||
f.set(b"F".to_vec(), Object::Ref(fmt_d_ref));
|
||||
f.set(b"D".to_vec(), Object::Ref(denom_ref));
|
||||
Object::Array(vec![Object::Dict(f)])
|
||||
};
|
||||
let mut measure = Dict::new();
|
||||
measure.set(b"Type".to_vec(), Object::name("Measure"));
|
||||
measure.set(b"Subtype".to_vec(), Object::Ref(rl_ref));
|
||||
measure.set(b"R".to_vec(), Object::Ref(ratio_ref));
|
||||
measure.set(b"CYX".to_vec(), Object::Ref(cyx_ref));
|
||||
measure.set(b"X".to_vec(), nf(unit_ref, factor_x_ref));
|
||||
measure.set(b"Y".to_vec(), nf(unit_ref, factor_y_ref));
|
||||
measure.set(b"D".to_vec(), nf(unit_ref, factor_d_ref));
|
||||
measure.set(b"A".to_vec(), nf(area_unit_ref, factor_a_ref));
|
||||
|
||||
annot_dict.set(b"IT".to_vec(), Object::Ref(it_ref));
|
||||
annot_dict.set(b"NM".to_vec(), Object::Ref(nm_ref));
|
||||
annot_dict.set(b"T".to_vec(), Object::Ref(author_ref));
|
||||
annot_dict.set(b"PCMeasureLabel".to_vec(), Object::Ref(label_ref));
|
||||
annot_dict.set(b"Measure".to_vec(), Object::Dict(measure.clone()));
|
||||
d.update_dict(r, |target| *target = annot_dict).unwrap();
|
||||
|
||||
// Non-RL indirect subtypes and numeric subtypes are rejected by Scale::read.
|
||||
let geo_ref = d.add(Object::name("GEO"));
|
||||
let mut bad_geo = measure.clone();
|
||||
bad_geo.set(b"Subtype".to_vec(), Object::Ref(geo_ref));
|
||||
assert!(Scale::read(&d, &Object::Dict(bad_geo)).is_err());
|
||||
let mut bad_num = measure;
|
||||
bad_num.set(b"Subtype".to_vec(), Object::Int(42));
|
||||
assert!(Scale::read(&d, &Object::Dict(bad_num)).is_err());
|
||||
|
||||
let listing = list(&d);
|
||||
assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported);
|
||||
assert_eq!(listing.measurements.len(), 1);
|
||||
let item = &listing.measurements[0];
|
||||
assert_eq!(item.id, "area-indirect");
|
||||
assert_eq!(item.author, "Architect");
|
||||
assert_eq!(item.label, "Floor Plan");
|
||||
assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() });
|
||||
}
|
||||
@@ -262,7 +262,7 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
|
||||
a.claim(o, SpaceCategory::Bookmarks);
|
||||
}
|
||||
let names = a.resolve_dict(cat.get(b"Names"));
|
||||
if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) {
|
||||
for d in [names.get(b"Dests"), cat.get(b"Dests")].into_iter().flatten() {
|
||||
a.claim(d, SpaceCategory::NamedDestinations);
|
||||
}
|
||||
if let Some(e) = names.get(b"EmbeddedFiles") {
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
//! between them, each drawing an image upright (no rotation, skew or flip);
|
||||
//! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array
|
||||
//! that changes anything, in a codec PdfCraft decodes;
|
||||
//! - they share their left and right edges and are stacked edge to edge with no gap or overlap
|
||||
//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them.
|
||||
//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed
|
||||
//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least
|
||||
//! [`MIN_FRAGMENTS`] of them.
|
||||
//!
|
||||
//! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost
|
||||
//! until the image settings downsample it; it is stored losslessly. A page whose content
|
||||
@@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe
|
||||
(w > 0 && h > 0).then_some((r, w, h, cs, n))
|
||||
}
|
||||
|
||||
/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge.
|
||||
/// Sorts it from the top down.
|
||||
fn stacked(run: &mut [Fragment]) -> bool {
|
||||
let Some(first) = run.first() else { return false };
|
||||
let [x0, _, x1, _] = first.rect;
|
||||
let width = x1 - x0;
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let tol = 1e-3 * width.max(1.0);
|
||||
if run.iter().any(|f| {
|
||||
(f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
run.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]),
|
||||
_ => true,
|
||||
})
|
||||
/// Whether `a` and `b` share their top and bottom edges within row tolerance.
|
||||
fn same_y(a: &Fragment, b: &Fragment) -> bool {
|
||||
let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]);
|
||||
(a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on while each is drawn right under (or right over) the one
|
||||
/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side,
|
||||
/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right
|
||||
/// within each row. Returns that exact row partition: tolerance-based membership is not
|
||||
/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor.
|
||||
fn stacked(run: &mut [Fragment]) -> Option<Vec<std::ops::Range<usize>>> {
|
||||
if run.is_empty()
|
||||
|| run.iter().any(|f| {
|
||||
let [x0, y0, x1, y1] = f.rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
!(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
|
||||
let mut row_bounds: Vec<[f64; 4]> = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < run.len() {
|
||||
let mut end = start + 1;
|
||||
while end < run.len() && same_y(&run[start], &run[end]) {
|
||||
end += 1;
|
||||
}
|
||||
let row = &mut run[start..end];
|
||||
row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
if !row.windows(2).all(|w| match w {
|
||||
[a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]),
|
||||
_ => true,
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let (Some(first), Some(last)) = (row.first(), row.last()) else { return None };
|
||||
row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]);
|
||||
rows.push(start..end);
|
||||
start = end;
|
||||
}
|
||||
let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None };
|
||||
let (width, height) = (x1 - x0, y1 - y0);
|
||||
if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol_x = 1e-3 * width.max(1.0);
|
||||
if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) {
|
||||
return None;
|
||||
}
|
||||
row_bounds
|
||||
.windows(2)
|
||||
.all(|w| match w {
|
||||
[a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]),
|
||||
_ => true,
|
||||
})
|
||||
.then_some(rows)
|
||||
}
|
||||
|
||||
/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row
|
||||
/// or strip by strip in one direction, up to a gap or a jump.
|
||||
fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
|
||||
let mut run: Vec<Fragment> = Vec::new();
|
||||
let mut row_x = [0.0f64, 0.0];
|
||||
let mut cur_x = [0.0f64, 0.0];
|
||||
let mut cur_y = [0.0f64, 0.0];
|
||||
let mut first_row = true;
|
||||
let mut right: Option<bool> = None;
|
||||
let mut down: Option<bool> = None;
|
||||
let mut complete = 0usize;
|
||||
let mut j = i;
|
||||
while let Some((next, m, name)) = group_at(ops, j) {
|
||||
while run.len() < limit {
|
||||
let Some((next, m, name)) = group_at(ops, j) else { break };
|
||||
let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break };
|
||||
let [a, _, _, d, e, f] = m.0;
|
||||
let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height };
|
||||
if let Some(prev) = run.last() {
|
||||
let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (prev.rect[1] - f2.rect[3]).abs() <= tol;
|
||||
let above = (f2.rect[1] - prev.rect[3]).abs() <= tol;
|
||||
let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0);
|
||||
match (down, below, above) {
|
||||
_ if !same_x => break,
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y;
|
||||
if same_row {
|
||||
let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]);
|
||||
let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x;
|
||||
let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x;
|
||||
match (right, step_right, step_left) {
|
||||
(None | Some(true), true, _) => {
|
||||
right = Some(true);
|
||||
cur_x[1] = f2.rect[2];
|
||||
}
|
||||
(None | Some(false), _, true) => {
|
||||
right = Some(false);
|
||||
cur_x[0] = f2.rect[0];
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
if first_row {
|
||||
row_x = cur_x;
|
||||
complete = run.len() + 1;
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span {
|
||||
break;
|
||||
}
|
||||
if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span {
|
||||
complete = run.len() + 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0);
|
||||
if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span {
|
||||
break;
|
||||
}
|
||||
let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]);
|
||||
let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v;
|
||||
let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v;
|
||||
match (down, below, above) {
|
||||
(None | Some(true), true, _) => down = Some(true),
|
||||
(None | Some(false), _, true) => down = Some(false),
|
||||
_ => break,
|
||||
}
|
||||
let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span;
|
||||
let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span;
|
||||
if starts_left && starts_right {
|
||||
right = None;
|
||||
complete = run.len() + 1;
|
||||
} else if starts_left {
|
||||
right = Some(true);
|
||||
} else if starts_right {
|
||||
right = Some(false);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
first_row = false;
|
||||
cur_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
}
|
||||
} else {
|
||||
row_x = [f2.rect[0], f2.rect[2]];
|
||||
cur_x = row_x;
|
||||
cur_y = [f2.rect[1], f2.rect[3]];
|
||||
complete = 1;
|
||||
}
|
||||
run.push(f2);
|
||||
j = next;
|
||||
}
|
||||
run.truncate(complete);
|
||||
run
|
||||
}
|
||||
|
||||
@@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
|
||||
}
|
||||
|
||||
/// The run's pixels as one image, top row first: `(pixels, width, height)`.
|
||||
fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]);
|
||||
let width = run.iter().map(|f| f.width).max()?;
|
||||
// Pixel rows per unit of height: the finest fragment's.
|
||||
let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let height = ((top - bottom) * density).round();
|
||||
fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range<usize>], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let (first, last) = (run.first()?, run.last()?);
|
||||
let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]);
|
||||
let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::<Option<_>>()?;
|
||||
// Pixels per unit along each axis: the finest fragment's.
|
||||
let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max);
|
||||
let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
|
||||
let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() };
|
||||
let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() };
|
||||
if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) {
|
||||
return None;
|
||||
}
|
||||
// In range and whole, so the cast is exact.
|
||||
let height = height as u32;
|
||||
// In range and whole, so the casts are exact.
|
||||
let (width, height) = (width as u32, height as u32);
|
||||
if u64::from(width) * u64::from(height) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let row = (width as usize).checked_mul(n)?;
|
||||
let stride = (width as usize).checked_mul(n)?;
|
||||
// What this builds isn't what its strips cost to read: four small ones can ask for all of it.
|
||||
let bytes = row.checked_mul(height as usize)?;
|
||||
let bytes = stride.checked_mul(height as usize)?;
|
||||
if !budget.pay_pixels(u64::try_from(bytes).ok()?) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![255u8; bytes];
|
||||
for f in run {
|
||||
let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height);
|
||||
let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height);
|
||||
let mut r0 = 0u32;
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) };
|
||||
if r1 <= r0 {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? };
|
||||
out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?);
|
||||
let th = r1 - r0;
|
||||
let mut c0 = 0u32;
|
||||
for (col_idx, f) in row.iter().enumerate() {
|
||||
let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) };
|
||||
if c1 <= c0 {
|
||||
return None;
|
||||
}
|
||||
let tw = c1 - c0;
|
||||
let obj = doc.get(f.image);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let px = pixels(doc, s, n, budget)?;
|
||||
let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? };
|
||||
let tile_row = (tw as usize).checked_mul(n)?;
|
||||
let col_off = (c0 as usize).checked_mul(n)?;
|
||||
for r in 0..th as usize {
|
||||
let dst = (r0 as usize + r) * stride + col_off;
|
||||
let src = r * tile_row;
|
||||
out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?);
|
||||
}
|
||||
c0 = c1;
|
||||
}
|
||||
r0 = r1;
|
||||
}
|
||||
Some((out, width, height))
|
||||
}
|
||||
@@ -288,13 +411,15 @@ fn merge_page(
|
||||
j = next;
|
||||
}
|
||||
scanned = scanned.saturating_add(run.len()).saturating_add(1);
|
||||
// Not one picture as a whole: the rows drawn one under the other from the start may be.
|
||||
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
|
||||
run = drawn_in_sequence(&ops, i, &xobjects, doc);
|
||||
// Not one picture as a whole: a prefix drawn row by row or strip by strip may be.
|
||||
let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None };
|
||||
if run.len() >= MIN_FRAGMENTS && rows.is_none() {
|
||||
run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc);
|
||||
j = run_end(&ops, i, run.len());
|
||||
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
|
||||
rows = stacked(&mut run);
|
||||
}
|
||||
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
|
||||
let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else {
|
||||
// Not a run: keep this op and look again from the next one.
|
||||
if let Some(op) = ops.get(i) {
|
||||
out.push(op.clone());
|
||||
@@ -305,7 +430,7 @@ fn merge_page(
|
||||
if !progress(Stage::Fragments) {
|
||||
return Err(OptimizeError::Cancelled);
|
||||
}
|
||||
let Some((px, w, h)) = merge(doc, &run, n, images) else {
|
||||
let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else {
|
||||
out.extend(ops.get(i..j).unwrap_or_default().iter().cloned());
|
||||
i = j;
|
||||
continue;
|
||||
@@ -322,7 +447,7 @@ fn merge_page(
|
||||
xobjects.set(name.clone(), Object::Ref(image));
|
||||
add_xobject(doc, page, &name, image)?;
|
||||
let (Some(first), Some(last)) = (run.first(), run.last()) else { break };
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]];
|
||||
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]];
|
||||
let num = Object::Real;
|
||||
out.push(Op::new("q", Vec::new()));
|
||||
out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)]));
|
||||
@@ -339,6 +464,7 @@ fn merge_page(
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Merge consecutive tiled image fragments across `pages`.
|
||||
pub(crate) fn run(
|
||||
doc: &mut Document,
|
||||
pages: &[ObjRef],
|
||||
@@ -361,17 +487,45 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers.
|
||||
#[test]
|
||||
fn a_merged_height_beyond_u32_is_refused_before_allocating() {
|
||||
// Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be
|
||||
// 2^16 × 2^48 pixels, whose product wraps to 0 in u64.
|
||||
let image = ObjRef { num: 1, generation: 0 };
|
||||
let edge = 1.0 - (-48f64).exp2();
|
||||
let run = [
|
||||
let vertical = [
|
||||
Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 },
|
||||
Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
let horizontal = [
|
||||
Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 },
|
||||
Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 },
|
||||
];
|
||||
assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Even an inconsistent partition must not silently omit a row or tile from its report.
|
||||
#[test]
|
||||
fn assembly_refuses_collapsed_output_rows_and_tiles() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(10));
|
||||
dict.set(b"Height".to_vec(), Object::Int(10));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100])));
|
||||
let mut run: Vec<_> = (0..4)
|
||||
.map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 })
|
||||
.collect();
|
||||
// The first supposed row consumes the entire height; the second cannot be discarded.
|
||||
assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
// Likewise, a tile whose right edge does not advance cannot simply vanish.
|
||||
if let Some(tile) = run.get_mut(1) {
|
||||
tile.rect = [0.0, 0.0, 10.0, 10.0];
|
||||
}
|
||||
assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
|
||||
}
|
||||
|
||||
/// Merging reads the images the pass after it reads, from the same allowance: with nothing
|
||||
@@ -474,4 +628,149 @@ mod tests {
|
||||
let reads = GROUP_READS.with(|c| c.get());
|
||||
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
|
||||
}
|
||||
|
||||
/// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid
|
||||
/// or separated runs are handled per run.
|
||||
#[test]
|
||||
fn side_by_side_and_grid_fragments_merge_into_one_image() {
|
||||
let tile = |doc: &mut Document, w: u32, h: u32, px: Vec<u8>| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
doc.add(Object::Stream(Stream::flate(d, &px)))
|
||||
};
|
||||
let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| {
|
||||
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes())));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
doc.add(Object::Dict(page))
|
||||
};
|
||||
|
||||
// Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3
|
||||
// pixels so the merged image keeps the finest horizontal resolution (8×3).
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for c in 0..4u8 {
|
||||
let w = if c % 2 == 0 { 1 } else { 2 };
|
||||
let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]);
|
||||
xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap();
|
||||
let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3)));
|
||||
assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3));
|
||||
let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||||
let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") };
|
||||
let parsed = parse(&cs.decoded().unwrap());
|
||||
let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap();
|
||||
assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]);
|
||||
|
||||
// A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
for r in 0..2u8 {
|
||||
for c in 0..3u8 {
|
||||
let v = r * 100 + c * 20;
|
||||
let im = tile(&mut doc, 2, 2, vec![v; 4]);
|
||||
xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im));
|
||||
}
|
||||
}
|
||||
let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)]
|
||||
.into_iter()
|
||||
.map(|(r, c)| {
|
||||
let x = 5.0 + f64::from(c) * 10.0;
|
||||
let y = 50.0 + f64::from(1 - r) * 10.0;
|
||||
format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n")
|
||||
})
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (6, 1));
|
||||
let xo = doc
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"Resources"))
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"XObject"))
|
||||
.and_then(Object::as_dict)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4)));
|
||||
let mut expected = Vec::new();
|
||||
expected.extend([0, 0, 20, 20, 40, 40].repeat(2));
|
||||
expected.extend([100, 100, 120, 120, 140, 140].repeat(2));
|
||||
assert_eq!(s.decoded().unwrap(), expected);
|
||||
|
||||
// Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images,
|
||||
// whether drawn left to right or right to left.
|
||||
for right_to_left in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![80; 4]);
|
||||
xobjects.set(b"S".to_vec(), Object::Ref(im));
|
||||
let xs: Vec<f64> = if right_to_left {
|
||||
vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0]
|
||||
} else {
|
||||
vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0]
|
||||
};
|
||||
let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}");
|
||||
}
|
||||
|
||||
// A 2×2 grid followed immediately by a lone fifth tile below only the first column merges
|
||||
// the 4 grid tiles and leaves the fifth tile alone.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n"))
|
||||
.collect::<String>();
|
||||
let page = build_page(&mut doc, xobjects, &ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (4, 1));
|
||||
|
||||
// Overlapping side-by-side strips or rows of different widths are left alone.
|
||||
for bad_ops in [
|
||||
"q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n",
|
||||
"q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n",
|
||||
] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut xobjects = Dict::new();
|
||||
let im = tile(&mut doc, 2, 2, vec![90; 4]);
|
||||
xobjects.set(b"G".to_vec(), Object::Ref(im));
|
||||
let page = build_page(&mut doc, xobjects, bad_ops);
|
||||
let mut report = Report::default();
|
||||
let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
|
||||
assert_eq!((merged, report.merged_images), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,19 @@ use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary.
|
||||
fn named_dests(doc: &Document) -> HashSet<Vec<u8>> {
|
||||
/// Named destinations whose target page exists: the `/Names /Dests` tree and the old `/Dests`
|
||||
/// dictionary.
|
||||
fn named_dests(doc: &Document, pages: &HashSet<ObjRef>, count: usize) -> HashSet<Vec<u8>> {
|
||||
let mut out = HashSet::new();
|
||||
let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out };
|
||||
if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) {
|
||||
out.extend(d.iter().map(|(k, _)| k.clone()));
|
||||
out.extend(d.iter().filter(|(_, v)| valid_explicit(doc, v, pages, count)).map(|(k, _)| k.clone()));
|
||||
}
|
||||
if let Some(tree) = names_tree(doc, &cat) {
|
||||
for (k, _) in tree_entries(doc, &tree) {
|
||||
out.insert(k);
|
||||
for (k, v) in tree_entries(doc, &tree) {
|
||||
if valid_explicit(doc, &v, pages, count) {
|
||||
out.insert(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -74,22 +77,33 @@ fn dest_name(o: &Object) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn valid(o: &Object, pages: &HashSet<ObjRef>, count: usize, names: &HashSet<Vec<u8>>) -> bool {
|
||||
match o {
|
||||
Object::Array(a) => match a.first() {
|
||||
Some(Object::Ref(r)) => pages.contains(r),
|
||||
Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count,
|
||||
_ => false,
|
||||
},
|
||||
other => dest_name(other).is_some_and(|n| names.contains(&n)),
|
||||
/// The `[page /Fit …]` array of an explicit destination (an array or a `<< /D […] >>` dictionary).
|
||||
fn explicit_array(doc: &Document, o: &Object) -> Option<Vec<Object>> {
|
||||
match doc.resolve(o).as_ref() {
|
||||
Object::Array(a) => Some(a.clone()),
|
||||
Object::Dict(d) => d.get(b"D").and_then(|x| doc.resolve(x).as_array().cloned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an explicit destination targets one of the document's pages.
|
||||
fn valid_explicit(doc: &Document, o: &Object, pages: &HashSet<ObjRef>, count: usize) -> bool {
|
||||
explicit_array(doc, o).is_some_and(|a| match a.first() {
|
||||
Some(Object::Ref(r)) => pages.contains(r),
|
||||
Some(Object::Int(i)) => usize::try_from(*i).is_ok_and(|i| i < count),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid(doc: &Document, o: &Object, pages: &HashSet<ObjRef>, count: usize, names: &HashSet<Vec<u8>>) -> bool {
|
||||
valid_explicit(doc, o, pages, count) || dest_name(o).is_some_and(|n| names.contains(&n))
|
||||
}
|
||||
|
||||
/// Remove links whose destination is missing, and clear the destination of such bookmarks
|
||||
/// (they stay, so their children do too). Returns (links, bookmarks).
|
||||
pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), pdfcraft_cos::CosError> {
|
||||
let set: HashSet<ObjRef> = pages.iter().copied().collect();
|
||||
let names = named_dests(doc);
|
||||
let names = named_dests(doc, &set, pages.len());
|
||||
let mut links = 0;
|
||||
for p in pages {
|
||||
let Some(pd) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
@@ -101,7 +115,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
if d.name(b"Subtype") != Some(b"Link") {
|
||||
return true;
|
||||
}
|
||||
destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names))
|
||||
destination(doc, &d).is_none_or(|dest| valid(doc, &dest, &set, pages.len(), &names))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -130,7 +144,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
stack.push(f);
|
||||
}
|
||||
if let Some(dest) = destination(doc, &d)
|
||||
&& !valid(&dest, &set, pages.len(), &names)
|
||||
&& !valid(doc, &dest, &set, pages.len(), &names)
|
||||
{
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"Dest");
|
||||
@@ -142,39 +156,40 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us
|
||||
Ok((links, bookmarks))
|
||||
}
|
||||
|
||||
/// Every named destination some object refers to (links, bookmarks, actions, open action).
|
||||
/// Every named destination some reachable object refers to (links, bookmarks, actions, open action).
|
||||
fn referenced_names(doc: &Document) -> HashSet<Vec<u8>> {
|
||||
let mut out = HashSet::new();
|
||||
let mut visit = |o: &Object| {
|
||||
let mut stack = vec![o.clone()];
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
// Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name
|
||||
// string, and an unresolved ref has no name, so the destination it uses would
|
||||
// look unreferenced and be removed. A GoToR or GoToE /D names a destination in
|
||||
// another document, so it does not count as a use of this one's.
|
||||
if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) {
|
||||
out.insert(n);
|
||||
}
|
||||
if d.name(b"S") == Some(b"GoTo")
|
||||
&& let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref()))
|
||||
{
|
||||
out.insert(n);
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v.clone()));
|
||||
}
|
||||
Object::Array(a) => stack.extend(a),
|
||||
Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
for num in doc.object_numbers() {
|
||||
visit(&doc.get(ObjRef::new(num, doc.generation(num))));
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack = vec![Object::Dict(doc.trailer().clone())];
|
||||
if let Some(r) = doc.root() {
|
||||
visit(&doc.get(r));
|
||||
stack.push(Object::Ref(r));
|
||||
}
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Ref(r) => {
|
||||
if seen.insert(r) {
|
||||
stack.push(doc.get(r).as_ref().clone());
|
||||
}
|
||||
}
|
||||
Object::Dict(d) => {
|
||||
// Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name
|
||||
// string, and an unresolved ref has no name, so the destination it uses would
|
||||
// look unreferenced and be removed. A GoToR or GoToE /D names a destination in
|
||||
// another document, so it does not count as a use of this one's.
|
||||
if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) {
|
||||
out.insert(n);
|
||||
}
|
||||
if d.name(b"S") == Some(b"GoTo")
|
||||
&& let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref()))
|
||||
{
|
||||
out.insert(n);
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v.clone()));
|
||||
}
|
||||
Object::Array(a) => stack.extend(a),
|
||||
Object::Stream(s) => stack.push(Object::Dict(s.dict)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -3096,6 +3096,52 @@ fn drawn_images(doc: &Document) -> Vec<Stream> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tolerated row must not be regrouped after its highest tile moves during X sorting.
|
||||
#[test]
|
||||
fn fragment_row_tolerance_preserves_every_tiles_pixels() {
|
||||
for aligned in [true, false] {
|
||||
for merge_fragments in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut refs = Vec::new();
|
||||
for gray in [20u8, 80, 140, 220] {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(10));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100]))));
|
||||
}
|
||||
let content = if aligned {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q"
|
||||
} else {
|
||||
"q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q"
|
||||
};
|
||||
page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content);
|
||||
let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||||
let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap();
|
||||
// Save and reopen, then inspect only images still drawn by the optimized page.
|
||||
let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let drawn = drawn_images(&doc);
|
||||
if report.fragments_merged == 0 {
|
||||
assert_eq!(report.merged_images, 0);
|
||||
assert_eq!(drawn.len(), 4);
|
||||
for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) {
|
||||
assert_eq!(image.decoded().unwrap(), [gray; 100]);
|
||||
}
|
||||
} else {
|
||||
assert!(merge_fragments);
|
||||
assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1));
|
||||
let image = &drawn[0];
|
||||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10)));
|
||||
let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect();
|
||||
assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() {
|
||||
// 120 strips, every other one JPEG and half as wide (as one PDF printer writes them).
|
||||
@@ -4196,6 +4242,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "BT ET");
|
||||
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||||
let dict_dest = |target: ObjRef| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let link = |doc: &mut Document, dest: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Link"));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||||
d.set(b"Dest".to_vec(), dest);
|
||||
Object::Ref(doc.add(Object::Dict(d)))
|
||||
};
|
||||
let missing = ObjRef::new(999, 0);
|
||||
// Links: (1) explicit dictionary destination to `p` (valid), (2) explicit dictionary
|
||||
// destination to `missing` (invalid), (3) named destination "dict_ok" whose value is
|
||||
// `<< /D [p /Fit] >>` (valid), (4) named destination "broken_tree" whose target page is
|
||||
// `missing` (invalid).
|
||||
let annots = vec![
|
||||
link(&mut doc, dict_dest(p)),
|
||||
link(&mut doc, dict_dest(missing)),
|
||||
link(&mut doc, Object::Array(vec![Object::Int(i64::from(u32::MAX) + 1), Object::name("Fit")])),
|
||||
link(&mut doc, s("dict_ok")),
|
||||
link(&mut doc, s("broken_tree")),
|
||||
];
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap();
|
||||
|
||||
// Bookmark with an indirect GoTo action pointing to "broken_old" in catalog /Dests (whose
|
||||
// page is `missing`), and a second bookmark pointing to "old_ok" in catalog /Dests.
|
||||
let outlines = doc.add(Object::Null);
|
||||
let mut goto_broken = Dict::new();
|
||||
goto_broken.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
goto_broken.set(b"D".to_vec(), Object::name("broken_old"));
|
||||
let goto_broken = doc.add(Object::Dict(goto_broken));
|
||||
let mut bm_broken = Dict::new();
|
||||
bm_broken.set(b"Title".to_vec(), s("Broken"));
|
||||
bm_broken.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
bm_broken.set(b"A".to_vec(), Object::Ref(goto_broken));
|
||||
let bm_broken = doc.add(Object::Dict(bm_broken));
|
||||
let mut bm_ok = Dict::new();
|
||||
bm_ok.set(b"Title".to_vec(), s("Ok"));
|
||||
bm_ok.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
bm_ok.set(b"Dest".to_vec(), Object::name("old_ok"));
|
||||
let bm_ok = doc.add(Object::Dict(bm_ok));
|
||||
doc.update_dict(bm_broken, |d| d.set(b"Next".to_vec(), Object::Ref(bm_ok))).unwrap();
|
||||
let mut o = Dict::new();
|
||||
o.set(b"First".to_vec(), Object::Ref(bm_broken));
|
||||
o.set(b"Last".to_vec(), Object::Ref(bm_ok));
|
||||
doc.set(outlines, Object::Dict(o));
|
||||
|
||||
// /Names /Dests has "broken_tree" (pointing to `missing`) and "dict_ok" (`<< /D [p /Fit] >>`).
|
||||
let broken_dest = doc.add(dict_dest(missing));
|
||||
let ok_dest = doc.add(dict_dest(p));
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Names".to_vec(), Object::Array(vec![s("broken_tree"), Object::Ref(broken_dest), s("dict_ok"), Object::Ref(ok_dest)]));
|
||||
let tree = doc.add(Object::Dict(tree));
|
||||
let mut names = Dict::new();
|
||||
names.set(b"Dests".to_vec(), Object::Ref(tree));
|
||||
|
||||
// Catalog /Dests has "broken_old" (pointing to `missing`) and "old_ok" (`<< /D [p /Fit] >>`).
|
||||
let mut old_dests = Dict::new();
|
||||
old_dests.set(b"broken_old".to_vec(), Object::Array(vec![Object::Ref(missing), Object::name("Fit")]));
|
||||
old_dests.set(b"old_ok".to_vec(), dict_dest(p));
|
||||
let old_dests = doc.add(Object::Dict(old_dests));
|
||||
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"Names".to_vec(), Object::Dict(names));
|
||||
c.set(b"Dests".to_vec(), Object::Ref(old_dests));
|
||||
c.set(b"Outlines".to_vec(), Object::Ref(outlines));
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Space audit claims both /Names /Dests and catalog /Dests under NamedDestinations.
|
||||
let only_tree = {
|
||||
let mut d = doc.clone();
|
||||
d.update_dict(root, |c| {
|
||||
c.remove(b"Dests");
|
||||
})
|
||||
.unwrap();
|
||||
audit_space(&d, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes
|
||||
};
|
||||
let both_tables = audit_space(&doc, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes;
|
||||
assert!(both_tables > only_tree, "catalog /Dests is counted alongside /Names /Dests: {both_tables} vs {only_tree}");
|
||||
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (3, 1, 2), "{report:?}");
|
||||
let left_links = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len();
|
||||
assert_eq!(left_links, 2, "the two valid dictionary/named links remain");
|
||||
assert!(doc.get(bm_broken).as_dict().unwrap().get(b"A").is_none(), "broken bookmark action is cleared");
|
||||
assert!(doc.get(bm_ok).as_dict().unwrap().get(b"Dest").is_some(), "valid bookmark destination stays");
|
||||
assert_eq!(named_dest_keys(&doc), vec![b"dict_ok".to_vec()]);
|
||||
let left_old = doc.get(old_dests).as_dict().cloned().unwrap();
|
||||
assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}");
|
||||
}
|
||||
|
||||
/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle.
|
||||
fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef {
|
||||
let row = (w as usize).div_ceil(8);
|
||||
|
||||
@@ -4505,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() {
|
||||
assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]);
|
||||
}
|
||||
|
||||
/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts
|
||||
/// at its first page, and a structure tree root listing a structure namespace (14.7.4).
|
||||
fn with_document_parts_and_namespaces() -> Document {
|
||||
// PDF 2.0, the version these structures belong to.
|
||||
let mut bytes = fixture();
|
||||
let header = bytes.get_mut(..8).expect("the fixture starts with its header");
|
||||
assert!(header.starts_with(b"%PDF-1."));
|
||||
header.copy_from_slice(b"%PDF-2.0");
|
||||
let mut doc = open(bytes);
|
||||
let first = pages(&doc).unwrap()[0].obj;
|
||||
let mut namespace = pdfcraft_cos::Dict::new();
|
||||
namespace.set(b"Type".to_vec(), Object::name("Namespace"));
|
||||
namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false }));
|
||||
let namespace = doc.add(Object::Dict(namespace));
|
||||
let mut tree = pdfcraft_cos::Dict::new();
|
||||
tree.set(b"Type".to_vec(), Object::name("StructTreeRoot"));
|
||||
tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)]));
|
||||
let tree = doc.add(Object::Dict(tree));
|
||||
let mut part = pdfcraft_cos::Dict::new();
|
||||
part.set(b"Type".to_vec(), Object::name("DPart"));
|
||||
part.set(b"Start".to_vec(), Object::Ref(first));
|
||||
part.set(b"End".to_vec(), Object::Ref(first));
|
||||
let part = doc.add(Object::Dict(part));
|
||||
let mut root = pdfcraft_cos::Dict::new();
|
||||
root.set(b"Type".to_vec(), Object::name("DPartRoot"));
|
||||
root.set(b"DPartRootNode".to_vec(), Object::Ref(part));
|
||||
let root = doc.add(Object::Dict(root));
|
||||
doc.update_dict(doc.root().unwrap(), |c| {
|
||||
c.set(b"DPartRoot".to_vec(), Object::Ref(root));
|
||||
c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) {
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let catalog = catalog.as_dict().unwrap();
|
||||
let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped")));
|
||||
let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped")));
|
||||
let part = node.as_dict().unwrap();
|
||||
assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}");
|
||||
assert!(part.get(b"Start").is_some(), "{what}: /Start dropped");
|
||||
assert!(part.get(b"End").is_some(), "{what}: /End dropped");
|
||||
let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped")));
|
||||
let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped")));
|
||||
let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]);
|
||||
assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}");
|
||||
assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed");
|
||||
assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_edits_keep_document_parts_and_structure_namespaces() {
|
||||
type Edit = fn(&mut Document);
|
||||
let edits: [(&str, Edit); 6] = [
|
||||
("rotate", |d| rotate_pages(d, &[0], 90).unwrap()),
|
||||
("move", |d| move_pages(d, &[0], 2).unwrap()),
|
||||
("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()),
|
||||
("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()),
|
||||
("insert blank", |d| {
|
||||
let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap();
|
||||
}),
|
||||
// The part's start page goes: the keys stay, the page reference is the part's to repair.
|
||||
("delete", |d| delete_pages(d, &[0]).unwrap()),
|
||||
];
|
||||
for (what, edit) in edits {
|
||||
let mut doc = with_document_parts_and_namespaces();
|
||||
let base = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup");
|
||||
doc = open(base);
|
||||
edit(&mut doc);
|
||||
assert_document_parts_and_namespaces_kept(&doc, what);
|
||||
assert_document_parts_and_namespaces_kept(
|
||||
&open(write_incremental(&doc, &SaveOptions::default()).unwrap()),
|
||||
&format!("{what}, incremental save"),
|
||||
);
|
||||
assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a
|
||||
/// `%` comment at the end of a piece without a line break continues through the first line of the
|
||||
/// next piece in both resource pruning (`extract_pages`) and `page_as_form`.
|
||||
|
||||
@@ -357,6 +357,7 @@ impl PdfCraftApp {
|
||||
};
|
||||
}
|
||||
("author", _) => self.comment_prefs.author = value.to_string(),
|
||||
("folder-collapsed", _) => self.set_folder_collapsed(value)?,
|
||||
("comment-tool-pinned", _) => {
|
||||
self.comment_prefs.pinned = match value {
|
||||
"true" => true,
|
||||
|
||||
@@ -87,6 +87,7 @@ impl PdfCraftApp {
|
||||
"reload_changed_files": self.reload_changed_files,
|
||||
"system_title_bar": self.system_title_bar,
|
||||
"pinned_folders": self.pinned.folders,
|
||||
"pinned_folders_collapsed": self.pinned.collapsed,
|
||||
"theme": self.theme_preference,
|
||||
"scroll_speed": self.scroll_speed,
|
||||
"invert_middle_drag": self.invert_middle_drag,
|
||||
@@ -252,6 +253,7 @@ impl PdfCraftApp {
|
||||
self.text_links = on;
|
||||
}
|
||||
self.pinned.restore(&v["pinned_folders"]);
|
||||
self.pinned.restore_collapsed(&v["pinned_folders_collapsed"]);
|
||||
if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
|
||||
let mut tools = Vec::new();
|
||||
for item in items {
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar`
|
||||
//! reports its tools, relative position, orientation and strip preference. These options persist across restarts.
|
||||
//! `ui.state.comment_tool_pinned` reports the same preference.
|
||||
//! `folder-collapsed=<folder>=on|off` folds a pinned folder on Home down to its heading or
|
||||
//! opens it again; `ui.state.pinned_folders` lists each pinned folder and whether it is.
|
||||
//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it
|
||||
//! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until
|
||||
//! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`.
|
||||
|
||||
@@ -146,6 +146,8 @@ impl Host for crate::PdfCraftApp {
|
||||
"default_mode": self.default_mode,
|
||||
"hotbar": self.hotbar,
|
||||
"comment_tool_pinned": self.comment_prefs.pinned,
|
||||
// Home's pinned folders, in order, and whether each is folded down to its heading.
|
||||
"pinned_folders": self.pinned.folders.iter().map(|f| json!({ "folder": f, "collapsed": self.pinned.collapsed.contains(f) })).collect::<Vec<_>>(),
|
||||
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },
|
||||
"tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" },
|
||||
"rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" },
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct PinnedFolders {
|
||||
pub listings: Vec<FolderListing>,
|
||||
/// Folders whose "Show more" is open.
|
||||
pub expanded: Vec<String>,
|
||||
/// Folders folded down to their heading (#5295); kept in settings.
|
||||
pub collapsed: Vec<String>,
|
||||
/// A listing in progress: the worker fills the slot.
|
||||
scan: Option<Arc<Mutex<Option<Vec<FolderListing>>>>>,
|
||||
/// When (egui time) the last listing started.
|
||||
@@ -86,8 +88,34 @@ impl PinnedFolders {
|
||||
self.folders = folders;
|
||||
self.listings.clear();
|
||||
self.expanded.clear();
|
||||
self.collapsed.clear();
|
||||
self.stale = true;
|
||||
}
|
||||
|
||||
/// Restore which folders are collapsed, after [`Self::restore`]: only folders that are
|
||||
/// pinned, each once, so untrusted settings can't grow the list.
|
||||
pub(crate) fn restore_collapsed(&mut self, value: &serde_json::Value) {
|
||||
let Some(items) = value.as_array() else { return };
|
||||
self.collapsed.clear();
|
||||
for folder in items.iter().filter_map(serde_json::Value::as_str) {
|
||||
if self.folders.iter().any(|f| f == folder) && !self.collapsed.iter().any(|f| f == folder) {
|
||||
self.collapsed.push(folder.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold pinned `folder` down to its heading (`collapsed`) or open it again. Returns false,
|
||||
/// changing nothing, when `folder` isn't pinned.
|
||||
pub(crate) fn set_collapsed(&mut self, folder: &str, collapsed: bool) -> bool {
|
||||
if !self.folders.iter().any(|f| f == folder) {
|
||||
return false;
|
||||
}
|
||||
self.collapsed.retain(|f| f != folder);
|
||||
if collapsed {
|
||||
self.collapsed.push(folder.to_string());
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` as a folder to pin: trimmed, without a trailing separator, and absolute; `None` if it
|
||||
@@ -221,6 +249,25 @@ impl PdfCraftApp {
|
||||
self.pinned.folders.retain(|f| f != folder);
|
||||
self.pinned.listings.retain(|l| l.folder != folder);
|
||||
self.pinned.expanded.retain(|f| f != folder);
|
||||
self.pinned.collapsed.retain(|f| f != folder);
|
||||
}
|
||||
|
||||
/// `ui.set folder-collapsed <folder>=on|off` (CodeRabbit on #5341): the control channel's
|
||||
/// way to fold a pinned folder down to its heading or open it again, as its chevron does.
|
||||
/// `ui.state` lists the pinned folders and which are collapsed. The folder is named by its
|
||||
/// path as pinned; the last `=` splits it from the state, so a path may hold `=` too.
|
||||
pub(crate) fn set_folder_collapsed(&mut self, value: &str) -> Result<(), String> {
|
||||
let (folder, state) = value.rsplit_once('=').ok_or("folder-collapsed must be <folder>=on or <folder>=off")?;
|
||||
let collapsed = match state {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("folder-collapsed must be <folder>=on or <folder>=off".into()),
|
||||
};
|
||||
if self.pinned.set_collapsed(folder, collapsed) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("folder-collapsed: {folder} is not a pinned folder (ui.state lists them)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// List the pinned folders again if the last listing is older than a few seconds or the
|
||||
@@ -308,10 +355,27 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let mut open = None;
|
||||
let mut unpin = None;
|
||||
let mut toggle = None;
|
||||
let mut fold = None;
|
||||
for folder in &app.pinned.folders {
|
||||
let name = folder_name(folder);
|
||||
// Buttons name their folder; two pinned folders of the same name add where each is.
|
||||
let shared = app.pinned.folders.iter().any(|f| f != folder && folder_name(f) == name);
|
||||
let named = if shared { format!("{name} ({})", location(folder)) } else { name.clone() };
|
||||
let collapsed = app.pinned.collapsed.contains(folder);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (chevron, tip, label) = if collapsed {
|
||||
("chevron-right", tl!("Expand"), tl!("Expand {folder}"))
|
||||
} else {
|
||||
("chevron-down", tl!("Collapse"), tl!("Collapse {folder}"))
|
||||
};
|
||||
let resp = icons::button(ui, chevron, 22.0, false, tip);
|
||||
// Every folder has one: say which folder it folds.
|
||||
let label = crate::i18n::fmt(label, &[("folder", &named)]);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if resp.clicked() {
|
||||
fold = Some(folder.clone());
|
||||
}
|
||||
let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
icons::paint(ui, icon, "folder", 20.0, t.icon);
|
||||
// Folder names and paths are file/path chrome: explicitly left to right, so an
|
||||
@@ -327,13 +391,16 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "x", tl!("Unpin"));
|
||||
// Every folder has an Unpin button: say which one it is.
|
||||
let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]);
|
||||
let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &named)]);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if resp.clicked() {
|
||||
unpin = Some(folder.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
let note = |ui: &mut egui::Ui, text: &str| {
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new(text).color(t.text_muted));
|
||||
@@ -405,6 +472,10 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
app.pinned.expanded.push(folder);
|
||||
}
|
||||
}
|
||||
if let Some(folder) = fold {
|
||||
let collapsed = app.pinned.collapsed.contains(&folder);
|
||||
app.pinned.set_collapsed(&folder, !collapsed);
|
||||
}
|
||||
if let Some(path) = open {
|
||||
app.open_recent(&path);
|
||||
}
|
||||
|
||||
@@ -953,6 +953,8 @@ fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() {
|
||||
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// The control channel reports the open document, its page count and current page, and its
|
||||
/// view options can be set: the page, and a right-hand panel by name (an unknown one is refused).
|
||||
#[test]
|
||||
fn state_and_view_options() {
|
||||
let (mut h, c) = harness();
|
||||
@@ -969,6 +971,9 @@ fn state_and_view_options() {
|
||||
assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err());
|
||||
}
|
||||
|
||||
/// In a Japanese interface, the mode tabs and Preferences show Japanese labels, the command
|
||||
/// palette finds commands by their Japanese or English names or their ids, and switching back to
|
||||
/// English keeps the tabs working.
|
||||
#[test]
|
||||
fn japanese_controls_and_search_keep_command_ids() {
|
||||
if crate::support::skip_without_catalogs("japanese_controls_and_search_keep_command_ids") {
|
||||
@@ -1004,6 +1009,9 @@ fn japanese_controls_and_search_keep_command_ids() {
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
|
||||
}
|
||||
|
||||
/// In a German interface, the mode tabs and Preferences show German labels, the language
|
||||
/// selector switches to English and back, and the command palette finds Split document by its
|
||||
/// German name, its English name or its command id.
|
||||
#[test]
|
||||
fn german_preferences_and_search_keep_command_ids() {
|
||||
if crate::support::skip_without_catalogs("german_preferences_and_search_keep_command_ids") {
|
||||
@@ -1039,6 +1047,9 @@ fn german_preferences_and_search_keep_command_ids() {
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
|
||||
}
|
||||
|
||||
/// In a Ukrainian interface, Preferences and dialogs show Ukrainian labels, the command palette
|
||||
/// finds Split document by its Ukrainian name, its English name or its command id, and a refused
|
||||
/// edit's notice names the command in Ukrainian.
|
||||
#[test]
|
||||
fn ukrainian_preferences_and_search_keep_command_ids() {
|
||||
if crate::support::skip_without_catalogs("ukrainian_preferences_and_search_keep_command_ids") {
|
||||
@@ -1075,6 +1086,10 @@ fn ukrainian_preferences_and_search_keep_command_ids() {
|
||||
assert_eq!(state["documents"][0]["name"], "doc.pdf");
|
||||
}
|
||||
|
||||
/// In a Japanese interface, Preferences opened from the menu bar switches the language to English,
|
||||
/// Ukrainian and back, and opens again with Cmd+,; the shortcuts list then reads in Japanese and
|
||||
/// the document stays open. Preferences is in Edit on Windows and Linux, and in the app menu (as
|
||||
/// Settings…) on macOS.
|
||||
#[test]
|
||||
fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
|
||||
if crate::support::skip_without_catalogs("preferences_menu_and_shortcut_allow_switching_interface_languages") {
|
||||
@@ -1085,16 +1100,24 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
|
||||
}
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
|
||||
// The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic
|
||||
// ("編集(E)", "環境設定(P)…"), so match by the start of the label.
|
||||
// The menu bar's Edit menu holds Preferences on Windows and Linux. On macOS the in-window bar
|
||||
// takes the Mac layout (`Target::current()`), which moves it to the app menu as Settings….
|
||||
// Titles and rows carry their mnemonic ("編集(E)", "環境設定(P)…"), so match by the start of
|
||||
// the label.
|
||||
let (menu, preferences) = if cfg!(target_os = "macos") {
|
||||
let ja = pdfcraft_ui_egui::i18n::Lang::from_code("ja").unwrap();
|
||||
("PdfCraft", pdfcraft_ui_egui::i18n::tr(ja, "Settings…").trim_end_matches('…'))
|
||||
} else {
|
||||
("編集", "環境設定")
|
||||
};
|
||||
let menu_row = |text: &'static str| {
|
||||
move |n: &egui_kittest::kittest::AccessKitNode<'_>| {
|
||||
n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text))
|
||||
}
|
||||
};
|
||||
h.get_by(menu_row("編集")).click();
|
||||
h.get_by(menu_row(menu)).click();
|
||||
h.run_steps(2);
|
||||
h.get_by(menu_row("環境設定")).click();
|
||||
h.get_by(menu_row(preferences)).click();
|
||||
h.run_steps(2);
|
||||
for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
|
||||
{
|
||||
@@ -2153,6 +2176,47 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() {
|
||||
assert!(h.state().views[0].forward.is_empty());
|
||||
}
|
||||
|
||||
/// CodeRabbit on #5341: the control channel folds a pinned folder on Home down to its heading
|
||||
/// and opens it again, as its chevron does, and `ui.state` says which are folded. Folders that
|
||||
/// aren't pinned, and states other than on and off, are refused with the reason.
|
||||
#[test]
|
||||
fn control_collapses_and_expands_a_pinned_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-fold-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("inside.pdf"), fixture(1)).unwrap();
|
||||
let folder = dir.to_string_lossy().into_owned();
|
||||
let slot: Arc<Mutex<Option<ControlClient>>> = Arc::default();
|
||||
let (s, pin) = (slot.clone(), folder.clone());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(move |cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.run_inline = true;
|
||||
app.pin_folder(&pin);
|
||||
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let c = slot.lock().unwrap().take().unwrap();
|
||||
let folded = |h: &mut Harness<'static, PdfCraftApp>| ok(h, &c, "ui.state", json!({}))["pinned_folders"].clone();
|
||||
assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }]));
|
||||
h.get_by_label("inside.pdf");
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=on") }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }]));
|
||||
assert!(h.query_by_label("inside.pdf").is_none(), "a collapsed folder lists nothing");
|
||||
let unpinned = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": "/not/pinned=on" })).unwrap_err();
|
||||
assert!(unpinned.contains("not a pinned folder"), "{unpinned}");
|
||||
let bad = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=yes") })).unwrap_err();
|
||||
assert!(bad.contains("=on or"), "{bad}");
|
||||
assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }]), "refusals change nothing");
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=off") }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }]));
|
||||
h.get_by_label("inside.pdf");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessible_labels_on_format_text_and_pin_dialog() {
|
||||
let (mut h, c) = harness();
|
||||
|
||||
@@ -140,6 +140,71 @@ fn unpinning_removes_the_folder_but_not_its_files() {
|
||||
assert!(path.exists(), "unpinning never touches the files");
|
||||
}
|
||||
|
||||
/// #5295: a pinned folder folds down to its heading and back, the other folders stay as they
|
||||
/// are, and the folded state is kept across a restart.
|
||||
#[test]
|
||||
fn a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart() {
|
||||
let (first, second) = (TempFolder::new("fold-a"), TempFolder::new("fold-b"));
|
||||
first.file("alpha.pdf", FIXTURE, Duration::from_secs(10));
|
||||
second.file("beta.pdf", FIXTURE, Duration::from_secs(10));
|
||||
let (a, b) = (first.path(), second.path());
|
||||
let pins = (a.clone(), b.clone());
|
||||
let mut h = harness(move |app| {
|
||||
app.pin_folder(&pins.0);
|
||||
app.pin_folder(&pins.1);
|
||||
});
|
||||
h.run_steps(2);
|
||||
let name = pdfcraft_ui_egui::folders_ui::folder_name(&a);
|
||||
h.get_by_label(&format!("Collapse {name}")).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("alpha.pdf").is_none(), "a collapsed folder lists nothing");
|
||||
h.get_by_label("beta.pdf");
|
||||
h.get_by_label(&format!("Unpin {name}"));
|
||||
assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a));
|
||||
|
||||
let mut restarted = PdfCraftApp::new();
|
||||
restarted.restore(&h.state().persist());
|
||||
assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&a));
|
||||
|
||||
h.get_by_label(&format!("Expand {name}")).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("alpha.pdf");
|
||||
assert!(h.state().pinned.collapsed.is_empty());
|
||||
|
||||
// Settings are untrusted: only pinned folders, each once.
|
||||
let settings = serde_json::json!({ "pinned_folders": [a, b], "pinned_folders_collapsed": [b, b, "/not/pinned", 5] });
|
||||
restarted.restore(&settings.to_string());
|
||||
assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&b));
|
||||
restarted.unpin_folder(&b);
|
||||
assert!(restarted.pinned.collapsed.is_empty(), "unpinning forgets the folded state");
|
||||
}
|
||||
|
||||
/// Two pinned folders of the same name: their buttons say where each one is, so a screen reader
|
||||
/// can tell them apart.
|
||||
#[test]
|
||||
fn folders_of_the_same_name_have_buttons_that_tell_them_apart() {
|
||||
let root = TempFolder::new("same-name");
|
||||
let (a, b) = (root.0.join("work").join("Scans"), root.0.join("home").join("Scans"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let (a, b) = (a.to_string_lossy().into_owned(), b.to_string_lossy().into_owned());
|
||||
let pins = (a.clone(), b.clone());
|
||||
let mut h = harness(move |app| {
|
||||
app.pin_folder(&pins.0);
|
||||
app.pin_folder(&pins.1);
|
||||
});
|
||||
h.run_steps(2);
|
||||
for (folder, other) in [(&a, &b), (&b, &a)] {
|
||||
let named = format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(folder));
|
||||
assert_ne!(named, format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(other)));
|
||||
h.get_by_label(&format!("Collapse {named}"));
|
||||
h.get_by_label(&format!("Unpin {named}"));
|
||||
}
|
||||
h.get_by_label(&format!("Collapse Scans ({})", pdfcraft_ui_egui::folders_ui::location(&a))).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a), "the button folds its own folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_folders_survive_a_restart() {
|
||||
let dir = TempFolder::new("restart");
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> 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.
|
||||
/// date reads back from it unchanged (a two-digit year holds only 1930 to 2029), else as ISO.
|
||||
pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String {
|
||||
match data_pattern {
|
||||
Some(p) => {
|
||||
@@ -53,6 +53,11 @@ pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option<Str
|
||||
tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters)
|
||||
}
|
||||
|
||||
/// The century split of a two-digit year (`yy`), XFA picture clauses' default: `00` to `29` are
|
||||
/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference,
|
||||
/// p. 1171, which makes 30 the default and lets the century split be set otherwise).
|
||||
const CENTURY_SPLIT: u32 = 30;
|
||||
|
||||
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"];
|
||||
@@ -160,7 +165,7 @@ pub fn pattern_to_iso(text: &str, pattern: &str) -> Option<String> {
|
||||
rest = &rest[digits.len()..];
|
||||
match t.as_str() {
|
||||
"yyyy" => y = Some(v),
|
||||
"yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }),
|
||||
"yy" => y = Some(if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v }),
|
||||
"mm" | "m" => m = Some(v),
|
||||
_ => d = Some(v),
|
||||
}
|
||||
|
||||
@@ -662,7 +662,9 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subfor
|
||||
}
|
||||
let column_widths: Vec<f64> =
|
||||
n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default();
|
||||
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea"));
|
||||
// A break to a page or content area. With no targetType, the default is `auto`: "layout
|
||||
// continues using the current layout container" (XFA 3.3 p. 638), so no break.
|
||||
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea"));
|
||||
Subform {
|
||||
common,
|
||||
layout: Layout::parse(n.attribute("layout")),
|
||||
|
||||
+81
-3
@@ -1192,19 +1192,34 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate
|
||||
live.children.iter().find_map(|c| node_at(c, som))
|
||||
}
|
||||
|
||||
/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads
|
||||
/// A date its data picture can't hold (2030 in a two-digit year) is written as ISO and reads
|
||||
/// back as written.
|
||||
#[test]
|
||||
fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
|
||||
use crate::data::{data_to_iso, iso_to_data};
|
||||
assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102");
|
||||
// A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written.
|
||||
for iso in ["2050-01-02", "1949-12-31"] {
|
||||
// A two-digit year reads back as 1930 to 2029, so those years are written in it.
|
||||
for (iso, data) in [("2029-12-31", "291231"), ("1930-01-01", "300101"), ("1949-12-31", "491231"), ("1950-01-01", "500101")] {
|
||||
assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), data, "{iso}");
|
||||
}
|
||||
// 2030 and 1929 keep ISO, and read back as written.
|
||||
for iso in ["2030-01-01", "1929-12-31", "2050-01-02"] {
|
||||
assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso);
|
||||
assert_eq!(data_to_iso(iso, Some("yymmdd")), iso);
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-digit year splits its century at 30, the XFA picture clause default: `00` to `29` are
|
||||
/// 2000 to 2029 and `30` to `99` are 1930 to 1999, typed and in the data alike.
|
||||
#[test]
|
||||
fn a_two_digit_year_splits_its_century_at_30() {
|
||||
use crate::data::data_to_iso;
|
||||
for (text, iso) in [("291231", "2029-12-31"), ("300101", "1930-01-01"), ("491231", "1949-12-31"), ("500101", "1950-01-01")] {
|
||||
assert_eq!(pattern_to_iso(text, "yymmdd").as_deref(), Some(iso), "{text}");
|
||||
assert_eq!(data_to_iso(text, Some("yymmdd")), iso, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The datasets fill the fields and add the rows of repeating subforms.
|
||||
#[test]
|
||||
fn month_and_weekday_names_in_date_patterns() {
|
||||
@@ -4221,6 +4236,8 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep
|
||||
let short = "form[0].short[0]";
|
||||
assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps");
|
||||
assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001");
|
||||
assert_eq!(typed(short, "19451018").as_deref(), Some("451018"), "45 reads back as 1945");
|
||||
assert_eq!(typed(short, "20301018"), None, "2030 as 30 would read back as 1930");
|
||||
// DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03.
|
||||
assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps");
|
||||
}
|
||||
@@ -4508,6 +4525,67 @@ fn clear_password_values_clears_static_xfa_password_fields() {
|
||||
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp);
|
||||
}
|
||||
|
||||
/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last`
|
||||
/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the
|
||||
/// page of `after` shows whether it broke.
|
||||
fn pages_around_break(before: &str, after: &str) -> (Option<usize>, Option<usize>) {
|
||||
let xml = format!(
|
||||
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="first" w="2in" h="0.3in"/><subform name="last" layout="tb">{before}<field name="f" w="2in" h="0.3in"/>{after}</subform><field name="after" w="2in" h="0.3in"/>
|
||||
</subform></template>"#
|
||||
);
|
||||
let t = crate::parse(&xml).unwrap().0;
|
||||
let form = crate::layout::layout(&t, None).unwrap();
|
||||
let page_of = |som: &str| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == som));
|
||||
(page_of("form[0].last[0].f[0]"), page_of("form[0].after[0]"))
|
||||
}
|
||||
|
||||
/// A `<breakBefore/>` without a targetType breaks nothing: its default is `auto`, layout goes on
|
||||
/// in the current container (XFA 3.3 p. 638), as a public form's last section expects. Written
|
||||
/// out as `auto` it breaks nothing either. One to a page area, or a content area, still starts a
|
||||
/// new page; so does a `breakAfter` to one.
|
||||
#[test]
|
||||
fn a_break_before_without_a_target_type_does_not_start_a_page() {
|
||||
let page_before = |brk: &str| pages_around_break(brk, "").0;
|
||||
assert_eq!(page_before("<breakBefore/>"), Some(0), "auto: no break");
|
||||
assert_eq!(page_before(r#"<breakBefore targetType="auto"/>"#), Some(0));
|
||||
assert_eq!(page_before(r#"<breakBefore targetType="pageArea"/>"#), Some(1));
|
||||
assert_eq!(page_before(r#"<breakBefore targetType="contentArea"/>"#), Some(1));
|
||||
let page_after = |brk: &str| pages_around_break("", brk).1;
|
||||
assert_eq!(page_after("<breakAfter/>"), Some(0), "auto: no break");
|
||||
assert_eq!(page_after(r#"<breakAfter targetType="auto"/>"#), Some(0));
|
||||
assert_eq!(page_after(r#"<breakAfter targetType="pageArea"/>"#), Some(1));
|
||||
assert_eq!(page_after(r#"<breakAfter targetType="contentArea"/>"#), Some(1));
|
||||
}
|
||||
|
||||
/// targetType is one of `auto`, `contentArea` and `pageArea` (XFA 3.3 pp. 636-638). An empty or
|
||||
/// unknown value, in either direction, names neither a page nor a content area, so it breaks
|
||||
/// nothing; the names are case-sensitive.
|
||||
#[test]
|
||||
fn a_break_with_an_empty_or_unknown_target_type_does_not_start_a_page() {
|
||||
for value in ["", " ", "page", "pagearea", "PageArea", "next"] {
|
||||
let before = format!(r#"<breakBefore targetType="{value}"/>"#);
|
||||
assert_eq!(pages_around_break(&before, "").0, Some(0), "breakBefore targetType={value:?}");
|
||||
let after = format!(r#"<breakAfter targetType="{value}"/>"#);
|
||||
assert_eq!(pages_around_break("", &after).1, Some(0), "breakAfter targetType={value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The deprecated `<break before="...">` form (XFA 3.3 pp. 633-634): `auto`, its default, forces
|
||||
/// no transition; `pageArea` and `contentArea` start a new page; an empty or unknown value breaks
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn the_deprecated_break_before_form_breaks_only_to_a_page_or_content_area() {
|
||||
let page = |brk: &str| pages_around_break(brk, "").0;
|
||||
assert_eq!(page("<break/>"), Some(0), "auto: no break");
|
||||
assert_eq!(page(r#"<break before="auto"/>"#), Some(0));
|
||||
assert_eq!(page(r#"<break before=""/>"#), Some(0));
|
||||
assert_eq!(page(r#"<break before="next"/>"#), Some(0));
|
||||
assert_eq!(page(r#"<break before="pageArea"/>"#), Some(1));
|
||||
assert_eq!(page(r#"<break before="contentArea"/>"#), Some(1));
|
||||
}
|
||||
|
||||
/// A form with `draws` in a subform placed by position, on a letter page whose content area is a
|
||||
/// quarter inch in, with `master` on the page itself.
|
||||
fn form_of_draws(master: &str, draws: &str) -> String {
|
||||
|
||||
@@ -1706,8 +1706,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["file.pin_folder"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart"]
|
||||
notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build."
|
||||
evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart", "crates/ui-egui/tests/it/control.rs::control_collapses_and_expands_a_pinned_folder"]
|
||||
notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder collapses to its heading, and stays collapsed across restarts. Pinned folders are Home-view settings, not a document's, so the engine tools have none; the control channel folds them (`ui.set folder-collapsed <folder>=on|off`) and `ui.state` lists them. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build."
|
||||
|
||||
[[feature]]
|
||||
id = "view.all-tools"
|
||||
@@ -5459,7 +5459,7 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "form_fill", "js_run"]
|
||||
evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"]
|
||||
evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/js/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved", "crates/js/src/tests.rs::formcalc_message_box_keeps_its_title_icon_and_buttons"]
|
||||
notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures. $host.messageBox keeps its title, icon and buttons; it answers OK (1) for now (#4291)."
|
||||
|
||||
[[feature]]
|
||||
@@ -5471,8 +5471,8 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
|
||||
tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (`<bind><picture>`, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (`<bind><picture>`, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2030 in a two-digit year, which holds 1930 to 2029 as XFA pictures split the century at 30), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-flatten"
|
||||
|
||||
Reference in new issue
Block a user