diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 2399666c3d..1cade11658 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -72,6 +72,8 @@ struct Gs { line_width: f64, miter_limit: f64, line_join: i64, + /// `J` / ExtGState `/LC`: a projecting square cap (2) reaches past the endpoint's corner. + line_cap: i64, } /// What processing a scope produced. @@ -245,6 +247,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< line_width: 1.0, miter_limit: 10.0, line_join: 0, + line_cap: 0, }; process_from(doc, scope, streams, resources, gs) } @@ -389,7 +392,7 @@ impl Walk { self.clip = false; } let kept = match o { - b"q" | b"Q" | b"cm" | b"Tf" | b"gs" | b"w" | b"M" | b"j" => { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" | b"w" | b"M" | b"j" | b"J" => { self.graphics_state(doc, scope, &op); Some(op) } @@ -427,15 +430,20 @@ impl Walk { /// The box a stroke of the current path can paint, grown from its construction box `b` by /// what the pen adds: half the line width (a width of 0 is one device pixel, taken as one - /// unit), times the miter limit when the path has joins and they are mitred, scaled by the - /// CTM's largest stretch. Caps and joins never reach further, so a hit test on it is - /// conservative, and a horizontal or vertical line no longer has a zero-area box. + /// unit) scaled by the CTM's largest stretch, and never less than half a unit of page space + /// (a renderer paints a hairline, or a line a CTM shrinks, about a device pixel wide); times + /// the miter limit when the path has joins and they are mitred (a rectangle has four), or + /// √2 for a projecting square cap (its outer corner). Caps and joins never reach further, so + /// a hit test on it is conservative, and a horizontal or vertical line no longer has a + /// zero-area box. fn stroke_box(&self, b: [f64; 4]) -> [f64; 4] { let m = self.gs.ctm.0; let scale = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2] + m[3] * m[3]).sqrt(); - let joins = self.path.iter().filter(|op| matches!(op.op.as_slice(), b"l" | b"c" | b"v" | b"y" | b"h" | b"re")).count() >= 2; + let joins = self.path.iter().filter(|op| matches!(op.op.as_slice(), b"l" | b"c" | b"v" | b"y" | b"h" | b"re")).count() >= 2 + || self.path.iter().any(|op| op.op.as_slice() == b"re"); let miter = if joins && self.gs.line_join == 0 { self.gs.miter_limit.max(1.0) } else { 1.0 }; - let pad = self.gs.line_width.max(1.0) / 2.0 * miter * scale; + let reach = if self.gs.line_cap == 2 { miter.max(std::f64::consts::SQRT_2) } else { miter }; + let pad = (self.gs.line_width.max(1.0) / 2.0 * scale).max(0.5) * reach; [b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad] } @@ -480,6 +488,7 @@ impl Walk { b"w" => self.gs.line_width = op.num(0).unwrap_or(self.gs.line_width), b"M" => self.gs.miter_limit = op.num(0).unwrap_or(self.gs.miter_limit), b"j" => self.gs.line_join = op.num(0).map_or(self.gs.line_join, |j| j as i64), + b"J" => self.gs.line_cap = op.num(0).map_or(self.gs.line_cap, |j| j as i64), _ => { if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); @@ -489,6 +498,7 @@ impl Walk { self.gs.line_width = num(b"LW").unwrap_or(self.gs.line_width); self.gs.miter_limit = num(b"ML").unwrap_or(self.gs.miter_limit); self.gs.line_join = num(b"LJ").map_or(self.gs.line_join, |j| j as i64); + self.gs.line_cap = num(b"LC").map_or(self.gs.line_cap, |j| j as i64); } } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6de3d1d645..5bbde87429 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3887,3 +3887,31 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() { assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone)); } } + +/// A hairline under a shrinking CTM still paints about a device pixel: its pad must not shrink +/// with the CTM, or a mark on that ink misses it. +#[test] +fn a_hairline_under_a_shrinking_ctm_is_padded_by_half_a_unit() { + // Device line from (10, 10) to (50, 10); the mark lies on its painted pixel, above its axis. + let mut doc = one_page(b"0.01 0 0 0.01 0 0 cm 0 w 1000 1000 m 5000 1000 l S", "", vec![]); + mark(&mut doc, 0, &[[20.0, 10.2, 30.0, 10.4]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +} + +/// One `re` has four mitred corners: it is padded by the miter limit like any joined path. +#[test] +fn a_rectangle_stroke_is_padded_for_its_mitred_corners() { + let mut doc = one_page(b"2 w 100 100 50 50 re S", "", vec![]); + // Past half the line width from the rectangle, inside the miter reach. + mark(&mut doc, 0, &[[155.0, 120.0, 160.0, 130.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +} + +/// A projecting square cap's outer corner reaches lw/2 x sqrt(2) from the endpoint. Under a shear +/// it maps past a pad of lw/2 times the CTM's stretch (the review's example: corner at x = 12). +#[test] +fn a_square_cap_corner_under_a_shear_is_inside_the_pad() { + let mut doc = one_page(b"1 0 1 1 0 0 cm 2 J 2 w 0 0 m 10 0 l S", "", vec![]); + mark(&mut doc, 0, &[[11.8, 0.8, 11.9, 0.95]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +}