mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
M7: paragraph edits keep the advance of the text they drop (edit.text-edit) (#2968)
This commit is contained in:
@@ -9684,6 +9684,102 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
|
||||
}
|
||||
|
||||
/// `'` moves to the next line before it shows its text, and `"` also sets the word and character
|
||||
/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the
|
||||
/// operator but must keep what it did: text after it, positioned from that line or using that
|
||||
/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the
|
||||
/// region path (a draft with a line break) are checked.
|
||||
#[test]
|
||||
fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() {
|
||||
let cases = [
|
||||
// The next line is placed relative to the line `'` moved to.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
// The same with `"`, whose spacing the footer also inherits.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET",
|
||||
// Spacing set by `"` carries over into the next text object.
|
||||
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET",
|
||||
// A split text object: text before the paragraph stays first, then the `'` lines go.
|
||||
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
// Split at a `'`: the rewrite's `Tm` there is already past its line move.
|
||||
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
];
|
||||
for content in cases {
|
||||
let footer = |doc: &Document| {
|
||||
let lines = text::text_lines(doc, 0).unwrap();
|
||||
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
|
||||
(f.rect, f.ink.clone())
|
||||
};
|
||||
let original = text_page(content);
|
||||
let before = footer(&original);
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap();
|
||||
for draft in ["NEW TEXT", "NEW\nTEXT"] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(footer(&doc), before, "{content} with {draft:?}");
|
||||
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold
|
||||
/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing
|
||||
/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after
|
||||
/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the
|
||||
/// one drawn with the quote operator is both the replaced line and the dropped copy.
|
||||
#[test]
|
||||
fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() {
|
||||
let cases = [
|
||||
// The copy's `'` moves to the line the footer is placed from.
|
||||
"BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET",
|
||||
// The copy's `"` sets the spacing the footer inherits in the next text object.
|
||||
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET",
|
||||
];
|
||||
for content in cases {
|
||||
let footer = |doc: &Document| {
|
||||
let lines = text::text_lines(doc, 0).unwrap();
|
||||
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
|
||||
(f.rect, f.ink.clone())
|
||||
};
|
||||
let original = text_page(content);
|
||||
let before = footer(&original);
|
||||
let targets: Vec<usize> =
|
||||
text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect();
|
||||
assert_eq!(targets.len(), 2, "{content}");
|
||||
for target in targets {
|
||||
let mut doc = original.clone();
|
||||
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(footer(&doc), before, "{content} replacing line {target}");
|
||||
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its
|
||||
/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4).
|
||||
#[test]
|
||||
fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() {
|
||||
for content in [
|
||||
"BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET",
|
||||
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET",
|
||||
] {
|
||||
let original = text_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}"));
|
||||
// The line drawn first, so the one followed by Far is the copy.
|
||||
let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap();
|
||||
let mut doc = original.clone();
|
||||
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}");
|
||||
assert_eq!(place(&doc), Some(before), "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2512: a session's drafts are laid out from the document as it was opened, never from each
|
||||
/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before.
|
||||
#[test]
|
||||
@@ -13610,6 +13706,87 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same
|
||||
/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after
|
||||
/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as
|
||||
/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the
|
||||
/// paragraph splits the text object, and what reopens it keeps that run's advance too.
|
||||
#[test]
|
||||
fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() {
|
||||
let cases = [
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"),
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// Several dropped runs: each one's advance is kept.
|
||||
("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// A kept run before the paragraph on its baseline: the text object is split there, and
|
||||
// the pen it had reached is kept as well as the line matrix.
|
||||
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// The same with horizontal scaling and a later line placed from the line matrix.
|
||||
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"),
|
||||
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"),
|
||||
// A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`.
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"),
|
||||
// A `Q` with nothing saved restores nothing: the advance is still kept.
|
||||
("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"),
|
||||
];
|
||||
for (content, run) in cases {
|
||||
let original = text_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}"));
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap();
|
||||
for draft in ["NEW TEXT", "NEW\nTEXT"] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}"));
|
||||
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave
|
||||
/// that advance, so a run later in the same text object stays where it was.
|
||||
#[test]
|
||||
fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() {
|
||||
// The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take.
|
||||
for content in [
|
||||
"BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
|
||||
"BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
|
||||
] {
|
||||
vertical_runs_keep_a_later_run_in_place(content);
|
||||
}
|
||||
}
|
||||
|
||||
fn vertical_runs_keep_a_later_run_in_place(content: &str) {
|
||||
let original = vertical_page(content);
|
||||
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect);
|
||||
let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0)));
|
||||
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap();
|
||||
for draft in [
|
||||
"D", "D
|
||||
E",
|
||||
] {
|
||||
let mut doc = original.clone();
|
||||
text::replace_block(&mut doc, 0, target, draft).unwrap();
|
||||
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}"));
|
||||
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The advances read the glyphs without `/ActualText`, which can come to more text than the
|
||||
/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept
|
||||
/// and the edit goes ahead, rather than refusing a page the reading allowed.
|
||||
#[test]
|
||||
fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() {
|
||||
let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET");
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let drop: std::collections::HashSet<usize> = ops.iter().position(|o| o.is("Tj")).into_iter().collect();
|
||||
let page = pdfcraft_model::pages(&doc)[0].dict.clone();
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on");
|
||||
assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_rules_refuse_unread_graphics_state() {
|
||||
let mut dict = Dict::new();
|
||||
|
||||
@@ -55,9 +55,11 @@ use fallback::{
|
||||
use fallback::{add_type3_font, is_type3_fallback};
|
||||
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
|
||||
use interp::{Origin, Paint, union};
|
||||
#[cfg(test)]
|
||||
pub(crate) use lines::dropped_advances_within;
|
||||
pub(crate) use lines::{
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
|
||||
joined, resource_dict, rewritten_contents, splice,
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances,
|
||||
dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice,
|
||||
};
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
use lines::{page_dict, word_lines};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::interp::{Carry, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines};
|
||||
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines};
|
||||
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
|
||||
use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions};
|
||||
|
||||
@@ -315,13 +315,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
|
||||
}
|
||||
let mut replacement = Vec::new();
|
||||
if let Some(op) = ops.get(s.op) {
|
||||
if op.is("\"") {
|
||||
replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))]));
|
||||
}
|
||||
if op.is("'") || op.is("\"") {
|
||||
replacement.push(Op::new("T*", vec![]));
|
||||
}
|
||||
replacement.extend(show_side_effects(op));
|
||||
}
|
||||
replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
patches.insert(s.op, replacement);
|
||||
|
||||
@@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'`
|
||||
/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`,
|
||||
/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place.
|
||||
pub(crate) fn show_side_effects(op: &Op) -> Vec<Op> {
|
||||
let mut out = Vec::new();
|
||||
if op.is("\"") {
|
||||
out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)])));
|
||||
out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)])));
|
||||
}
|
||||
if op.is("'") || op.is("\"") {
|
||||
out.push(Op::new("T*", vec![]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite: what dropped operator `i` must leave behind
|
||||
/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the
|
||||
/// operator the text object was split at: the rewrite's `Tm` and state there already include
|
||||
/// that operator's effects.
|
||||
pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option<usize>) -> Vec<Op> {
|
||||
if keep || split_at == Some(i) {
|
||||
return Vec::new();
|
||||
}
|
||||
ops.get(i).map(show_side_effects).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite that splits a text object: what reopens it after the
|
||||
/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and
|
||||
/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and
|
||||
/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later
|
||||
/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already
|
||||
/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix
|
||||
/// alone by that advance. Only an advance along a horizontal baseline is expressible that way;
|
||||
/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix.
|
||||
pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option<f64>, scale: f64) -> Vec<Op> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())];
|
||||
// tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space.
|
||||
let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]);
|
||||
let det = tlm[0] * tlm[3] - tlm[1] * tlm[2];
|
||||
let tx = (dx * tlm[3] - dy * tlm[2]) / det;
|
||||
let ty = (dy * tlm[0] - dx * tlm[1]) / det;
|
||||
let denominator = size.unwrap_or(0.0) * scale;
|
||||
let adjustment = -1000.0 * tx / denominator;
|
||||
if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() {
|
||||
out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving
|
||||
/// the text matrix as far as that operator's text did without drawing anything (as
|
||||
/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td`
|
||||
/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it
|
||||
/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next
|
||||
/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ`
|
||||
/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource
|
||||
/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the
|
||||
/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget
|
||||
/// keeps no advances rather than refusing an edit the reading allowed.
|
||||
pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet<usize>) -> HashMap<usize, Op> {
|
||||
dropped_advances_within(doc, ops, resources, drop, None)
|
||||
}
|
||||
|
||||
/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small).
|
||||
pub(crate) fn dropped_advances_within(
|
||||
doc: &Document,
|
||||
ops: &[Op],
|
||||
resources: &Dict,
|
||||
drop: &std::collections::HashSet<usize>,
|
||||
text_left: Option<usize>,
|
||||
) -> HashMap<usize, Op> {
|
||||
let mut out = HashMap::new();
|
||||
let mut carry = Carry::new();
|
||||
if let Some(left) = text_left {
|
||||
carry.text_left = left;
|
||||
}
|
||||
let follows = kept_run_follows(ops, drop);
|
||||
let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else {
|
||||
return out;
|
||||
};
|
||||
for s in shown {
|
||||
if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let denominator = s.state.size * s.state.scale;
|
||||
let adjustment = -1000.0 * s.advance / denominator;
|
||||
if denominator != 0.0 && adjustment.is_finite() {
|
||||
out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each operator: whether a run shown after it in its text object, and not in `drop`, is
|
||||
/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]).
|
||||
/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move
|
||||
/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where
|
||||
/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear.
|
||||
fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet<usize>) -> Vec<bool> {
|
||||
// The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a
|
||||
// `Q` that restores something is a boundary here.
|
||||
let mut paired = vec![false; ops.len()];
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
if op.is("q") {
|
||||
open.push(i);
|
||||
} else if op.is("Q")
|
||||
&& let Some(q) = open.pop()
|
||||
{
|
||||
for at in [q, i] {
|
||||
if let Some(slot) = paired.get_mut(at) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![false; ops.len()];
|
||||
let mut follows = false;
|
||||
// What followed each `Q` still open (read backwards), restored at its `q`.
|
||||
let mut after_restore: Vec<bool> = Vec::new();
|
||||
for (i, op) in ops.iter().enumerate().rev() {
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = follows;
|
||||
}
|
||||
let pairs = paired.get(i).copied().unwrap_or(false);
|
||||
match op.op.as_slice() {
|
||||
b"Q" if pairs => {
|
||||
after_restore.push(follows);
|
||||
follows = false;
|
||||
}
|
||||
b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false),
|
||||
b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false,
|
||||
b"Tj" | b"TJ" if !drop.contains(&i) => follows = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
||||
pub(crate) fn joined(streams: &[ContentStream]) -> Pieces {
|
||||
Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::<Vec<_>>())
|
||||
|
||||
@@ -4,7 +4,10 @@ use super::fallback::{
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
use super::lines::{
|
||||
content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice,
|
||||
text_lines,
|
||||
};
|
||||
use super::{
|
||||
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
|
||||
parse, rewrite_changed_words, rewrite_styled_block,
|
||||
@@ -68,16 +71,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let reused = font.as_ref().filter(|m| source_encodes(m, &shown));
|
||||
let mut substituted = None;
|
||||
let mut replacement: Vec<Op> = Vec::new();
|
||||
// ' and " also move to the next line; keep that.
|
||||
match ops[first].op.as_slice() {
|
||||
b"'" => replacement.push(Op::new("T*", vec![])),
|
||||
b"\"" => {
|
||||
replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("T*", vec![]));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// ' and " also move to the next line (and " sets the spacing); keep that.
|
||||
replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default());
|
||||
match reused {
|
||||
Some(metrics) => replacement.extend(source_show(
|
||||
metrics,
|
||||
@@ -134,8 +129,23 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go. Each other
|
||||
// dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and
|
||||
// spacing behind, as the first one does inside the replacement, and a dropped copy its advance
|
||||
// where text after it is placed by that (the line's own runs give way to the replacement's).
|
||||
let mut advances = dropped_advances(doc, &ops, &res, &drops);
|
||||
for i in &target.ops {
|
||||
advances.remove(i);
|
||||
}
|
||||
let new_data = splice(&pieces, &ops, |i| {
|
||||
if i == first {
|
||||
return (std::mem::take(&mut replacement), false);
|
||||
}
|
||||
let keep = !drops.contains(&i);
|
||||
let mut insert = dropped_side_effects(&ops, i, keep, None);
|
||||
insert.extend(advances.remove(&i));
|
||||
(insert, keep)
|
||||
});
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let page_ref = p.obj;
|
||||
if substituted.is_some() {
|
||||
@@ -995,16 +1005,18 @@ pub(crate) fn rewrite_block_from(
|
||||
};
|
||||
let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?;
|
||||
let on_top = crate::lift_on_top(over, &mut block_ops);
|
||||
let n = pdfcraft_content::num;
|
||||
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
|
||||
block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
|
||||
}
|
||||
let mut advances = dropped_advances(doc, &ops, &res, &dropped);
|
||||
let new_data = splice(&pieces, &ops, |i| {
|
||||
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
|
||||
(insert, !dropped.contains(&i))
|
||||
let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
|
||||
let keep = !dropped.contains(&i);
|
||||
insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start)));
|
||||
insert.extend(advances.remove(&i));
|
||||
(insert, keep)
|
||||
});
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let new_font = !reuse;
|
||||
|
||||
@@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) {
|
||||
}
|
||||
|
||||
/// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error
|
||||
/// when it moved or lost any other text on the page: text positioned after the end of the text
|
||||
/// before it, say, depends on the words that went.
|
||||
/// when it moved or lost any other text on the page: text positioned after the end of the words
|
||||
/// that went, say, when their advance couldn't be left behind (see `dropped_advances`).
|
||||
fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result<Option<f64>, EditError> {
|
||||
let mut unmatched: HashMap<_, usize> = HashMap::new();
|
||||
for line in before {
|
||||
|
||||
@@ -1546,7 +1546,8 @@ fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[
|
||||
/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P`
|
||||
/// marker opened at its start and closed at its `next` line or, when nothing follows, after the
|
||||
/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or
|
||||
/// beyond `ops.len()`, since `splice`'s callback never visits that index).
|
||||
/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator
|
||||
/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance.
|
||||
fn splice_with_marker(
|
||||
pieces: &Pieces,
|
||||
ops: &[Op],
|
||||
@@ -1554,6 +1555,7 @@ fn splice_with_marker(
|
||||
at: usize,
|
||||
mut block_ops: Vec<Op>,
|
||||
drop: &std::collections::HashSet<usize>,
|
||||
mut left_behind: impl FnMut(usize, bool) -> Vec<Op>,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let data = splice(pieces, ops, |i| {
|
||||
let mut insert = Vec::new();
|
||||
@@ -1571,7 +1573,9 @@ fn splice_with_marker(
|
||||
if i == at {
|
||||
insert.append(&mut block_ops);
|
||||
}
|
||||
(insert, !drop.contains(&i))
|
||||
let keep = !drop.contains(&i);
|
||||
insert.extend(left_behind(i, keep));
|
||||
(insert, keep)
|
||||
});
|
||||
if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
|
||||
return data;
|
||||
@@ -1896,14 +1900,21 @@ pub(crate) fn rewrite_styled_block(
|
||||
block_ops.extend(after.ops());
|
||||
if split {
|
||||
block_ops.insert(0, Op::new("ET", vec![]));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
|
||||
block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
|
||||
}
|
||||
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
|
||||
// safe. The close is not part of `block_ops`: that vector can end inside a text object
|
||||
// when the rewrite splits one, and a kept row's operators come later in the stream.
|
||||
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
|
||||
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
|
||||
// A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance.
|
||||
let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop);
|
||||
let split_at = split.then_some(start);
|
||||
let left_behind = |i: usize, keep: bool| {
|
||||
let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at);
|
||||
out.extend(advances.remove(&i));
|
||||
out
|
||||
};
|
||||
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind);
|
||||
let contents = rewritten_contents(doc, &streams, data)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| {
|
||||
|
||||
@@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() {
|
||||
assert_eq!(drawn.join(" "), "John Smyth Example");
|
||||
}
|
||||
|
||||
/// A layer whose words follow one another with no position of their own: a word that went would
|
||||
/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead.
|
||||
/// A layer whose words follow one another with no position of their own: a word that goes leaves
|
||||
/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are
|
||||
/// kept as the scan, with nothing drawn.
|
||||
#[test]
|
||||
fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() {
|
||||
fn words_that_depend_on_the_one_before_are_kept_in_place() {
|
||||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET");
|
||||
let mut s = Session::new();
|
||||
let id = s.open("relative.pdf", None, bytes, None).unwrap();
|
||||
assert_eq!(texts(&s, id), ["John Smith Example"]);
|
||||
let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]);
|
||||
let before = right(&s).unwrap();
|
||||
edit_block(&mut s, id, 0, "John Example");
|
||||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}");
|
||||
assert_eq!(drawn, ["John Example"]);
|
||||
assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::<String>::new()));
|
||||
assert_eq!(right(&s), Some(before), "Example stays where it was");
|
||||
}
|
||||
|
||||
/// The edit survives a save and reopen, and each piece can be edited again.
|
||||
@@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible text positioned after the end of an OCR word depends on that word. An edit may not
|
||||
/// leave the word gone and the visible text somewhere else while calling the rest kept.
|
||||
/// Visible text positioned after the end of an OCR word depends on that word. The word that goes
|
||||
/// leaves its advance behind, so the visible text stays where it was and the word kept stays the
|
||||
/// scan's.
|
||||
#[test]
|
||||
fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() {
|
||||
fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() {
|
||||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET");
|
||||
let mut s = Session::new();
|
||||
let id = s.open("label.pdf", None, bytes, None).unwrap();
|
||||
let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]);
|
||||
assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]);
|
||||
assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()]));
|
||||
let before = label(&s).unwrap();
|
||||
edit_block(&mut s, id, 0, "John");
|
||||
let (scan, _) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s));
|
||||
// Nothing new is drawn: the visible text is the page's own.
|
||||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()]));
|
||||
assert_eq!(label(&s), Some(before), "Label stays where it was");
|
||||
}
|
||||
|
||||
/// A large word right before a small one, as OCR's sizes can make them: the large word's cover,
|
||||
|
||||
Reference in New Issue
Block a user