diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3c1100d312..5d220d5592 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6844,6 +6844,142 @@ fn reading_follows_forms_however_their_resources_are_stored() { } } +/// #3503: a form XObject's `/BBox` clips its content, so text drawn outside it is not shown and +/// should not be read either -- matching the renderer, which already honours the clip. +#[test] +fn form_bbox_clips_text_from_reading() { + let page = stream("", "/Fm Do"); + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 50 50] /Resources << /Font << /F1 6 0 R >> >>", + "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Fm 5 0 R >> >> /Contents 4 0 R >>", + &page, + &fm, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE"]); +} + +/// #3503: a page's own `re`/`W`/`n` clip (not a form's `/BBox`) hides text outside it the same +/// way, and `Q` lifts it again -- the clip is scoped to its `q`/`Q` like the rest of the graphics +/// state, not a one-way switch. +#[test] +fn page_re_w_n_clip_hides_text_from_reading_until_q() { + let page = stream( + "", + "q 0 0 50 50 re W n BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 100 100 Td (AFTER) Tj ET", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE", "AFTER"]); +} + +/// The text `reading_blocks` reads on a 400 × 400 pt page drawing `content` with Helvetica as +/// `/F1`, and forms `/Fa` and `/Fb` (objects 6 and 7) with the given dictionary entries and content. +fn read_clipped(content: &str, forms: [(&str, &str); 2]) -> Vec { + let font = "/Font << /F1 5 0 R >>"; + let resources = format!("<< {font} /XObject << /Fa 6 0 R /Fb 7 0 R >> >>"); + let form = |(dict, body): (&str, &str)| stream(&format!("/Type /XObject /Subtype /Form {dict} /Resources {resources}"), body); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources {resources} /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + &form(forms[0]), + &form(forms[1]), + ]); + text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +/// #5075 review: a `/BBox` with anything but four numbers is no bound. Reading the numbers it +/// happens to hold (`[0 /Bad 0 50 50]` as `[0 0 50 50]`) invented a clip and hid real text. +#[test] +fn a_malformed_form_bbox_clips_nothing() { + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET"; + for bbox in ["[0 /Bad 0 50 50]", "[0 0 50]", "[0 0 50 50 60]", "[0 (x) 50 50]"] { + let texts = read_clipped("/Fa Do", [(&format!("/BBox {bbox}"), body), ("", "")]); + assert_eq!(texts, ["INSIDE", "OUTSIDE"], "/BBox {bbox}"); + } +} + +/// #5075 review: a clipping path of several rectangles clips to all of them, not to the last one +/// drawn (text seen through the first rectangle was hidden), and not to the box around them +/// either (text in the gap between them was read, though nothing of it shows). +#[test] +fn a_clip_of_several_rectangles_keeps_text_in_each() { + let content = "q 0 0 50 50 re 100 100 50 50 re W n BT /F1 12 Tf 5 5 Td (FIRST) Tj ET BT /F1 12 Tf 105 105 Td (SECOND) Tj ET \ + BT /F1 12 Tf 60 60 Td (GAP) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET Q"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["FIRST", "SECOND"]); +} + +/// #5075 review: a form's `/BBox` clips however thin it is and wherever its matrix puts it. A box +/// one point by 1e-10 was taken for no box (its area fell under a fixed threshold), and one moved +/// 1e12 points away lost its area to rounding; either way the text outside it was read. +#[test] +fn a_thin_or_far_off_form_bbox_still_clips() { + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET"; + let sorted = |mut v: Vec| { + v.sort(); + v + }; + let thin = read_clipped(&format!("/Fa Do {after}"), [("/BBox [0 0 1 1e-10]", body), ("", "")]); + assert_eq!(thin, ["AFTER"], "a thin /BBox"); + let far = read_clipped(&format!("/Fa Do {after}"), [("/Matrix [1 0 0 1 1e12 1e12] /BBox [0 0 100 100]", body), ("", "")]); + assert_eq!(sorted(far), ["AFTER", "INSIDE"], "a /BBox 1e12 points away"); +} + +/// #5075 review: a clip of no area has no inside, so nothing drawn under it shows (ISO 32000-2 +/// §8.5.4). It was taken for no clip at all, and everything under it was read. +#[test] +fn a_clip_of_no_area_shows_nothing() { + let content = "q 0 0 100 0 re W n BT /F1 12 Tf 5 5 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["AFTER"]); +} + +/// #5075 review: what shows is the intersection of every clip in force. A long run that crosses a +/// page clip and, apart from it, a form's `/BBox` overlaps each of them but shows in neither, as it +/// does not when one form's `/BBox` lies apart from the one of the form it is drawn in. +#[test] +fn disjoint_clips_show_nothing_between_them() { + let run = "BT /F1 12 Tf 5 10 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET"; + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + // A page clip and a form's `/BBox` side by side. + let page_clip = format!("q 0 0 50 50 re W n /Fa Do Q {after}"); + assert_eq!(read_clipped(&page_clip, [("/BBox [100 0 150 50]", run), ("", "")]), ["AFTER"]); + // A form's `/BBox` and the `/BBox` of the form inside it, side by side. + let nested = format!("/Fa Do {after}"); + assert_eq!(read_clipped(&nested, [("/BBox [0 0 50 50]", "/Fb Do"), ("/BBox [100 0 150 50]", run)]), ["AFTER"]); +} + +/// #5075 review: a clip turned with its matrix keeps its turned shape. Its upright bounding box +/// takes in corners it does not cover, and text there was read although it is clipped away. +#[test] +fn a_turned_clip_hides_text_in_the_corners_of_its_bounding_box() { + // A 100 pt square turned 45° about (200, 0): a diamond with its lower corner at (200, 0). + // Undone straight after the clip, so the text is drawn upright on the page. + let (turn, undo) = + ("0.70710678 0.70710678 -0.70710678 0.70710678 200 0 cm", "0.70710678 -0.70710678 0.70710678 0.70710678 -141.421356 141.421356 cm"); + let texts = "BT /F1 6 Tf 132 3 Td (CORNER) Tj ET BT /F1 6 Tf 195 65 Td (CENTRE) Tj ET"; + let page_clip = format!("q {turn} 0 0 100 100 re W n {undo} {texts} Q"); + assert_eq!(read_clipped(&page_clip, [("", ""), ("", "")]), ["CENTRE"], "a turned page clip"); + // The same diamond as a form's `/BBox`, under its `/Matrix`. + let matrix = "/Matrix [0.70710678 0.70710678 -0.70710678 0.70710678 200 0] /BBox [0 0 100 100]"; + assert_eq!(read_clipped("/Fa Do", [(matrix, &format!("{undo} {texts}")), ("", "")]), ["CENTRE"], "a turned form /BBox"); +} + /// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but /// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. #[test] @@ -6958,24 +7094,28 @@ fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. +/// +/// `/BBox [0 0 20 420]` is wide enough to hold every form's own text below (#3503 clips a form's +/// content to its `/BBox`; this test is about the loop and resource handling, not clipping). #[test] fn hostile_form_xobjects_are_read_once() { let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do"); let font = "/Font << /F1 5 0 R >>"; let lp = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), "/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET", ); let a = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /B 8 0 R >> >>"), "/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET", ); let b = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /A 7 0 R >> >>"), "/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET", ); - let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); - let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); + let nores = stream("/Subtype /Form /BBox [0 0 20 420]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); + let bad = + stream(&format!("/Subtype /Form /BBox [0 0 20 420] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); let doc = build(&[ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..ada90d8484 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -155,6 +155,66 @@ impl TextLine { mod tests { use super::*; + /// The clip regions reading uses (#3503): intersected regions keep only what both cover, + /// regions apart leave nothing, turned ones keep their shape, and touching is not showing. + #[test] + fn clip_regions_intersect_and_show_what_they_cover() { + use interp::{intersect, region, shows}; + let square = |x: f64, y: f64| region(&Matrix::IDENTITY, [x, y, x + 10.0, y + 10.0]).unwrap(); + let area = |p: &[[f64; 2]]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum::().abs() / 2.0; + // Squares overlapping by half each way share a quarter. + let both = intersect(&square(0.0, 0.0), &square(5.0, 5.0)); + assert!((area(&both) - 25.0).abs() < 1e-9, "{both:?}"); + assert!(shows([6.0, 6.0, 7.0, 7.0], &both) && !shows([1.0, 1.0, 2.0, 2.0], &both)); + // Squares apart share nothing, so nothing shows, not even a box across both. + let none = intersect(&square(0.0, 0.0), &square(20.0, 0.0)); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &none), "{none:?}"); + // Squares that meet only along an edge share no area either: a box across that edge + // does not show (#5075 review). + let edge = intersect(&square(0.0, 0.0), &square(10.0, 0.0)); + assert!(!shows([8.0, 2.0, 12.0, 4.0], &edge), "{edge:?}"); + // Nothing intersected with anything is nothing, whichever side it is on. + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&square(0.0, 0.0), &Vec::new()))); + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&Vec::new(), &square(0.0, 0.0)))); + // A box touching an edge shares no area; one a hair inside does. + assert!(!shows([10.0, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + assert!(shows([9.99, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + // A square turned 45° keeps its diamond shape: a box in the corner of its bounding box is + // outside it, the centre is inside. + let c = std::f64::consts::FRAC_1_SQRT_2; + let diamond = region(&Matrix([c, c, -c, c, 0.0, 0.0]), [0.0, 0.0, 10.0, 10.0]).unwrap(); + assert!(!shows([-7.0, 0.0, -6.5, 0.5], &diamond) && shows([-0.5, 6.5, 0.5, 7.5], &diamond)); + // A corner repeated (an edge of no length) does not hide what the region covers. + let doubled = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!(shows([4.0, 4.0, 6.0, 6.0], &doubled)); + // A rectangle of no area is a region that covers nothing; a corner that isn't finite + // gives no region to clip with at all. + let flat = region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).unwrap(); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &flat), "{flat:?}"); + assert!(region(&Matrix::IDENTITY, [0.0, 0.0, f64::INFINITY, 10.0]).is_none()); + // However thin, a rectangle with area keeps it, and so does one far from the origin. + let thin = region(&Matrix::IDENTITY, [0.0, 0.0, 1.0, 1e-10]).unwrap(); + assert!(shows([0.25, 0.0, 0.75, 1.0], &thin) && !shows([0.25, 1.0, 0.75, 2.0], &thin)); + let far = region(&Matrix([1.0, 0.0, 0.0, 1.0, 1e12, 1e12]), [0.0, 0.0, 100.0, 100.0]).unwrap(); + assert!(shows([1e12 + 10.0, 1e12 + 10.0, 1e12 + 20.0, 1e12 + 20.0], &far) && !shows([1e12 + 200.0, 1e12, 1e12 + 300.0, 1e12 + 10.0], &far)); + } + + /// A stream that sets clip after clip, each turned a little more, keeps its region bounded: + /// past the corner cap a region becomes its bounding box, which still holds all it held. + #[test] + fn clip_after_clip_keeps_the_region_bounded() { + use interp::{intersect, region, shows}; + let mut clip = region(&Matrix::IDENTITY, [-100.0, -100.0, 100.0, 100.0]).unwrap(); + for i in 1..=10_000 { + let (s, c) = (f64::from(i) * 0.001).sin_cos(); + let turned = region(&Matrix([c, s, -s, c, 0.0, 0.0]), [-60.0, -60.0, 60.0, 60.0]).unwrap(); + clip = intersect(&clip, &turned); + assert!(clip.len() <= 32 && clip.iter().flatten().all(|v| v.is_finite()), "clip {i}: {} corners", clip.len()); + } + // Every square is centred on the origin, so the middle still shows. + assert!(shows([-1.0, -1.0, 1.0, 1.0], &clip)); + } + fn block(text: &str, rect: [f64; 4]) -> TextBlock { TextBlock { text: text.into(), diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 6fb50138ab..0bc9373899 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -218,9 +218,139 @@ pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] } +/// A convex part of the page, in page space: the corners of a convex polygon, in order. With fewer +/// than three corners it covers nothing. +pub(super) type Region = Vec<[f64; 2]>; + +/// A clip, as the union of the convex regions it leaves visible: one for each rectangle of its +/// path. With no parts it leaves nothing visible. +pub(super) type Clip = Vec; + +/// The most corners a clip region keeps. Each intersection with a four-cornered clip can add +/// corners, so past this a region becomes its bounding box: still all it held, and a stream that +/// sets clip after clip stays bounded. +const MAX_REGION_CORNERS: usize = 32; + +/// The most parts a clip keeps. Clips intersect part by part, so past this its parts become one, +/// the box around them: still all they held, and the work stays bounded. +const MAX_CLIP_PARTS: usize = 16; + +/// `r` (`[x0, y0, x1, y1]`, either way round) under `m`, keeping its shape when `m` turns or +/// skews it. A rectangle of no area is a region that covers nothing, however thin a rectangle +/// with area is; `None` when a corner, or its measure, is not finite. +pub(super) fn region(m: &Matrix, r: [f64; 4]) -> Option { + let corners: Region = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])] + .into_iter() + .map(|(x, y)| { + let (x, y) = m.apply(x, y); + [x, y] + }) + .collect(); + let finite = corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).is_finite() && perimeter(&corners).is_finite(); + finite.then(|| if encloses_area(&corners) { corners } else { Vec::new() }) +} + +/// The polygon's signed area, doubled: positive when its corners run anticlockwise (y up). +/// Measured from its first corner, so that far from the origin it keeps its precision. +fn twice_area(p: &[[f64; 2]]) -> f64 { + let Some(o) = p.first() else { return 0.0 }; + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (a[0] - o[0]) * (b[1] - o[1]) - (b[0] - o[0]) * (a[1] - o[1])).sum() +} + +fn perimeter(p: &[[f64; 2]]) -> f64 { + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum() +} + +/// Whether the polygon encloses any area: its area against the square of its perimeter, so the +/// answer is the same at any scale and a thin box still has one. +fn encloses_area(p: &[[f64; 2]]) -> bool { + let around = perimeter(p); + p.len() >= 3 && twice_area(p).abs() > 1e-12 * around * around +} + +/// The four corners of the box around `points`. +fn bounding<'a>(points: impl Iterator) -> Region { + let b = points.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); + vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// The part of `a` inside `b`, both convex (Sutherland-Hodgman: `a` cut by each edge of `b`). Clips +/// in force intersect, so text shows only where all of them let it through. Regions that share no +/// area (apart, or meeting only along an edge) leave an empty one, as does an empty `a` or `b`. +pub(super) fn intersect(a: &Region, b: &Region) -> Region { + if a.len() < 3 || b.len() < 3 { + return Vec::new(); + } + let turn = twice_area(b).signum(); + let mut out = a.clone(); + for (p, q) in b.iter().zip(b.iter().cycle().skip(1)) { + // Positive on the side of the edge `p` → `q` that `b` lies on. + let inside = |v: &[f64; 2]| turn * ((q[0] - p[0]) * (v[1] - p[1]) - (q[1] - p[1]) * (v[0] - p[0])); + let cut = std::mem::take(&mut out); + for (s, e) in cut.iter().zip(cut.iter().cycle().skip(1)) { + let (ds, de) = (inside(s), inside(e)); + if ds >= 0.0 { + out.push(*s); + } + // The edge crosses the line: `ds - de` is not zero, as one is negative and one is not. + if (ds >= 0.0) != (de >= 0.0) { + let t = ds / (ds - de); + out.push([s[0] + t * (e[0] - s[0]), s[1] + t * (e[1] - s[1])]); + } + } + } + if !encloses_area(&out) { + return Vec::new(); + } + if out.len() > MAX_REGION_CORNERS { + out = bounding(out.iter()); + } + out +} + +/// The clip that leaves visible only what both `a` and `b` do: each part of one intersected with +/// each part of the other. +pub(super) fn clip_within(a: &Clip, b: &Clip) -> Clip { + let mut out: Clip = a.iter().flat_map(|p| b.iter().map(move |q| intersect(p, q))).filter(|r| !r.is_empty()).collect(); + if out.len() > MAX_CLIP_PARTS { + out = vec![bounding(out.iter().flatten())]; + } + out +} + +/// Whether the box `r` shows through `clip`: through any of its parts. +pub(super) fn clip_shows(r: [f64; 4], clip: &Clip) -> bool { + clip.iter().any(|part| shows(r, part)) +} + +/// Whether the box `r` (`[x0, y0, x1, y1]`) and `region` share some area, by the separating axis +/// test: two convex shapes are apart exactly when their shadows on some axis, one of the box's or +/// a normal of one of the region's edges, do not overlap. Touching is not sharing. +pub(super) fn shows(r: [f64; 4], region: &Region) -> bool { + if region.len() < 3 { + return false; + } + let corners = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]; + let apart = |axis: [f64; 2]| { + let shadow = |points: &[[f64; 2]]| { + points.iter().map(|p| p[0] * axis[0] + p[1] * axis[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + let ((a0, a1), (b0, b1)) = (shadow(&corners), shadow(region)); + a1 <= b0 || b1 <= a0 + }; + // An edge of no length (two corners in one place) has no normal to test. + let normals = region.iter().zip(region.iter().cycle().skip(1)).map(|(p, q)| [p[1] - q[1], q[0] - p[0]]).filter(|n| n[0] != 0.0 || n[1] != 0.0); + !([[1.0, 0.0], [0.0, 1.0]].into_iter().chain(normals).any(apart)) +} + #[derive(Clone)] pub(super) struct Ts { pub(super) ctm: Matrix, + /// The clip in force, if any, as what it leaves visible: the intersection of every + /// rectangular content-stream clip (`re` `W`/`W*` `n`, ISO 32000-2 §8.5.4) and every enclosing + /// form's `/BBox` ([`super::lines`]), each in the shape its matrix gives it. `q`/`Q` save and + /// restore it along with the rest of `Ts`. + pub(super) clip: Option, /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). pub(super) fill: Vec, pub(super) font: Option<(Vec, Rc)>, @@ -506,6 +636,7 @@ impl Carry { pub(super) fn new() -> Self { let ts = Ts { ctm: Matrix::IDENTITY, + clip: None, fill: Vec::new(), font: None, face: None, @@ -860,11 +991,57 @@ pub(super) fn interpret( // with no properties dict (a file saved before this was tracked, or by another writer) reads // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; + // A pending path of `x y w h re`s, and whether a `W`/`W*` has marked it as the path that `n` + // turns into the new clip (ISO 32000-2 §8.5.4) -- the common "set a rectangular clip" idiom. + // The path is `Some(None)` once one of its rectangles can't be read, and then clips nothing. + // Each rectangle is a part of the clip; a path of more than `MAX_CLIP_PARTS` keeps the box + // around them, which still holds them all. Any other operator between `re` and `n` ends the + // idiom: the clip is not read then. + let mut pending_clip: Option>> = None; + let mut clip_pending = false; for (i, op) in ops.iter().enumerate() { // A gap's space carries only into a `Tj` or `TJ` right after it. if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { carry.gap_space = false; } + match op.op.as_slice() { + b"re" => { + let rect = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); + pending_clip = Some(match (pending_clip.take(), rect) { + (None, Some(rect)) => Some(vec![rect]), + (Some(Some(mut path)), Some(rect)) => { + if path.len() >= MAX_CLIP_PARTS { + path = vec![path.iter().fold(rect, |all, r| union(all, *r))]; + } else { + path.push(rect); + } + Some(path) + } + _ => None, + }); + } + b"W" | b"W*" => clip_pending = pending_clip.is_some(), + b"n" => { + if clip_pending && let Some(Some(path)) = pending_clip.take() { + // A rectangle past what a matrix can hold leaves the clip unread. One of no + // area is a part that shows nothing, so a clip of no area hides everything. + let parts: Option = path.iter().map(|r| region(&ts.ctm, *r)).collect(); + if let Some(parts) = parts { + let new: Clip = parts.into_iter().filter(|part| !part.is_empty()).collect(); + ts.clip = Some(match &ts.clip { + Some(old) => clip_within(old, &new), + None => new, + }); + } + } + pending_clip = None; + clip_pending = false; + } + _ => { + pending_clip = None; + clip_pending = false; + } + } match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 1e899bc352..9eadb65107 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::fallback::{same_font, same_paint}; -use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; +use super::interp::{Carry, Clip, Origin, Pen, Shown, TextState, Ts, clip_shows, clip_within, direction, interpret, region, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { @@ -565,8 +565,9 @@ impl FormBudget { } } -/// A form XObject a page draws: its object, content, resources and its matrix composed with the -/// CTM at the `Do`. +/// A form XObject a page draws: its object, content, resources, its matrix composed with the +/// CTM at the `Do`, and the clip its `/BBox` makes (page space, under that same matrix, so a +/// turned form keeps its turned box; a box of no area hides everything) if it has a valid one. pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -575,6 +576,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, + bbox: Option, } impl FormCall { @@ -644,6 +646,12 @@ pub(crate) fn form_call( <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) }); let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r)); + // Four finite numbers, or no bound at all: a member that isn't one makes the box unknown, and + // reading the numbers around it would invent a clip. + let bbox = s.dict.get(b"BBox").map(|b| doc.resolve(b)).and_then(|b| { + let values: Vec = b.as_array()?.iter().map(|x| doc.resolve(x).as_f64().filter(|v| v.is_finite())).collect::>()?; + <[f64; 4]>::try_from(values).ok() + }); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -653,7 +661,8 @@ pub(crate) fn form_call( Err(_) => Vec::new(), }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) + let ctm = m.map_or(ctm, |m| Matrix(m).then(&ctm)); + Some(FormCall { obj, data, form, own, ctm, bbox: bbox.and_then(|b| region(&ctm, b)).map(|r| if r.is_empty() { Vec::new() } else { vec![r] }) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -684,6 +693,12 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Some(clip_within(outer, own)), + (outer, own) => outer.clone().or_else(|| own.clone()), + }; let mut carry = Carry { - ts: Ts { ctm: form.ctm, ..ts }, + ts: Ts { ctm: form.ctm, clip, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(),