From 41412fb9836fb137831424b0954e0d4c35ac11fe Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 28 Oct 2026 07:48:28 +0800 Subject: [PATCH 1/4] fix(edit): Office export respects a form XObject's own /BBox clip Export to Word/HTML/PowerPoint/RTF reads a page's text through reading_lines/reading_blocks, which walks form XObjects without any notion of a clip. So text a form's /BBox clips away -- never drawn -- was still read and exported, while Find, text extraction and the renderer already honour it. Give reading_lines a stack of the active forms' /BBox (page space, under each form's own matrix): a run whose box falls outside any of them is not added to the page's lines. Nested forms each check their own box independently, which is equivalent to intersecting them for deciding whether a run is outside at least one. description both say Office export is separate. This covers only a form's own /BBox, not arbitrary re W n page clips or curved clips, matching the scope #3628 itself drew for the renderer's own extraction device. hostile_form_xobjects_are_read_once used a 9x9 placeholder /BBox while drawing its own test text far outside it; the fix correctly clips that away. Widened the box to one that holds the test's text -- the test is about loop and resource handling, not clipping. Co-Authored-By: Claude Sonnet 5 --- crates/edit/src/tests.rs | 35 ++++++++++++++++++++++++++++++----- crates/edit/src/text/lines.rs | 34 +++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 22fbd6e8ae..d1d77059c0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5638,6 +5638,27 @@ 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"]); +} + /// 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] @@ -5752,24 +5773,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/lines.rs b/crates/edit/src/text/lines.rs index 85209a5472..7b80953772 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -3,6 +3,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::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; +use pdfcraft_content::overlaps; impl TextLine { /// The baseline's height in user space. @@ -449,8 +450,8 @@ 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 its `/BBox` (page space, under that same matrix) if it has a valid one. pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -459,6 +460,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, + pub(crate) bbox: Option<[f64; 4]>, } impl FormCall { @@ -528,6 +530,11 @@ 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)); + let bbox = s.dict.get(b"BBox").map(|b| doc.resolve(b)).and_then(|b| { + let a = b.as_array()?; + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 4]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) + }); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -537,7 +544,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.map(|b| ctm.bbox(b)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -551,6 +559,10 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, budget: FormBudget, + /// Active forms' `/BBox`, page space: a run drawn outside any of these is not shown + /// (#3503). Each is checked on its own, not intersected, which is equivalent for + /// deciding whether a run is outside at least one of them. + clip: Vec<[f64; 4]>, } /// `stream` gives the stream number of the operator at an index of `ops`. fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> { @@ -568,6 +580,11 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Result<(Vec Date: Fri, 30 Oct 2026 15:01:59 +0800 Subject: [PATCH 2/4] fix(edit): Office export also respects a page's own re/W/n clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the form-/BBox clip from the previous commit: a page (or a form) can clip with a plain content-stream re/W/n too (ISO 32000-2 §8.5.4), not only through a form's /BBox. The renderer already honours both; Office export honoured neither until the previous commit, and still missed this one. Ts gains a clip field (the active rectangular clip, page space), tracked by the existing q/Q save/restore (Ts already saves and restores through there, so the clip needs no extra plumbing for that). reading_lines recognizes the re ... W/W* ... n idiom and narrows the clip when it completes; a form drawn inside an active clip inherits it, on top of its own /BBox. text_lines/text_blocks (the interactive editor) call the same interpret() but consume its Shown records through their own separate loop in text_lines_in, which never looks at state.clip -- this stays scoped to reading_lines/reading_blocks (Office export, and whatever else reads a page the way a reader sees it), not editing. Co-Authored-By: Claude Sonnet 5 --- crates/edit/src/tests.rs | 20 ++++++++++++++++++++ crates/edit/src/text/interp.rs | 33 +++++++++++++++++++++++++++++++++ crates/edit/src/text/lines.rs | 6 ++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d1d77059c0..a5bd4c6eb9 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5659,6 +5659,26 @@ fn form_bbox_clips_text_from_reading() { 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"]); +} + /// 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] diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 5114a76263..70992b6d6e 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -218,9 +218,20 @@ 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` narrowed to its overlap with `b` (nested clips intersect). A pair that shares no area comes +/// back inverted (`x0 > x1` or `y0 > y1`), which fails every later overlap test on its own -- +/// nothing more needs to notice that the clip is now empty. +fn narrow(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] +} + #[derive(Clone)] pub(super) struct Ts { pub(super) ctm: Matrix, + /// The page-space bounding box of the rectangular clip in force, if any (`re` `W`/`W*` `n`; + /// ISO 32000-2 §8.5.4). `q`/`Q` save and restore it along with the rest of `Ts`. A form drawn + /// inside it inherits it, narrowed further by the form's own `/BBox` ([`super::lines`]). + pub(super) clip: Option<[f64; 4]>, /// 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)>, @@ -503,6 +514,7 @@ impl Carry { pub(super) fn new() -> Self { let ts = Ts { ctm: Matrix::IDENTITY, + clip: None, fill: Vec::new(), font: None, face: None, @@ -842,7 +854,28 @@ 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 `x y w h re`, 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. Several + // `re`s before `W n` keep only the last; any other operator between `re` and `n` drops it. + let mut pending_clip: Option<[f64; 4]> = None; + let mut clip_pending = false; for (i, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"re" => pending_clip = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]), + b"W" | b"W*" => clip_pending = pending_clip.is_some(), + b"n" => { + if clip_pending && let Some(r) = pending_clip { + let page_rect = ts.ctm.bbox(r); + ts.clip = Some(ts.clip.map_or(page_rect, |c| narrow(c, page_rect))); + } + 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 7b80953772..21009124a8 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -580,8 +580,10 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Date: Tue, 3 Nov 2026 13:33:24 +0800 Subject: [PATCH 3/4] fix(edit): read clips as the regions they leave, intersected (#3503 review) Review of #5075, four findings, each with a test seen failing first: - A /BBox with anything but four numbers is no bound. filter_map read [0 /Bad 0 50 50] as [0 0 50 50], invented a clip and hid real text. - A clipping path of several rectangles clipped to the last one only, hiding text seen through the others. It now clips to the box around them all, which holds every one of them, so nothing they show is lost; a path with an unreadable rectangle clips nothing. - Clips in force were each tested on their own, so a run crossing two clips that lie apart (a page clip and a form's /BBox, or nested forms' /BBoxes) overlapped each and was read though nothing of it shows. Clips now intersect. - A turned clip was reduced to its upright bounding box, whose corners it does not cover, so text there was read. A clip is now the convex region the rectangle's corners make under its matrix. So the graphics state's clip becomes a convex Region (page space). A form's /BBox is intersected into the clip it inherits at its Do (its content starts with a stack of its own, so it cannot lift it), which replaces the separate stack of form boxes. Regions intersect by Sutherland-Hodgman and a run shows when the separating-axis test finds no gap; a region past 32 corners becomes its bounding box, so a stream that sets clip after clip stays bounded. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 69 +++++++++++++++++++ crates/edit/src/text.rs | 46 +++++++++++++ crates/edit/src/text/interp.rs | 119 ++++++++++++++++++++++++++++----- crates/edit/src/text/lines.rs | 48 ++++++------- 4 files changed, 239 insertions(+), 43 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a5bd4c6eb9..eb8cf31707 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5679,6 +5679,75 @@ fn page_re_w_n_clip_hides_text_from_reading_until_q() { 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. +#[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 300 300 Td (OUTSIDE) Tj ET Q"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["FIRST", "SECOND"]); +} + +/// #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] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3d1626a576..e18459862d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -151,6 +151,52 @@ 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:?}"); + // 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)); + // No area, or a corner that isn't finite: no region to clip with. + assert!(region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).is_none()); + assert!(region(&Matrix::IDENTITY, [0.0, 0.0, f64::INFINITY, 10.0]).is_none()); + } + + /// 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 70992b6d6e..a5a1d8fcde 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -218,20 +218,90 @@ 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` narrowed to its overlap with `b` (nested clips intersect). A pair that shares no area comes -/// back inverted (`x0 > x1` or `y0 > y1`), which fails every later overlap test on its own -- -/// nothing more needs to notice that the clip is now empty. -fn narrow(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(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]>; + +/// 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; + +/// `r` (`[x0, y0, x1, y1]`, either way round) under `m`, keeping its shape when `m` turns or +/// skews it; `None` when it has no area to clip with or a corner 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(); + (corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).abs() > 1e-9).then_some(corners) +} + +/// The polygon's signed area, doubled: positive when its corners run anticlockwise (y up). +fn twice_area(p: &[[f64; 2]]) -> f64 { + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum() +} + +/// 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 leave an empty one. +pub(super) fn intersect(a: &Region, b: &Region) -> Region { + 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 out.len() > MAX_REGION_CORNERS { + let b = out.iter().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])]); + out = vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + } + out +} + +/// 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 page-space bounding box of the rectangular clip in force, if any (`re` `W`/`W*` `n`; - /// ISO 32000-2 §8.5.4). `q`/`Q` save and restore it along with the rest of `Ts`. A form drawn - /// inside it inherits it, narrowed further by the form's own `/BBox` ([`super::lines`]). - pub(super) clip: Option<[f64; 4]>, + /// The clip in force, if any, as the region 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)>, @@ -854,19 +924,34 @@ 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 `x y w h re`, 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. Several - // `re`s before `W n` keep only the last; any other operator between `re` and `n` drops it. - let mut pending_clip: Option<[f64; 4]> = None; + // 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. Several rectangles clip + // to the box around them all, which holds all of them, so no text they show is lost. 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() { match op.op.as_slice() { - b"re" => pending_clip = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]), + 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, rect) { + (None, rect) => rect, + (Some(Some(path)), Some(rect)) => Some(union(path, rect)), + (Some(_), _) => None, + }); + } b"W" | b"W*" => clip_pending = pending_clip.is_some(), b"n" => { - if clip_pending && let Some(r) = pending_clip { - let page_rect = ts.ctm.bbox(r); - ts.clip = Some(ts.clip.map_or(page_rect, |c| narrow(c, page_rect))); + if clip_pending && let Some(Some(r)) = pending_clip { + // A clip of no area (or past what a matrix can hold) is not read: it leaves + // the text as it was rather than hiding all of it. + if let Some(new) = region(&ts.ctm, r) { + ts.clip = Some(match &ts.clip { + Some(old) => intersect(old, &new), + None => new, + }); + } } pending_clip = None; clip_pending = false; diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 21009124a8..2b725fa761 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,9 +1,8 @@ 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, Origin, Pen, Region, Shown, TextState, Ts, direction, interpret, intersect, region, shows, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; -use pdfcraft_content::overlaps; impl TextLine { /// The baseline's height in user space. @@ -451,7 +450,8 @@ impl FormBudget { } /// A form XObject a page draws: its object, content, resources, its matrix composed with the -/// CTM at the `Do`, and its `/BBox` (page space, under that same matrix) if it has a valid one. +/// CTM at the `Do`, and the region its `/BBox` leaves visible (page space, under that same matrix, +/// so a turned form keeps its turned box) if it has a valid one. pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -460,7 +460,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, - pub(crate) bbox: Option<[f64; 4]>, + bbox: Option, } impl FormCall { @@ -530,10 +530,11 @@ 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 a = b.as_array()?; - let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - <[f64; 4]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) + 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, @@ -545,7 +546,7 @@ pub(crate) fn form_call( }; budget.visits = budget.visits.saturating_add(1); let ctm = m.map_or(ctm, |m| Matrix(m).then(&ctm)); - Some(FormCall { obj, data, form, own, ctm, bbox: bbox.map(|b| ctm.bbox(b)) }) + Some(FormCall { obj, data, form, own, ctm, bbox: bbox.and_then(|b| region(&ctm, b)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -559,10 +560,6 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, budget: FormBudget, - /// Active forms' `/BBox`, page space: a run drawn outside any of these is not shown - /// (#3503). Each is checked on its own, not intersected, which is equivalent for - /// deciding whether a run is outside at least one of them. - clip: Vec<[f64; 4]>, } /// `stream` gives the stream number of the operator at an index of `ops`. fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> { @@ -580,10 +577,9 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Some(intersect(outer, own)), + (outer, own) => outer.clone().or_else(|| own.clone()), + }; + let mut carry = + Carry { ts: Ts { ctm: form.ctm, clip, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { w.path.push(r); } - if let Some(b) = form.bbox { - w.clip.push(b); - } - let result = walk(w, &ops, form.resources(resources), &mut carry, &|_| stream); - if form.bbox.is_some() { - w.clip.pop(); - } - result?; + walk(w, &ops, form.resources(resources), &mut carry, &|_| stream)?; *text_left = carry.text_left; if form.obj.is_some() { w.path.pop(); @@ -628,7 +624,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Date: Thu, 5 Nov 2026 14:50:37 +0800 Subject: [PATCH 4/4] fix(edit): a clip is the union of its rectangles, and one of no area hides all (#3503 review) Second review of #5075, five findings, each with a case seen failing on the previous commit: - A path of several rectangles clipped to the box around them, so text in the gap between them was read though nothing of it shows. A clip is now the union of its parts, one convex region per rectangle; clips intersect part by part (a path of more than 16 keeps the box around them, which still holds them all). - A form's /BBox one point by 1e-10 fell under a fixed area threshold and was taken for no box. Whether a region has area is now its area against the square of its perimeter, the same at any scale. - At 1e12 points from the origin the shoelace products lost the area to rounding, and the box was dropped. The area is measured from the first corner. - Clips meeting only along an edge intersected to a region of no area that a run across the edge still showed through; an intersection with no area, or with an empty region on either side, is now empty. - A clip of no area was taken for no clip, and everything under it was read. It has no inside (ISO 32000-2 8.5.4), so it now hides all; only a clip whose corners or measure are not finite is left unread. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 30 +++++++++- crates/edit/src/text.rs | 18 +++++- crates/edit/src/text/interp.rs | 103 ++++++++++++++++++++++++++------- crates/edit/src/text/lines.rs | 14 ++--- 4 files changed, 132 insertions(+), 33 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index eb8cf31707..ade8e19528 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5709,14 +5709,40 @@ fn a_malformed_form_bbox_clips_nothing() { } /// #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. +/// 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 300 300 Td (OUTSIDE) Tj ET Q"; + 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. diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index e18459862d..7c8d844660 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -165,6 +165,13 @@ mod tests { // 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))); @@ -176,9 +183,16 @@ mod tests { // 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)); - // No area, or a corner that isn't finite: no region to clip with. - assert!(region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).is_none()); + // 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: diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index a5a1d8fcde..cd7edbcc01 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -222,13 +222,22 @@ pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { /// 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; `None` when it has no area to clip with or a corner is not finite. +/// 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() @@ -237,18 +246,41 @@ pub(super) fn region(m: &Matrix, r: [f64; 4]) -> Option { [x, y] }) .collect(); - (corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).abs() > 1e-9).then_some(corners) + 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 { - p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum() + 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 leave an empty one. +/// 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)) { @@ -267,13 +299,30 @@ pub(super) fn intersect(a: &Region, b: &Region) -> Region { } } } + if !encloses_area(&out) { + return Vec::new(); + } if out.len() > MAX_REGION_CORNERS { - let b = out.iter().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])]); - out = vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + 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. @@ -297,11 +346,11 @@ pub(super) fn shows(r: [f64; 4], region: &Region) -> bool { #[derive(Clone)] pub(super) struct Ts { pub(super) ctm: Matrix, - /// The clip in force, if any, as the region it leaves visible: the intersection of every + /// 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, + 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)>, @@ -926,29 +975,39 @@ pub(super) fn interpret( 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. Several rectangles clip - // to the box around them all, which holds all of them, so no text they show is lost. Any - // other operator between `re` and `n` ends the idiom: the clip is not read then. - let mut pending_clip: Option> = None; + // 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() { 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, rect) { - (None, rect) => rect, - (Some(Some(path)), Some(rect)) => Some(union(path, rect)), - (Some(_), _) => None, + 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(r)) = pending_clip { - // A clip of no area (or past what a matrix can hold) is not read: it leaves - // the text as it was rather than hiding all of it. - if let Some(new) = region(&ts.ctm, r) { + 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) => intersect(old, &new), + Some(old) => clip_within(old, &new), None => new, }); } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 2b725fa761..5aafd3303d 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, Region, Shown, TextState, Ts, direction, interpret, intersect, region, shows, 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 { @@ -450,8 +450,8 @@ impl FormBudget { } /// A form XObject a page draws: its object, content, resources, its matrix composed with the -/// CTM at the `Do`, and the region its `/BBox` leaves visible (page space, under that same matrix, -/// so a turned form keeps its turned box) if it has a valid one. +/// 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, @@ -460,7 +460,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, - bbox: Option, + bbox: Option, } impl FormCall { @@ -546,7 +546,7 @@ pub(crate) fn form_call( }; budget.visits = budget.visits.saturating_add(1); 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)) }) + 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 @@ -579,7 +579,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Some(intersect(outer, own)), + (Some(outer), Some(own)) => Some(clip_within(outer, own)), (outer, own) => outer.clone().or_else(|| own.clone()), }; let mut carry =