mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
commit
6404602e63
4 files changed
+408
-10
No files matched your search
+145
-5
@@ -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 >>",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user