fix(edit): Office export also respects a page's own re/W/n clip

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 5 committed 2026-11-06 08:43:50 +08:00
1 parent 41412fb983
commit 6138f2ccc2
3 files changed
+57 -2

No files matched your search

+20
View File
@@ -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<String> = 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]
+33
View File
@@ -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<Op>,
pub(super) font: Option<(Vec<u8>, Rc<Metrics>)>,
@@ -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.
+4 -2
View File
@@ -580,8 +580,10 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
// Text after a form starts a new line.
last = None;
}
// A run a form's `/BBox` clips away is not drawn, so it is not read either (#3503).
if w.clip.iter().any(|c| !overlaps(s.rect, *c, 0.0)) {
// 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 {
last = None;
continue;
}