mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6138f2ccc2
commit
945f0f3f39
4 files changed
+239
-43
No files matched your search
@@ -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<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.
|
||||
#[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]
|
||||
|
||||
@@ -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::<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:?}");
|
||||
// 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(),
|
||||
|
||||
+102
-17
@@ -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<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();
|
||||
(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<Region>,
|
||||
/// 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>)>,
|
||||
@@ -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<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"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;
|
||||
|
||||
@@ -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<pdfcraft_cos::ObjRef>,
|
||||
pub(crate) data: Vec<u8>,
|
||||
@@ -460,7 +460,7 @@ pub(crate) struct FormCall {
|
||||
form: Arc<Object>,
|
||||
own: Option<Arc<Object>>,
|
||||
pub(crate) ctm: Matrix,
|
||||
pub(crate) bbox: Option<[f64; 4]>,
|
||||
bbox: Option<Region>,
|
||||
}
|
||||
|
||||
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<f64> = 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<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,
|
||||
@@ -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<TextLine
|
||||
next_stream: usize,
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
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<TextLine
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
// A run that a form's `/BBox`, or a content-stream `re`/`W`/`n` clip, hides is not
|
||||
// drawn, so it is not read either (#3503).
|
||||
let clipped = w.clip.iter().any(|c| !overlaps(s.rect, *c, 0.0)) || s.state.clip.is_some_and(|c| !overlaps(s.rect, c, 0.0));
|
||||
if clipped {
|
||||
// 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| !shows(s.rect, clip)) {
|
||||
last = None;
|
||||
continue;
|
||||
}
|
||||
@@ -604,20 +600,20 @@ 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.
|
||||
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
|
||||
// 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(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<TextLine
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut decode = DecodeBudget::page();
|
||||
let streams = content_streams_within(doc, &p.dict, &mut decode)?;
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode), clip: Vec::new() };
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) };
|
||||
let mut carry = Carry::new();
|
||||
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
|
||||
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
|
||||
|
||||
Reference in new issue
Block a user