From 1b62d47fa769cfd4c37a64c9933572fc5de52a1f Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:49:56 +0900 Subject: [PATCH] M3: find text in mixed horizontal and vertical PDFs (#287) Co-authored-by: Codex Co-authored-by: Brandon Thomas --- crates/render/src/text.rs | 97 +++++++++++++++++++++----- parity/acrobat-features.toml | 4 +- vendor/README.md | 2 + vendor/hayro-interpret/src/font/mod.rs | 6 ++ 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 624a956cf8..82291713cc 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -167,9 +167,8 @@ impl PageText { } struct TextDevice { - glyphs: Vec, - /// How many glyphs run in each view direction: right, down, left, up. - directions: [usize; 4], + /// Separate flows by writing direction: right, down, left, up. + glyphs: [Vec; 4], } impl<'a> Device<'a> for TextDevice { @@ -206,7 +205,14 @@ impl<'a> Device<'a> for TextDevice { Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; // The baseline direction in view space (y down), to the nearest quarter turn. - let (dx, dy) = (t.as_coeffs()[0], t.as_coeffs()[1]); + let coefficients = t.as_coeffs(); + let (dx, dy) = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) { + // PDF vertical fonts advance along negative glyph-space y (WMode 1), while + // keeping the glyph outline upright. Its x axis is not the writing direction. + (-coefficients[2], -coefficients[3]) + } else { + (coefficients[0], coefficients[1]) + }; let dir = if dx.abs() >= dy.abs() { if dx >= 0.0 { 0 } else { 2 } } else if dy > 0.0 { @@ -214,7 +220,6 @@ impl<'a> Device<'a> for TextDevice { } else { 3 }; - self.directions[dir] += text.chars().count(); let em = Rect::new(0.0, -200.0, advance, 800.0); let b = (t * em.to_path(0.1)).bounding_box(); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 { @@ -225,7 +230,7 @@ impl<'a> Device<'a> for TextDevice { let n = text.chars().count().max(1) as f64; let w = b.width() / n; let x0 = b.x0 + w * i as f64; - self.glyphs.push(TextGlyph { text: ch.to_string(), rect: [x0 as f32, b.y0 as f32, (x0 + w) as f32, b.y1 as f32] }); + self.glyphs[dir].push(TextGlyph { text: ch.to_string(), rect: [x0 as f32, b.y0 as f32, (x0 + w) as f32, b.y1 as f32] }); } } fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} @@ -244,18 +249,32 @@ 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(), directions: [0; 4] }; + let mut dev = TextDevice { glyphs: Default::default() }; interpret_page(p, &mut ctx, &mut dev); - // Lay out in the frame where most text runs left to right, so a page shown rotated (by - // `/Rotate` or by its content matrix) still reads along its lines; then map the boxes back. - // Pages whose `/Rotate` is cancelled by counter-rotated content stay as they are. - let quarter = (0..4u8).max_by_key(|q| (dev.directions[*q as usize], *q == 0)).unwrap_or(0); - for g in &mut dev.glyphs { - g.rect = to_upright(g.rect, quarter, w, h); + // A horizontal heading must not force a vertical body back into horizontal line grouping. + // Group each writing direction independently, then join flows from their topmost position. + let mut flows = Vec::new(); + for (quarter, mut glyphs) in dev.glyphs.into_iter().enumerate() { + if glyphs.is_empty() { + continue; + } + let top = glyphs.iter().map(|g| g.rect[1]).fold(f32::INFINITY, f32::min); + for g in &mut glyphs { + g.rect = to_upright(g.rect, quarter as u8, w, h); + } + let mut text = layout(glyphs); + for g in &mut text.glyphs { + g.rect = from_upright(g.rect, quarter as u8, w, h); + } + flows.push((top, text)); } - let mut text = layout(dev.glyphs); - for g in &mut text.glyphs { - g.rect = from_upright(g.rect, quarter, w, h); + flows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut text = PageText::default(); + for (_, flow) in flows { + let offset = text.line_of.last().map_or(0, |line| line.saturating_add(1)); + text.line_of.extend(flow.line_of.into_iter().map(|line| line.saturating_add(offset))); + text.glyphs.extend(flow.glyphs); + text.space_before.extend(flow.space_before); } Some(text) } @@ -508,6 +527,52 @@ pub fn layout(glyphs: Vec) -> PageText { mod tests { use super::*; + #[test] + fn identity_v_columns_are_searchable_in_reading_order() { + use lopdf::{Document, Object, Stream, dictionary}; + for encoding in ["Identity-V", "UniJIS-UCS2-V"] { + let mut doc = Document::with_version("1.7"); + let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 5 beginbfchar <0001> <65E5> <0002> <672C> <0003> <8A9E> <0004> <6587> <0005> <7AE0> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let cmap = if encoding == "Identity-V" { + cmap.to_vec() + } else { + let mut mapping = String::from_utf8(cmap.to_vec()).unwrap(); + for (code, unicode) in [("0001", "65E5"), ("0002", "672C"), ("0003", "8A9E"), ("0004", "6587"), ("0005", "7AE0")] { + mapping = mapping.replace(&format!("<{code}> <{unicode}>"), &format!("<{unicode}> <{unicode}>")); + } + mapping.into_bytes() + }; + let unicode = doc.add_object(Stream::new(dictionary! {}, cmap)); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVertical", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity" + }); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVertical", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let horizontal = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVertical", "Encoding" => "Identity-H", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let (first, second) = if encoding == "Identity-V" { ("000100020003", "00040005") } else { ("65E5672C8A9E", "65877AE0") }; + let operators = + format!("BT /H1 20 Tf 1 0 0 1 40 280 Tm <{first}> Tj /F1 20 Tf 1 0 0 1 200 250 Tm <{first}> Tj 1 0 0 1 170 250 Tm <{second}> Tj ET"); + let content = doc.add_object(Stream::new(dictionary! {}, operators.into_bytes())); + let pages = doc.new_object_id(); + 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" => dictionary! {"Font" => dictionary! {"F1" => font, "H1" => horizontal}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.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(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "日本語\n日本語\n文章"); + assert_eq!(text.find("日本語"), vec![0..3, 3..6]); + assert_eq!(text.find("文章"), vec![6..8]); + let rects = text.line_rects(3..6); + assert_eq!(rects.len(), 1); + assert!(rects[0][3] - rects[0][1] > rects[0][2] - rects[0][0], "a vertical search highlight: {rects:?}"); + assert!(text.glyphs[3].rect[0] > text.glyphs[6].rect[0], "columns read right to left in page view coordinates"); + } + } + #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0]; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0c410fc40b..aee0ee93b2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1346,8 +1346,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/ui.rs::find_reports_no_matches", "crates/automation/tests/automation.rs::open_inspect_render_and_find"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/ui.rs::find_reports_no_matches", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order"] +notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Complex interleaved flows still use a geometric reading-order heuristic." [[feature]] id = "view.find-next-previous" diff --git a/vendor/README.md b/vendor/README.md index 1224ec20ea..862f574487 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -15,3 +15,5 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream` | Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. + +Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 878a4b16a7..5ccf5c48a6 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -165,6 +165,12 @@ impl OutlineGlyph { self.id } + /// PdfCraft patch: whether this glyph belongs to a vertical-writing Type0 font. + /// The glyph outline stays upright; text extraction must follow the font's advance axis. + pub fn is_vertical(&self) -> bool { + matches!(&self.font, OutlineFont::Type0(font) if !font.is_horizontal()) + } + /// Get the advance width for this glyph. /// /// The advance width is how far to move horizontally after drawing