Merge #5075: fix(edit): Office export respects a form's /BBox and a page's re/W/n clip

# Conflicts:
#	crates/edit/src/text/lines.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:22:16 +07:00
commit 6404602e63
4 files changed
+408 -10

No files matched your search

+145 -5
View File
@@ -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<String> = 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<String> = 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<String> {
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<String>| {
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 >>",
+60
View File
@@ -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::<f64>().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(),
+177
View File
@@ -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<Region>;
/// 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<Region> {
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<Item = &'a [f64; 2]>) -> 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<Clip>,
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
pub(super) fill: Vec<Op>,
pub(super) font: Option<(Vec<u8>, Rc<Metrics>)>,
@@ -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<Option<Vec<[f64; 4]>>> = 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<Clip> = 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.
+26 -5
View File
@@ -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<pdfcraft_cos::ObjRef>,
pub(crate) data: Vec<u8>,
@@ -575,6 +576,7 @@ pub(crate) struct FormCall {
form: Arc<Object>,
own: Option<Arc<Object>>,
pub(crate) ctm: Matrix,
bbox: Option<Clip>,
}
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<f64> = b.as_array()?.iter().map(|x| doc.resolve(x).as_f64().filter(|v| v.is_finite())).collect::<Option<_>>()?;
<[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<TextLine
// Text after a form starts a new line.
last = None;
}
// A run that the clips in force (forms' `/BBox`es and content-stream `re`/`W`/`n`
// clips, intersected) hide is not drawn, so it is not read either (#3503).
if s.state.clip.as_ref().is_some_and(|clip| !clip_shows(s.rect, clip)) {
last = None;
continue;
}
let si = stream(s.op);
// Text from another stream starts a new line, as when each stream was read alone.
if last_stream.replace(si).is_some_and(|prev| prev != si) {
@@ -701,8 +716,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
// Its content is clipped to its `/BBox` within whatever clips the page had in force at the
// `Do`. It starts with a stack of its own, so no `Q` of its own can lift that clip.
let clip = match (&ts.clip, &form.bbox) {
(Some(outer), Some(own)) => 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(),