mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
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:
1 parent
41412fb983
commit
6138f2ccc2
3 files changed
+57
-2
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user