From e8e275cbd40ccd0dc6b56f82b16a575571043702 Mon Sep 17 00:00:00 2001 From: git-monchiant Date: Mon, 12 Oct 2026 14:28:27 +0700 Subject: [PATCH] Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable (#1217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable Chrome (Skia) writes /ActualText around each Thai, Indic or Arabic cluster whose shaped glyphs (a tone mark moved clear of a vowel, a ligature) have no Unicode of their own. Text extraction ignored it, so those marks were lost: "น้ำหนัก" came out as "นาหนัก", "เป็น" as "เปน", and searching for any such word found nothing. - hayro-interpret patch (25): Device::actual_text hands the device a marked content sequence's /ActualText, inline or named in /Properties. - TextDevice turns the glyphs of a replacement into one glyph carrying the replacement text, boxed around them. The outermost replacement wins, an empty one stands for no text, and one spanning several lines or writing directions keeps per-glyph text so selection and lines still work. - Canva draws text twice (outline or shadow) and marks only one drawing: the unmarked redraw at the same places takes the same replacement, so the existing redraw detection still drops the copy. Checked on 1,114 real-world PDFs against the previous build, with pdftotext as the reference: 957 extract byte-identical text, 157 change and all of them read closer to the reference (exact matches 3 -> 122), none got worse, none newly fail, and total extraction time is unchanged. Co-Authored-By: Claude Opus 5.5 * Text extraction: bound the work of the ActualText redraw dedupe `TextDevice::finish()` compared every replacement with every unmatched glyph in up to 4096 grid cells, so a hostile page stacking ~10^5 glyphs and ~10^5 replacements at one spot meant ~10^10 comparisons (a hang). It now charges each visited cell and each candidate run against a per-page budget (`MAX_REDRAW_WORK`, 2^22) and stops deduplicating once it is spent; redraws found so far are still merged. A regression test feeds 100,000 replacements and 100,000 non-matching glyphs at one place. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: git-monchiant <4667243+git-monchiant@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/render/src/text.rs | 346 +++++++++++++++++++- vendor/README.md | 1 + vendor/hayro-interpret/src/device.rs | 5 + vendor/hayro-interpret/src/interpret/mod.rs | 15 +- 4 files changed, 355 insertions(+), 12 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ddd7b9ac14..151bd63a55 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -353,6 +353,183 @@ type Drawn = (usize, [f64; 2], TextGlyph); struct TextDevice { /// Every glyph in content order. glyphs: Vec, + /// How many marked content sequences are open. + depth: usize, + /// The outermost open `/ActualText` replacement: its depth, text and the glyphs drawn inside + /// it (with no text of their own yet). + actual: Option<(usize, String, Vec)>, + /// Every `/ActualText` replacement made on the page. + replaced: Vec, +} + +/// An `/ActualText` replacement: the glyph standing for it in `TextDevice::glyphs` (none for an +/// empty replacement), its text, and the text and box of each replaced glyph that has Unicode of +/// its own. +struct Replaced { + at: Option, + text: String, + originals: Vec<(String, [f32; 4])>, +} + +/// Grid cell size, in points, for finding the glyphs drawn at a place. +const CELL: f32 = 8.0; + +/// Most grid cells searched around one place: a glyph so tall its tolerance spans more is skipped. +const MAX_CELLS: i64 = 4096; + +/// Most work (grid cells visited plus glyphs compared) the redraw dedupe in `TextDevice::finish` +/// may do on one page. A hostile page stacking ~10^5 glyphs and ~10^5 replacements at one spot +/// would otherwise make ~10^10 comparisons; past the budget the remaining replacements keep +/// their unmarked redraws (the text reads twice, as it would without `/ActualText`). +const MAX_REDRAW_WORK: usize = 1 << 22; + +fn cell(v: f32) -> i64 { + // Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates. + (v / CELL).floor() as i64 +} + +/// How far a redrawn glyph may sit from the one it redraws: a fifth of its height, as in `redraws`. +fn redraw_tolerance(rect: &[f32; 4]) -> f32 { + 0.2 * (rect[3] - rect[1]).max(0.1) +} + +/// One glyph carrying `text`, boxed around `drawn` in their writing direction; `None` when they +/// span several lines or writing directions (one box would break selection and line grouping). +fn merge(drawn: &[Drawn], text: String) -> Option { + let first = drawn.first()?; + let (quarter, u) = (first.0, first.1); + // Across the line: from the writing direction a quarter turn towards the line's bottom. + let n = [-u[1], u[0]]; + let along = |p: &[f32; 2]| f64::from(p[0]) * u[0] + f64::from(p[1]) * u[1]; + let across = |p: &[f32; 2]| f64::from(p[0]) * n[0] + f64::from(p[1]) * n[1]; + let height = across(&first.2.quad[0]) - across(&first.2.quad[3]); + let (mut s0, mut s1, mut t0, mut t1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); + for p in drawn.iter().flat_map(|d| d.2.quad.iter()) { + (s0, s1) = (s0.min(along(p)), s1.max(along(p))); + (t0, t1) = (t0.min(across(p)), t1.max(across(p))); + } + let one_line = drawn.iter().all(|d| compatible(u, d.1)) && t1 - t0 <= 2.0 * height.abs(); + if !(one_line && s0.is_finite() && s1.is_finite() && t0.is_finite() && t1.is_finite()) { + return None; + } + // The edge towards the next line is the one the first glyph's quad starts on. + let (near, far) = if height >= 0.0 { (t1, t0) } else { (t0, t1) }; + let point = |s: f64, t: f64| [(s * u[0] + t * n[0]) as f32, (s * u[1] + t * n[1]) as f32]; + let quad = [point(s0, near), point(s1, near), point(s1, far), point(s0, far)]; + let rect = quad.iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |r, p| { + [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])] + }); + Some((quarter, u, TextGlyph { text, rect, quad, direction: first.2.direction })) +} + +impl TextDevice { + fn new() -> Self { + TextDevice { glyphs: Vec::new(), depth: 0, actual: None, replaced: Vec::new() } + } + + /// Ends the open `/ActualText` replacement (ISO 32000-2 §14.9.4): its glyphs become one glyph + /// carrying the replacement text, boxed around them all. Chrome (Skia) writes one around + /// each Thai, Indic or Arabic cluster whose shaped glyphs (a tone mark moved clear of a vowel, + /// a ligature) have no Unicode of their own, so without it those marks are lost and the + /// words can't be found. + /// + /// A replacement spanning several lines or writing directions stays per glyph (each glyph's own + /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. + fn end_actual_text(&mut self) { + let Some((_, text, drawn)) = self.actual.take() else { return }; + let Some(glyph) = merge(&drawn, text.clone()) else { + self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); + return; + }; + let originals = drawn.iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())).map(|d| (d.2.text.clone(), d.2.rect)).collect(); + let at = if text.chars().all(|c| c.is_control()) { + None + } else { + self.glyphs.push(glyph); + Some(self.glyphs.len() - 1) + }; + self.replaced.push(Replaced { at, text, originals }); + } + + /// Ends the page. Some producers draw text twice, the copy for an outline, shadow or + /// glow, and mark only one drawing with `/ActualText` (Canva). The unmarked redraw of a + /// replacement's glyphs (the same text at the same places, in content order) takes the same + /// replacement, so the two read alike and line layout drops the copy as a redraw. + fn finish(&mut self) { + self.end_actual_text(); + if self.replaced.is_empty() { + return; + } + let mut taken = vec![false; self.glyphs.len()]; + for at in self.replaced.iter().filter_map(|r| r.at) { + if let Some(t) = taken.get_mut(at) { + *t = true; + } + } + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, g) in self.glyphs.iter().enumerate() { + if !taken.get(i).copied().unwrap_or(true) { + grid.entry((cell(g.2.rect[0]), cell(g.2.rect[1]))).or_default().push(i); + } + } + // Redraws found: the glyph range and the replacement standing for it. + let mut redraws: Vec<(std::ops::Range, usize)> = Vec::new(); + let mut budget = MAX_REDRAW_WORK; + 'replacements: for (k, r) in self.replaced.iter().enumerate() { + let Some((_, rect)) = r.originals.first() else { continue }; + let tol = redraw_tolerance(rect); + let (x0, x1, y0, y1) = (cell(rect[0] - tol), cell(rect[0] + tol), cell(rect[1] - tol), cell(rect[1] + tol)); + if x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1)) > MAX_CELLS { + continue; + } + // Checking one candidate walks its run twice (free, then same). + let candidate_cost = r.originals.len().saturating_mul(2).saturating_add(1); + for key in (x0..=x1).flat_map(|x| (y0..=y1).map(move |y| (x, y))) { + let Some(rest) = budget.checked_sub(1) else { break 'replacements }; + budget = rest; + for &i in grid.get(&key).map_or(&[][..], Vec::as_slice) { + let Some(rest) = budget.checked_sub(candidate_cost) else { break 'replacements }; + budget = rest; + let Some(range) = i.checked_add(r.originals.len()).map(|end| i..end) else { continue }; + let Some(run) = self.glyphs.get(range.clone()) else { continue }; + let free = taken.get(range.clone()).is_some_and(|t| t.iter().all(|t| !t)); + let same = run.iter().zip(&r.originals).all(|(g, (text, o))| { + let tol = redraw_tolerance(o); + g.2.text == *text && (g.2.rect[0] - o[0]).abs() < tol && (g.2.rect[1] - o[1]).abs() < tol + }); + if free && same { + if let Some(t) = taken.get_mut(range.clone()) { + t.fill(true); + } + redraws.push((range, k)); + } + } + } + } + if redraws.is_empty() { + return; + } + redraws.sort_by_key(|(range, _)| range.start); + let glyphs = std::mem::take(&mut self.glyphs); + let mut redraws = redraws.into_iter().peekable(); + let mut i = 0; + while let Some(g) = glyphs.get(i) { + let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else { + self.glyphs.push(g.clone()); + i += 1; + continue; + }; + let run = glyphs.get(range.clone()).unwrap_or_default(); + let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default(); + if !text.chars().all(|c| c.is_control()) { + match merge(run, text) { + Some(glyph) => self.glyphs.push(glyph), + None => self.glyphs.extend_from_slice(run), + } + } + i = range.end.max(i + 1); + } + } } impl<'a> Device<'a> for TextDevice { @@ -362,17 +539,24 @@ impl<'a> Device<'a> for TextDevice { fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) { - let Some(u) = glyph.as_unicode() else { - if std::env::var_os("PDFCRAFT_TEXT_DEBUG").is_some() { - eprintln!("NOUNICODE {} {:?}", if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" }, (transform * glyph_transform).as_coeffs()); + // Inside an `/ActualText` replacement only the glyph's box matters. + let replaced = self.actual.is_some(); + let text = match glyph.as_unicode() { + Some(hayro::hayro_interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), + Some(hayro::hayro_interpret::hayro_cmap::BfString::String(s)) => s, + None if replaced => String::new(), + None => { + if std::env::var_os("PDFCRAFT_TEXT_DEBUG").is_some() { + eprintln!( + "NOUNICODE {} {:?}", + if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" }, + (transform * glyph_transform).as_coeffs() + ); + } + return; } - return; }; - let text = match u { - hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(), - hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s, - }; - if text.chars().all(|c| c.is_control()) { + if !replaced && text.chars().all(|c| c.is_control()) { return; } // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em @@ -427,11 +611,29 @@ impl<'a> Device<'a> for TextDevice { quad: corners.map(|p| [p.x as f32, p.y as f32]), direction: direction.map(|d| d as f32), }; - self.glyphs.push((dir, direction, glyph)); + match &mut self.actual { + Some((_, _, drawn)) => drawn.push((dir, direction, glyph)), + None => self.glyphs.push((dir, direction, glyph)), + } } fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} fn pop_transparency_group(&mut self) {} + fn begin_marked_content(&mut self, _: &[u8], _: Option) { + self.depth = self.depth.saturating_add(1); + } + fn actual_text(&mut self, text: &[u8]) { + // A replacement nested in another is already covered by the outer one. + if self.actual.is_none() { + self.actual = Some((self.depth, pdfcraft_cos::PdfString::literal(text).to_text(), Vec::new())); + } + } + fn end_marked_content(&mut self) { + if self.actual.as_ref().is_some_and(|(depth, _, _)| *depth == self.depth) { + self.end_actual_text(); + } + self.depth = self.depth.saturating_sub(1); + } } /// Extract the text of one page. Panics inside the interpreter are the caller's to catch. @@ -445,8 +647,10 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting let settings = settings.clone(); let initial = p.initial_transform(true).to_kurbo(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); - let mut dev = TextDevice { glyphs: Vec::new() }; + let mut dev = TextDevice::new(); interpret_page(p, &mut ctx, &mut dev); + // A replacement the content never closed ends with the page. + dev.finish(); let glyphs = dev.glyphs; // Text on a curved path: glyphs that each continue the previous one's line while the // direction turns. Each such run is one line. @@ -1377,6 +1581,126 @@ mod tests { } } + /// One page per content stream, in Helvetica (`/F1`), with `/P1` in the resources' + /// `/Properties` naming the `/ActualText` "fi". + fn marked_pages(contents: &[&str]) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let pages = doc.new_object_id(); + let mut kids = Vec::new(); + for c in contents { + let content = doc.add_object(Stream::new(dictionary! {}, c.as_bytes().to_vec())); + let resources = dictionary! { + "Font" => dictionary! {"F1" => font}, + "Properties" => dictionary! {"P1" => dictionary! {"ActualText" => Object::string_literal("fi")}}, + }; + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => resources, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() + } + + fn marked_text(pdf: &Pdf, page: usize) -> PageText { + extract_page(pdf, page, &InterpreterSettings::default()).unwrap() + } + + #[test] + fn actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction() { + // Chrome marks each shaped Thai cluster this way: "น้ำ" (U+0E19 U+0E49 U+0E33) here stands + // for the two glyphs "xy", between "a" and "b". The same codes unmarked are the control. + let turns = ["1 0 0 1 100 150", "0 1 -1 0 150 100", "-1 0 0 -1 200 150", "0 -1 1 0 150 200"]; + let marked: Vec = + turns.iter().map(|m| format!("BT /F1 20 Tf {m} Tm (a) Tj /Span <>> BDC (xy) Tj EMC (b) Tj ET")).collect(); + let plain: Vec = turns.iter().map(|m| format!("BT /F1 20 Tf {m} Tm (axyb) Tj ET")).collect(); + let marked = marked_pages(&marked.iter().map(String::as_str).collect::>()); + let plain = marked_pages(&plain.iter().map(String::as_str).collect::>()); + for (page, turn) in turns.iter().enumerate() { + let text = marked_text(&marked, page); + let control = marked_text(&plain, page); + assert_eq!(control.plain_text(), "axyb", "Tm {turn}"); + assert_eq!(text.plain_text(), "aน้ำb", "Tm {turn}"); + assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::>(), ["a", "น้ำ", "b"], "Tm {turn}"); + assert_eq!(text.find("น้ำ"), vec![1..2], "Tm {turn}"); + assert_eq!(text.line_rects(0..3).len(), 1, "one line, Tm {turn}"); + // The replacement's box is the union of the boxes of the glyphs it replaces. + let (x, y) = (&control.glyphs[1].rect, &control.glyphs[2].rect); + let union = [x[0].min(y[0]), x[1].min(y[1]), x[2].max(y[2]), x[3].max(y[3])]; + for (got, want) in text.glyphs[1].rect.iter().zip(union) { + assert!((got - want).abs() < 0.01, "box {:?} ≠ {union:?}, Tm {turn}", text.glyphs[1].rect); + } + assert_eq!(text.glyphs[1].direction, control.glyphs[1].direction, "Tm {turn}"); + } + } + + #[test] + fn redraw_dedupe_work_is_bounded_on_hostile_pages() { + // 100,000 replacements and 100,000 unmarked glyphs that never match, all at one spot: + // unbounded, every replacement would compare every glyph (10^10 comparisons). + const N: usize = 100_000; + let glyph = |text: &str| { + let quad = [[100.0, 110.0], [110.0, 110.0], [110.0, 100.0], [100.0, 100.0]]; + (0, [1.0, 0.0], TextGlyph { text: text.into(), rect: [100.0, 100.0, 110.0, 110.0], quad, direction: [1.0, 0.0] }) + }; + let mut dev = TextDevice::new(); + // A genuine redraw first: it is still deduplicated within the budget. + dev.glyphs.push(glyph("fi")); + dev.glyphs.push(glyph("x")); + dev.replaced.push(Replaced { at: Some(0), text: "fi".into(), originals: vec![("x".into(), [100.0, 100.0, 110.0, 110.0])] }); + dev.glyphs.extend((0..N).map(|_| glyph("y"))); + for _ in 0..N { + dev.glyphs.push(glyph("z")); + let at = dev.glyphs.len() - 1; + dev.replaced.push(Replaced { at: Some(at), text: "z".into(), originals: vec![("q".into(), [100.0, 100.0, 110.0, 110.0])] }); + } + let started = std::time::Instant::now(); + dev.finish(); + assert!(started.elapsed() < std::time::Duration::from_secs(30), "took {:?}", started.elapsed()); + assert_eq!(dev.glyphs.len(), 2 * N + 2, "unmatched glyphs are kept"); + assert_eq!(dev.glyphs[1].2.text, "fi", "the redraw found within the budget takes the replacement"); + } + + #[test] + fn actual_text_follows_the_pdf_rules_for_marked_content() { + let pdf = marked_pages(&[ + // Named in the resources' /Properties. + "BT /F1 20 Tf 1 0 0 1 100 150 Tm (o) Tj /Span /P1 BDC (W) Tj EMC (ce) Tj ET", + // Nested: the outermost replacement covers everything inside it. + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC /Span <> BDC (x) Tj EMC (y) Tj EMC ET", + // Empty: the marked glyphs stand for no text (a soft hyphen at a line break); the gap + // the hyphen leaves still reads as one. + "BT /F1 20 Tf 1 0 0 1 100 150 Tm (co) Tj /Span <> BDC (-) Tj EMC (op) Tj ET", + // Across two lines: each glyph keeps its own text, so selection and lines still work. + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC (x) Tj 0 -40 Td (y) Tj EMC ET", + // Unbalanced: a stray EMC first, and a replacement the page never closes. + "EMC BT /F1 20 Tf 1 0 0 1 100 150 Tm (a) Tj /Span <> BDC (xy) Tj ET", + // Marked content without /ActualText changes nothing. + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /P <> BDC (ab) Tj EMC /Artifact BMC (c) Tj EMC ET", + ]); + let plain: Vec = (0..6).map(|p| marked_text(&pdf, p).plain_text()).collect(); + assert_eq!(plain, ["ofice", "AB", "co op", "x\ny", "afi", "abc"]); + } + + #[test] + fn an_unmarked_redraw_of_actual_text_reads_as_the_same_text() { + // Canva draws text twice (an outline or shadow) and marks only one drawing. The copy, + // before or after the marked one and half a point off, takes the same replacement and is + // dropped as a redraw; the same codes elsewhere on the page keep their own text. + let marked = "BT /F1 20 Tf 1 0 0 1 100 150 Tm (a) Tj /Span <>> BDC (xy) Tj EMC (b) Tj ET"; + let copy = "BT /F1 20 Tf 1 0 0 1 100.5 150.5 Tm (axyb) Tj ET"; + let elsewhere = "BT /F1 20 Tf 1 0 0 1 100 50 Tm (axyb) Tj ET"; + let pdf = marked_pages(&[&format!("{marked} {copy} {elsewhere}"), &format!("{copy} {marked} {elsewhere}")]); + for page in 0..2 { + assert_eq!(marked_text(&pdf, page).plain_text(), "aน้ำb\naxyb", "page {page}"); + } + } + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a diff --git a/vendor/README.md b/vendor/README.md index 30c4fa49ee..540238e80e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,6 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}` (`cargo test -p hayro`) | diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 8b511ca494..72d5a98098 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -59,6 +59,11 @@ pub trait Device<'a> { /// The tag is the marked content tag (e.g. b"P", b"Span"). The mcid is /// the marked content identifier from the properties dict, if present. fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option) {} + /// PdfCraft patch (25): the `/ActualText` of the marked content sequence that just began + /// (ISO 32000-2 §14.9.4), as the text string's raw bytes. Called right after + /// `begin_marked_content`; the text replaces everything drawn up to the matching + /// `end_marked_content`. + fn actual_text(&mut self, _text: &[u8]) {} /// Called at the end of a marked content sequence (EMC). fn end_marked_content(&mut self) {} } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 5db7e8b6bd..931b3388ce 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -18,7 +18,7 @@ use crate::x_object::{ use hayro_syntax::content::TypedIter; use hayro_syntax::content::ops::TypedInstruction; use hayro_syntax::object::dict::keys::{ - ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, + ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; use hayro_syntax::page::{ContentReader, Page, Resources}; @@ -615,6 +615,19 @@ fn interpret_window<'a>( } device.begin_marked_content(bdc.0, mcid); + + // PdfCraft patch (25): hand the device the sequence's `/ActualText`, from an inline + // property list or one named in the resources' `/Properties`. + let actual = dict_or_stream(bdc.1) + .map(|(props, _)| props.clone()) + .or_else(|| { + let name = bdc.1.clone().into_name()?; + resources.properties.get::>(name) + }) + .and_then(|props| props.get::>(ACTUAL_TEXT)); + if let Some(text) = actual { + device.actual_text(text.as_bytes()); + } } TypedInstruction::MarkedContentPointWithProperties(_) => {} TypedInstruction::EndMarkedContent(_) => {