mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 03:01:55 +00:00
Merge pull request #5396 from KenM76/fix/ocr-edit-under-scan
fix(edit): an edited OCR phrase under its scan image is drawn on top (#3973)
This commit is contained in:
10 files changed
+514
-27
No files matched your search
@@ -1691,6 +1691,12 @@ pub(crate) fn selectable_images(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows
|
||||
/// inside `rect` (user space).
|
||||
pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result<bool, EditError> {
|
||||
Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some()))
|
||||
}
|
||||
|
||||
fn transform_image(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
|
||||
@@ -77,7 +77,7 @@ use marks::{matrix_number, n};
|
||||
pub(crate) use place::undrawable;
|
||||
#[cfg(test)]
|
||||
use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role};
|
||||
use place::{check, check_on_top, contents, page_list, place_tagged};
|
||||
use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint};
|
||||
pub use place::{stamp, undrawable_message};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -371,6 +371,76 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag:
|
||||
Ok(mark_ref)
|
||||
}
|
||||
|
||||
/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged
|
||||
/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR
|
||||
/// layer written before the scan image it lies under would keep the revealed text hidden (#3973).
|
||||
pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> {
|
||||
if ops.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
use pdfcraft_content::Op;
|
||||
let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())];
|
||||
all.extend_from_slice(ops);
|
||||
all.push(Op::new("Q", vec![]));
|
||||
place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop)
|
||||
}
|
||||
|
||||
/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]).
|
||||
pub(crate) struct Reach {
|
||||
/// The paragraph's old box, where its cover goes.
|
||||
pub rect: [f64; 4],
|
||||
/// The move asked for (`BlockStyle::offset`).
|
||||
pub offset: [f64; 2],
|
||||
/// The widest line the rewrite may draw, how many lines it has, and how far apart they are.
|
||||
pub width: f64,
|
||||
pub rows: usize,
|
||||
pub pitch: f64,
|
||||
pub upright: bool,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
/// The old box and the moved one, grown to `width` either way (alignment may grow a line left
|
||||
/// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way.
|
||||
/// Unbounded when a number is not finite.
|
||||
pub(crate) fn bounds(&self) -> [f64; 4] {
|
||||
const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY];
|
||||
let ([dx, dy], r) = (self.offset, self.rect);
|
||||
if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) {
|
||||
return UNBOUNDED;
|
||||
}
|
||||
let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy];
|
||||
let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX));
|
||||
let (width, height) = (self.width.abs(), self.pitch.abs() * rows);
|
||||
let grown = if self.upright {
|
||||
[moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()]
|
||||
} else {
|
||||
let d = width.hypot(height);
|
||||
[moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d]
|
||||
};
|
||||
let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])];
|
||||
if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after
|
||||
/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image
|
||||
/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline
|
||||
/// image or a shading after it counts anywhere, and so does any XObject when the page's images
|
||||
/// cannot be listed.
|
||||
pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool {
|
||||
let later = ops.get(at..).unwrap_or_default();
|
||||
if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) {
|
||||
return invisible;
|
||||
}
|
||||
later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`,
|
||||
/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began.
|
||||
pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec<pdfcraft_content::Op>) -> Vec<pdfcraft_content::Op> {
|
||||
if over { std::mem::take(paragraph) } else { Vec::new() }
|
||||
}
|
||||
|
||||
fn too_deep_on_top() -> EditError {
|
||||
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
|
||||
}
|
||||
|
||||
@@ -6213,6 +6213,126 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() {
|
||||
assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused");
|
||||
}
|
||||
|
||||
/// Invisible text moves on top only when something painted after it covers it: an XObject drawn
|
||||
/// over its box (an image, or a form holding one), or any inline image or shading. An image
|
||||
/// clipped away entirely, or drawn elsewhere, does not move it.
|
||||
#[test]
|
||||
fn revealed_text_moves_on_top_only_when_painted_over() {
|
||||
let on_top_of = |doc: Document| {
|
||||
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let at = ops.iter().position(|o| o.is("BT")).unwrap();
|
||||
revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at)
|
||||
};
|
||||
let on_top = |content: &str| on_top_of(image_page_with(content));
|
||||
let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q";
|
||||
assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it");
|
||||
assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere");
|
||||
assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first");
|
||||
assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is");
|
||||
assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it");
|
||||
assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it");
|
||||
assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it");
|
||||
let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET";
|
||||
let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q";
|
||||
assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely");
|
||||
assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it");
|
||||
assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it");
|
||||
assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it");
|
||||
let in_form = |content: &str| {
|
||||
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>";
|
||||
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A");
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
page.into(),
|
||||
stream("", content),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
image,
|
||||
form,
|
||||
];
|
||||
on_top_of(build(&objs.iter().map(String::as_str).collect::<Vec<_>>()))
|
||||
};
|
||||
assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it");
|
||||
assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere");
|
||||
}
|
||||
|
||||
/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn
|
||||
/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths.
|
||||
#[test]
|
||||
fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() {
|
||||
let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q";
|
||||
let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() };
|
||||
let sized = text::BlockStyle { size: Some(24.0), ..Default::default() };
|
||||
let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() };
|
||||
let range = text::RangeStyle {
|
||||
range: 0..2,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: Some(120.0),
|
||||
color: None,
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() };
|
||||
let above = "q 200 0 0 60 100 200 cm /Im0 Do Q";
|
||||
let cases = [
|
||||
("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved),
|
||||
("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved),
|
||||
("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()),
|
||||
("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()),
|
||||
("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()),
|
||||
];
|
||||
let low = "q 200 0 0 30 100 300 cm /Im0 Do Q";
|
||||
let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([
|
||||
("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low),
|
||||
("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low),
|
||||
("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above),
|
||||
]);
|
||||
for (content, edit, style, scan) in cases {
|
||||
let mut doc = image_page_with(&format!("{content} {scan}"));
|
||||
let before = text::text_blocks(&doc, 0).unwrap().remove(0);
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let at = ops.iter().position(|o| o.is("BT")).unwrap();
|
||||
assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap();
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
let scan_at = ops.iter().position(|o| o.is("Do")).unwrap();
|
||||
let shows = ["Tj", "TJ", "'", "\""];
|
||||
assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below.
|
||||
#[test]
|
||||
fn a_rewrites_reach_covers_its_move_width_and_lines() {
|
||||
let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds();
|
||||
assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]);
|
||||
assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]);
|
||||
assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]);
|
||||
assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]);
|
||||
assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY);
|
||||
let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true };
|
||||
assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down");
|
||||
}
|
||||
|
||||
/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep
|
||||
/// for that is refused before either rewrite path changes anything.
|
||||
#[test]
|
||||
fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() {
|
||||
let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001));
|
||||
let mut doc = text_page(&content);
|
||||
assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible);
|
||||
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
for edit in ["Jane", "Jane\nDoe"] {
|
||||
let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err();
|
||||
assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}");
|
||||
assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_move_budget_refusals_keep_the_original_page_selectable() {
|
||||
let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995)));
|
||||
|
||||
@@ -31,7 +31,8 @@ pub struct TextBlock {
|
||||
/// Byte ranges in `editor_text` and their source line indexes.
|
||||
pub spans: Vec<(std::ops::Range<usize>, usize)>,
|
||||
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
|
||||
/// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]).
|
||||
/// pixels show the words. A rewrite draws the new text visibly, on top of the page when
|
||||
/// the scan is drawn after it (see [`BlockStyle::cover`]).
|
||||
pub invisible: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,33 @@ pub(super) struct TextState {
|
||||
/// graphics state the text was painted with that [`TextState::ops`] doesn't restate.
|
||||
pub(super) stroke: Vec<Op>,
|
||||
pub(super) line_width: Option<f64>,
|
||||
ext_gstate: Vec<Vec<u8>>,
|
||||
ext_gstate: Vec<GsSelection>,
|
||||
/// The dash pattern and line cap in force (see [`Ts`]): compared, not restated.
|
||||
pub(super) dash: Dash,
|
||||
pub(super) line_cap: i64,
|
||||
}
|
||||
|
||||
impl TextState {
|
||||
/// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on
|
||||
/// top of the page, after its content wrapped in `q`/`Q`.
|
||||
pub(crate) fn initial() -> Self {
|
||||
TextState {
|
||||
font: None,
|
||||
char_spacing: 0.0,
|
||||
word_spacing: 0.0,
|
||||
scale: 1.0,
|
||||
leading: 0.0,
|
||||
rise: 0.0,
|
||||
fill: Vec::new(),
|
||||
render_mode: 0,
|
||||
stroke: Vec::new(),
|
||||
line_width: None,
|
||||
ext_gstate: Vec::new(),
|
||||
dash: (Vec::new(), 0.0),
|
||||
line_cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn of(ts: &Ts) -> Self {
|
||||
TextState {
|
||||
font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)),
|
||||
@@ -54,13 +74,17 @@ impl TextState {
|
||||
///
|
||||
/// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force:
|
||||
/// run these inside a `q`/`Q` that starts from `current`, one per state drawn.
|
||||
pub(crate) fn paint_ops(&self, current: &TextState) -> Vec<Op> {
|
||||
///
|
||||
/// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at
|
||||
/// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM
|
||||
/// set back.
|
||||
pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec<Op> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut v = Vec::new();
|
||||
// The selections both share stay in force; the ones after them are selected again in order.
|
||||
let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count();
|
||||
for name in self.ext_gstate.iter().skip(shared) {
|
||||
v.push(Op::new("gs", vec![Object::Name(name.clone())]));
|
||||
for sel in self.ext_gstate.iter().skip(shared) {
|
||||
sel.push_ops(&mut v, ctm);
|
||||
}
|
||||
// A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it.
|
||||
let reselected = !v.is_empty();
|
||||
@@ -222,7 +246,7 @@ pub(super) struct Ts {
|
||||
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
|
||||
pub(super) line_width: Option<f64>,
|
||||
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
|
||||
pub(super) ext_gstate: Vec<Vec<u8>>,
|
||||
pub(super) ext_gstate: Vec<GsSelection>,
|
||||
/// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`).
|
||||
pub(super) dash: Dash,
|
||||
/// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt).
|
||||
@@ -255,12 +279,38 @@ const MAX_GS: usize = 32;
|
||||
/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute
|
||||
/// value, so a later selection of the same dictionary overrides the earlier one completely: the
|
||||
/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream.
|
||||
fn select_gs(list: &mut Vec<Vec<u8>>, name: &[u8]) {
|
||||
list.retain(|n| n != name);
|
||||
fn select_gs(list: &mut Vec<GsSelection>, name: &[u8], anchor: Option<[f64; 6]>) {
|
||||
list.retain(|s| s.name != name);
|
||||
if list.len() >= MAX_GS {
|
||||
list.remove(0);
|
||||
}
|
||||
list.push(name.to_vec());
|
||||
list.push(GsSelection { name: name.to_vec(), anchor });
|
||||
}
|
||||
|
||||
/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary
|
||||
/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct GsSelection {
|
||||
name: Vec<u8>,
|
||||
anchor: Option<[f64; 6]>,
|
||||
}
|
||||
|
||||
impl GsSelection {
|
||||
/// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both
|
||||
/// moves need an invertible matrix; without one it is selected where it runs.
|
||||
fn push_ops(&self, v: &mut Vec<Op>, ctm: [f64; 6]) {
|
||||
let gs = Op::new("gs", vec![Object::Name(self.name.clone())]);
|
||||
let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| {
|
||||
let (anchor, here) = (Matrix(a), Matrix(ctm));
|
||||
Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?)))
|
||||
});
|
||||
let Some((to, back)) = moves else {
|
||||
v.push(gs);
|
||||
return;
|
||||
};
|
||||
let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect());
|
||||
v.extend([cm(to), gs, cm(back)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text-showing operators of one stream with their text, font, box and baseline.
|
||||
@@ -879,14 +929,15 @@ pub(super) fn interpret(
|
||||
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
|
||||
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
|
||||
b"gs" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
select_gs(&mut ts.ext_gstate, name);
|
||||
}
|
||||
let mut held = None;
|
||||
let params = match op.name(0).and_then(|name| states.get(name)) {
|
||||
Some(p) => super::dict_in_place(doc, p, &mut held),
|
||||
None => None,
|
||||
};
|
||||
if let Some(name) = op.name(0) {
|
||||
let masked = params.and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some());
|
||||
select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0));
|
||||
}
|
||||
if let Some(params) = params {
|
||||
ts.gs.apply(doc, params);
|
||||
// `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5).
|
||||
|
||||
@@ -702,11 +702,13 @@ fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String,
|
||||
let mut ops = vec![Op::new("q", vec![])];
|
||||
ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?);
|
||||
// Moved: a translation inside that state.
|
||||
let mut ctm = Matrix(plan.emit.ctm);
|
||||
if let Some((ax, ay)) = plan.movement {
|
||||
ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
|
||||
ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm);
|
||||
}
|
||||
// Painted as the paragraph was, including what its text object set before the first show.
|
||||
ops.extend(plan.state.paint_ops(plan.after));
|
||||
ops.extend(plan.state.paint_ops(plan.after, ctm.0));
|
||||
ops.push(Op::new("BT", vec![]));
|
||||
let mut state = plan.state.clone();
|
||||
state.word_spacing = 0.0;
|
||||
@@ -816,6 +818,11 @@ pub(crate) fn rewrite_block_from(
|
||||
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
|
||||
members.iter().try_for_each(|line| line.ensure_editable())?;
|
||||
let p = page_dict(doc, page)?;
|
||||
// Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't
|
||||
// take that is refused here, before either rewrite path changes anything.
|
||||
if b.invisible {
|
||||
crate::check_on_top(doc, &p)?;
|
||||
}
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?;
|
||||
@@ -930,9 +937,28 @@ pub(crate) fn rewrite_block_from(
|
||||
char_spacing: o_state.char_spacing,
|
||||
lead,
|
||||
};
|
||||
let plan =
|
||||
BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) };
|
||||
let at = if split { start } else { emit.bt_op };
|
||||
// Revealed OCR text that something is painted over goes on top of the page, where it starts
|
||||
// from the initial state.
|
||||
let widest = wrapped.iter().map(|(l, _, _)| advance.of(l)).fold(width, f64::max);
|
||||
let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright };
|
||||
let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at);
|
||||
let initial = TextState::initial();
|
||||
let drawn_from = if over { &initial } else { after };
|
||||
let plan = BlockPlan {
|
||||
block: &b,
|
||||
lines: &lines,
|
||||
style,
|
||||
emit,
|
||||
after: drawn_from,
|
||||
movement,
|
||||
state: o_state,
|
||||
tm,
|
||||
size,
|
||||
underline: style.underline == Some(true),
|
||||
};
|
||||
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 {
|
||||
@@ -940,7 +966,6 @@ pub(crate) fn rewrite_block_from(
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
|
||||
}
|
||||
let at = if split { start } else { emit.bt_op };
|
||||
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))
|
||||
@@ -957,12 +982,14 @@ pub(crate) fn rewrite_block_from(
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
})?;
|
||||
crate::draw_on_top(doc, &p, emit.ctm, &on_top)?;
|
||||
Ok(LineEdit { substituted: font.substituted })
|
||||
}
|
||||
|
||||
/// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a
|
||||
/// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text.
|
||||
/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it.
|
||||
/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one
|
||||
/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]).
|
||||
pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result<Vec<Op>, EditError> {
|
||||
if !block.invisible {
|
||||
return Ok(Vec::new());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::super::interp::TextState;
|
||||
use super::grouping::{across, is_centred, row_rect};
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
@@ -401,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB
|
||||
Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0)))
|
||||
}
|
||||
|
||||
/// How far below a row of `size` the next one is written: the source's leading unless a size
|
||||
/// or line spacing is chosen.
|
||||
fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 {
|
||||
if style.line_spacing.is_none() && style.size.is_none() {
|
||||
source_leading
|
||||
} else {
|
||||
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size)
|
||||
}
|
||||
}
|
||||
|
||||
type WrappedStyledRows = (StyledRows, RowRanges, Vec<usize>, Vec<bool>, Vec<usize>);
|
||||
|
||||
fn wrap_styled_rows(
|
||||
@@ -976,6 +987,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
let o = &lead.origin;
|
||||
// What is in force where the rewrite is drawn, and what the text after it expects.
|
||||
let after = if split { &o.state } else { &o.bt_state };
|
||||
let at = if split { start } else { o.bt_op };
|
||||
validate_contextual_rows(&rows, &kept)?;
|
||||
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?;
|
||||
@@ -989,6 +1001,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
|
||||
let (tx, ty) = back.apply(dx, dy);
|
||||
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
|
||||
let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0;
|
||||
// A new row can land one leading above the next paragraph and look like the same
|
||||
// paragraph. When every row is in this rewrite, a paragraph marker keeps them in one
|
||||
// scope so the neighbour stays out (#2517). A row left in the source stream cannot share
|
||||
@@ -1007,6 +1020,19 @@ pub(crate) fn rewrite_styled_block(
|
||||
})
|
||||
.rfind(|v| *v > 0.0)
|
||||
.unwrap_or(first.size * 1.5);
|
||||
// Revealed OCR text that something is painted over goes on top of the page, where it starts
|
||||
// from the initial state.
|
||||
let largest =
|
||||
block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max);
|
||||
// At least one line of the largest glyphs, so a larger range reaches up as far as it draws.
|
||||
const LINE_EM: f64 = 1.2;
|
||||
let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM);
|
||||
let widest = measured.iter().copied().fold(width, f64::max);
|
||||
// This path lays rows out along user-space baselines, so the paragraph is upright here.
|
||||
let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true };
|
||||
let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at);
|
||||
let initial = TextState::initial();
|
||||
let drawn_from = if over { &initial } else { after };
|
||||
let mut baseline = first.origin.baseline;
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i));
|
||||
@@ -1073,7 +1099,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
// object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is
|
||||
// drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next.
|
||||
block_ops.push(Op::new("q", vec![]));
|
||||
block_ops.extend(state.paint_ops(after));
|
||||
block_ops.extend(state.paint_ops(drawn_from, run_ctm));
|
||||
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
@@ -1164,21 +1190,17 @@ pub(crate) fn rewrite_styled_block(
|
||||
x += segment_width;
|
||||
pos = end;
|
||||
}
|
||||
baseline -= if style.line_spacing.is_none() && style.size.is_none() {
|
||||
source_leading
|
||||
} else {
|
||||
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
|
||||
};
|
||||
baseline -= row_step(style, anchor.size, source_leading);
|
||||
}
|
||||
push_whole_paragraph_marker(&mut block_ops, marked, "EMC");
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
let on_top = crate::lift_on_top(over, &mut block_ops);
|
||||
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()));
|
||||
}
|
||||
let at = if split { start } else { o.bt_op };
|
||||
// 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.
|
||||
@@ -1190,5 +1212,6 @@ pub(crate) fn rewrite_styled_block(
|
||||
d.set(b"Contents".to_vec(), contents);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
})?;
|
||||
crate::draw_on_top(doc, &p, o.ctm, &on_top)?;
|
||||
Ok(LineEdit { substituted })
|
||||
}
|
||||
@@ -207,3 +207,192 @@ fn visible_stretched_text_rewrites_unchanged() {
|
||||
let i = ((y * out.width + x) * 4) as usize;
|
||||
assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools
|
||||
/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image.
|
||||
fn ocr_under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
|
||||
under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET"))
|
||||
}
|
||||
|
||||
/// A page whose `text` content is followed by the opaque 200 × 100 scan image.
|
||||
fn under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
|
||||
under_scan_with(text, |_| pdfcraft_cos::Dict::new())
|
||||
}
|
||||
|
||||
/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`.
|
||||
fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc<Vec<u8>> {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
|
||||
let (w, h) = (200u32, 100u32);
|
||||
let mut rgb = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
|
||||
rgb.extend(if ink { INK } else { PAPER });
|
||||
}
|
||||
}
|
||||
let mut d = Document::new_empty();
|
||||
let pages = d.add(Object::Dict(Dict::new()));
|
||||
let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q");
|
||||
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
|
||||
let mut image = Dict::new();
|
||||
image.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
image.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
image.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
image.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||||
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
let image = d.add(Object::Stream(Stream::flate(image, &rgb)));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Dict(font));
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"Im".to_vec(), Object::Ref(image));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d)));
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
p.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
p.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
|
||||
let page = d.add(Object::Dict(p));
|
||||
d.update_dict(pages, |p| {
|
||||
p.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
p.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
let mut root = Dict::new();
|
||||
root.set(b"Type".to_vec(), Object::name("Catalog"));
|
||||
root.set(b"Pages".to_vec(), Object::Ref(pages));
|
||||
let root = d.add(Object::Dict(root));
|
||||
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
|
||||
std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
|
||||
}
|
||||
|
||||
/// The scanned word's box shows the cover and new ink, not the scan.
|
||||
fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) {
|
||||
let px = word_pixels(s, id);
|
||||
let paper = px.iter().filter(|p| near(**p, PAPER)).count();
|
||||
let ink = px.iter().filter(|p| near(**p, INK)).count();
|
||||
assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len());
|
||||
assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len());
|
||||
}
|
||||
|
||||
fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec<String> {
|
||||
s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect()
|
||||
}
|
||||
|
||||
fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) {
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap();
|
||||
}
|
||||
|
||||
/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image,
|
||||
/// not in the OCR text's place where the image would hide it (#3973). One line takes the
|
||||
/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and
|
||||
/// the edit survives redo, a save and reopen, and being edited again.
|
||||
#[test]
|
||||
fn an_ocr_layer_under_the_scan_image_shows_its_edit() {
|
||||
for edit in ["Jane", "Jane\nDoe"] {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap();
|
||||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(blocks[0].invisible, "the OCR layer is invisible text");
|
||||
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
|
||||
|
||||
edit_block(&mut s, id, 0, edit);
|
||||
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
|
||||
assert_edit_shows(&s, id, edit);
|
||||
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo");
|
||||
assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again");
|
||||
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again");
|
||||
s.redo(id).unwrap();
|
||||
assert_edit_shows(&s, id, &format!("{edit:?} redo"));
|
||||
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
let id = reopened.open("saved.pdf", None, saved, None).unwrap();
|
||||
assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened");
|
||||
assert_edit_shows(&reopened, id, &format!("{edit:?} reopened"));
|
||||
|
||||
edit_block(&mut reopened, id, 0, "Ann");
|
||||
assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again");
|
||||
assert_edit_shows(&reopened, id, &format!("{edit:?} edited again"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still
|
||||
/// goes on top of the scan, and the visible text stays.
|
||||
#[test]
|
||||
fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() {
|
||||
for edit in ["Jane", "Jane\nDoe"] {
|
||||
let mut s = Session::new();
|
||||
let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET");
|
||||
let id = s.open("split.pdf", None, page, None).unwrap();
|
||||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||||
let john = blocks.iter().position(|b| b.text == "John").unwrap();
|
||||
assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}");
|
||||
edit_block(&mut s, id, john, edit);
|
||||
let mut after = texts(&s, id);
|
||||
after.sort();
|
||||
let mut want = vec!["Head".to_string(), edit.replace('\n', " ")];
|
||||
want.sort();
|
||||
assert_eq!(after, want, "{edit:?}");
|
||||
assert_edit_shows(&s, id, &format!("{edit:?} split"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected
|
||||
/// in, black elsewhere.
|
||||
fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict {
|
||||
use pdfcraft_cos::{Dict, Object, Stream};
|
||||
let mut group = Dict::new();
|
||||
group.set(b"S".to_vec(), Object::name("Transparency"));
|
||||
group.set(b"CS".to_vec(), Object::name("DeviceRGB"));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
|
||||
form.set(b"Group".to_vec(), Object::Dict(group));
|
||||
let mut mask = Dict::new();
|
||||
mask.set(b"Type".to_vec(), Object::name("Mask"));
|
||||
mask.set(b"S".to_vec(), Object::name("Luminosity"));
|
||||
mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f")))));
|
||||
let mut state = Dict::new();
|
||||
state.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
state.set(b"SMask".to_vec(), Object::Dict(mask));
|
||||
let mut states = Dict::new();
|
||||
states.set(b"SM".to_vec(), Object::Dict(state));
|
||||
states
|
||||
}
|
||||
|
||||
/// The soft mask is selected before a translation, so it is placed by the CTM before it. The
|
||||
/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed
|
||||
/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders
|
||||
/// exactly as the same edit on the page without the mask. Both rewrite paths.
|
||||
#[test]
|
||||
fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() {
|
||||
for edit in ["Jane", "Jane\nDoe"] {
|
||||
let mut plain = Session::new();
|
||||
let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q");
|
||||
let plain_id = plain.open("plain.pdf", None, page, None).unwrap();
|
||||
edit_block(&mut plain, plain_id, 0, edit);
|
||||
let mut s = Session::new();
|
||||
let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state);
|
||||
let id = s.open("masked.pdf", None, page, None).unwrap();
|
||||
assert_eq!(texts(&s, id), ["John"]);
|
||||
edit_block(&mut s, id, 0, edit);
|
||||
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
|
||||
assert_edit_shows(&s, id, &format!("{edit:?} masked"));
|
||||
let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count();
|
||||
assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user