From a699e1330535bd0a7f6b8a79ea9bedefd97325bd Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:00:11 +0200 Subject: [PATCH 01/15] Word and HTML exports name the document's language Word export named no language, so Word checked the spelling of every export as English and underlined a whole document in any other language. The PDF already says its language in the catalog's /Lang. When that is a well formed language tag, Word gets it as the default language of the text (w:docDefaults), in the attribute Word keeps for its script: w:eastAsia for Chinese, Japanese and Korean, w:bidi for Arabic, Hebrew, Persian and Urdu, w:val for the rest. Both HTML exports, with and without CSS, get . Without /Lang, or with a malformed one, the files are exactly as before. docx_with_language, html_with_language and html_simple_with_language take the language; docx, html and html_simple stay as they are and call them with none. The engine reads /Lang through the existing initial view. The new export and engine tests fail without the change. --- crates/engine/src/compare.rs | 8 +++++--- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++ crates/export/src/as_docx.rs | 30 +++++++++++++++++++++++++--- crates/export/src/as_html.rs | 36 ++++++++++++++++++++++++++++++---- crates/export/src/lib.rs | 4 ++-- crates/export/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 8 ++++---- 7 files changed, 137 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 9be1954b09..0996ed76ee 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -300,11 +300,13 @@ impl crate::Document { let (pages, truncated_pages) = self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); + // Word and HTML name the document's language (`/Lang`), so spelling is checked in it. + let language = self.initial_view().language; let bytes = match format { - OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), + OfficeFormat::Docx => pdfcraft_export::docx_with_language(&pages, &title, language.as_deref()), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), - OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), - OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(), + OfficeFormat::Html => pdfcraft_export::html_with_language(&pages, &title, language.as_deref()).into_bytes(), + OfficeFormat::HtmlSimple => pdfcraft_export::html_simple_with_language(&pages, &title, language.as_deref()).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..9673f2beee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5912,6 +5912,35 @@ fn actions_fail_when_hidden_information_could_not_be_removed() { assert!(e.starts_with("Sanitize document:"), "{e}"); } +/// A PDF whose catalog names its language (`/Lang`) exports to Word and HTML in that language, so +/// Word checks the spelling in it, not in English; one that names none exports as before. +#[test] +fn office_exports_take_the_documents_language() { + let pdf = |catalog: &str| { + pdfcraft_testkit::Pdf::new() + .obj(catalog) + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>") + .stream("", b"BT /F1 12 Tf 20 200 Td (Hello) Tj ET") + .build() + }; + let mut s = Session::new(); + let bg = s.open("lang.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R /Lang (bg-BG) >>")), None).unwrap(); + let plain = s.open("plain.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R >>")), None).unwrap(); + let d = s.get(bg).unwrap(); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx == pdfcraft_export::docx_with_language(&d.export_pages(), "lang", Some("bg-BG")), "Word names bg-BG"); + assert!(docx != pdfcraft_export::docx(&d.export_pages(), "lang"), "Word names a language"); + for format in [compare::OfficeFormat::Html, compare::OfficeFormat::HtmlSimple] { + let html = String::from_utf8(d.export_office(format)).unwrap(); + assert!(html.contains(""), "{html}"); + } + let d = s.get(plain).unwrap(); + assert!(d.export_office(compare::OfficeFormat::Docx) == pdfcraft_export::docx(&d.export_pages(), "plain"), "no language, as before"); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("\n")); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::HtmlSimple)).unwrap().contains("\n")); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index ab7451dc0e..107f3676e2 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,5 +1,5 @@ use super::Zip; -use super::as_html::esc; +use super::as_html::{esc, language_tag}; use super::layout::{Image, Item, Page, Table, body_size, items}; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). @@ -228,9 +228,28 @@ fn word_background(pages: &[Page]) -> Option { (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) } +/// The `w:lang` that makes `tag` the language of a Word document's text. Word keeps one language +/// per script: Chinese, Japanese and Korean are its East Asian text, Arabic, Hebrew, Persian and +/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. +fn word_lang(tag: &str) -> String { + let attribute = match tag.split('-').next().unwrap_or_default().to_ascii_lowercase().as_str() { + "zh" | "ja" | "ko" => "w:eastAsia", + "ar" | "he" | "fa" | "ur" => "w:bidi", + _ => "w:val", + }; + format!("", esc(tag)) +} + /// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; /// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. pub fn docx(pages: &[Page], title: &str) -> Vec { + docx_with_language(pages, title, None) +} + +/// [`docx`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives +/// it): the default language of its text, so Word checks the spelling in that language rather +/// than in its own. A language that is not a well formed tag is left out. +pub fn docx_with_language(pages: &[Page], title: &str, language: Option<&str>) -> Vec { let mut body = String::new(); let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. @@ -338,11 +357,16 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin doc_rels.push_str(""); } doc_rels.push_str(""); - let styles = "\n\ + let defaults = language_tag(language) + .map(|tag| format!("{}", word_lang(tag))) + .unwrap_or_default(); + let styles = format!( + "\n{defaults}\ \ \ \ -"; +" + ); let core = format!( "\n{}", esc(title) diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 3bc6709e69..6ae767d09c 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -8,6 +8,20 @@ pub(crate) fn allowed(c: char) -> bool { matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') } +/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary +/// language of two or three letters, then subtags of one to eight letters or digits, joined by +/// hyphens, 35 characters at most. Anything else names no language. +pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> { + let tag = lang?.trim(); + let mut subtags = tag.split('-'); + let primary = subtags.next()?; + let well_formed = tag.len() <= 35 + && (2..=3).contains(&primary.len()) + && primary.bytes().all(|b| b.is_ascii_alphabetic()) + && subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric())); + well_formed.then_some(tag) +} + pub(crate) fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars().filter(|c| allowed(*c)) { @@ -38,17 +52,31 @@ pub(crate) fn base64(data: &[u8]) -> String { /// One HTML file: headings and paragraphs, tables as real `` elements, images inline, a /// rule between pages. pub fn html(pages: &[Page], title: &str) -> String { - html_with_mode(pages, title, false) + html_with_language(pages, title, None) } /// A simple HTML export with no CSS; page layout and colours are omitted. pub fn html_simple(pages: &[Page], title: &str) -> String { - html_with_mode(pages, title, true) + html_simple_with_language(pages, title, None) } -fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String { +/// [`html`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives +/// it), which the page names (``) for spell checking, speech and hyphenation. A +/// language that is not a well formed tag is left out. +pub fn html_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String { + html_with_mode(pages, title, false, language) +} + +/// [`html_simple`] for a document in `language`, named as [`html_with_language`] names it: a +/// language is not styling, so the simple export keeps it. +pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String { + html_with_mode(pages, title, true, language) +} + +fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { + let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( - "\n\n\n\n{}\n{}\n\n\n", + "\n\n\n\n{}\n{}\n\n\n", esc(title), if simple { "" diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 2b82919bbf..b8f10e7b68 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -22,14 +22,14 @@ mod as_markdown; mod as_rtf; mod layout; -pub use as_docx::docx; pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; +pub use as_docx::{docx, docx_with_language}; pub(crate) use as_html::esc; #[cfg(test)] use as_html::{allowed, base64}; -pub use as_html::{html, html_simple}; +pub use as_html::{html, html_simple, html_simple_with_language, html_with_language}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index f630d98d5d..d62148aaf2 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -245,6 +245,44 @@ pub(crate) fn part(zip: &[u8], name: &str) -> String { panic!("{name} missing") } +/// The PDF's language (`/Lang`) is the default language of the Word document's text, in the +/// attribute Word keeps for its script, and the HTML page's. No language, or one that is not a +/// well formed tag, leaves both files as they were. +#[test] +fn word_and_html_take_the_documents_language() { + let pages = [page()]; + let styles = |lang| part(&docx_with_language(&pages, "Report", lang), "word/styles.xml"); + let defaults = |lang: &str| format!("{lang}"); + assert!(styles(Some("bg-BG")).contains(&defaults("")), "{}", styles(Some("bg-BG"))); + assert!(styles(Some(" en-GB ")).contains(""), "the tag is trimmed"); + assert!(styles(Some("zh-Hant-TW")).contains(&defaults("")), "East Asian text"); + assert!(styles(Some("ja")).contains(""), "East Asian text"); + assert!(styles(Some("ar-EG")).contains(&defaults("")), "right-to-left text"); + assert!(styles(Some("HE")).contains(""), "right-to-left text"); + assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); + for lang in [ + None, + Some(""), + Some(" "), + Some("bg_BG"), + Some("b"), + Some("bulg-BG"), + Some("bg--BG"), + Some("bg-"), + Some("en-US-abcdefghi"), + Some("en-abcdefgh-abcdefgh-abcdefgh-abcdefgh"), + Some("en-\"x\""), + Some("1a-BG"), + Some("\u{431}\u{433}-BG"), + ] { + assert!(docx_with_language(&pages, "Report", lang) == docx(&pages, "Report"), "{lang:?}"); + assert_eq!(html_with_language(&pages, "Report", lang), html(&pages, "Report"), "{lang:?}"); + assert_eq!(html_simple_with_language(&pages, "Report", lang), html_simple(&pages, "Report"), "{lang:?}"); + } + // The simple export leaves out styling, not the language. + assert!(html_simple_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); +} + #[test] fn word_opens_text_with_control_characters_and_long_pages() { // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 252fb43fd2..36c415ec6b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7131,8 +7131,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7255,8 +7255,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. The page names the PDF's language (``, from a well formed `/Lang`). Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From 0cae1c518b863ab660272b375710a3261e1a3c34 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:10:15 +0200 Subject: [PATCH 02/15] A language tag longer than 35 characters is still a tag BCP 47 sets no maximum length; 35 characters is only the least a protocol must hold. A well formed tag such as en-US-u-ca-gregory-nu-latn-co-phonebk was left out of both exports. The bound is now 255, kept only so a PDF's /Lang can't put an unbounded string in the file. --- crates/export/src/as_html.rs | 8 ++++++-- crates/export/src/tests.rs | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 6ae767d09c..45f6ecafca 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -8,14 +8,18 @@ pub(crate) fn allowed(c: char) -> bool { matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') } +/// The longest language tag the exports write. BCP 47 sets no maximum (35 characters is only +/// the least a protocol must hold), so this bounds only what a PDF's `/Lang` can put in a file. +const LONGEST_TAG: usize = 255; + /// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary /// language of two or three letters, then subtags of one to eight letters or digits, joined by -/// hyphens, 35 characters at most. Anything else names no language. +/// hyphens, [`LONGEST_TAG`] characters at most. Anything else names no language. pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> { let tag = lang?.trim(); let mut subtags = tag.split('-'); let primary = subtags.next()?; - let well_formed = tag.len() <= 35 + let well_formed = tag.len() <= LONGEST_TAG && (2..=3).contains(&primary.len()) && primary.bytes().all(|b| b.is_ascii_alphabetic()) && subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric())); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index d62148aaf2..ba5f95f66d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -260,6 +260,11 @@ fn word_and_html_take_the_documents_language() { assert!(styles(Some("ar-EG")).contains(&defaults("")), "right-to-left text"); assert!(styles(Some("HE")).contains(""), "right-to-left text"); assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); + // A tag longer than 35 characters is still a tag. + let long = "en-US-u-ca-gregory-nu-latn-co-phonebk"; + assert!(styles(Some(long)).contains(&format!("")), "{long}"); + assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("")), "{long}"); + let too_long = format!("en{}", "-abcdefgh".repeat(29)); for lang in [ None, Some(""), @@ -270,7 +275,7 @@ fn word_and_html_take_the_documents_language() { Some("bg--BG"), Some("bg-"), Some("en-US-abcdefghi"), - Some("en-abcdefgh-abcdefgh-abcdefgh-abcdefgh"), + Some(too_long.as_str()), Some("en-\"x\""), Some("1a-BG"), Some("\u{431}\u{433}-BG"), From 561cc0d7079fef5d2f8b2a3af4715c91b3c1cb92 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:10:33 +0200 Subject: [PATCH 03/15] A language tag can't end in a singleton A singleton (one letter or digit) starts an extension, or with x private use, and BCP 47 needs a subtag after it. en-x and en-a were written as the document's language. They and a singleton straight after another are now left out; one-character subtags inside private use stay allowed. --- crates/export/src/as_html.rs | 29 +++++++++++++++++++++++------ crates/export/src/tests.rs | 6 ++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 45f6ecafca..8c250ca484 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -14,16 +14,33 @@ const LONGEST_TAG: usize = 255; /// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary /// language of two or three letters, then subtags of one to eight letters or digits, joined by -/// hyphens, [`LONGEST_TAG`] characters at most. Anything else names no language. +/// hyphens, [`LONGEST_TAG`] characters at most. A singleton (one letter or digit) starts an +/// extension, or with `x` private use, and needs a subtag after it. Anything else names no +/// language. pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> { let tag = lang?.trim(); let mut subtags = tag.split('-'); let primary = subtags.next()?; - let well_formed = tag.len() <= LONGEST_TAG - && (2..=3).contains(&primary.len()) - && primary.bytes().all(|b| b.is_ascii_alphabetic()) - && subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric())); - well_formed.then_some(tag) + if tag.len() > LONGEST_TAG || !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) { + return None; + } + // In private use every subtag is data, one character long or not. + let (mut private, mut singleton) = (false, false); + for s in subtags { + if !(1..=8).contains(&s.len()) || !s.bytes().all(|b| b.is_ascii_alphanumeric()) { + return None; + } + if s.len() == 1 && !private { + if singleton { + return None; + } + private = s.eq_ignore_ascii_case("x"); + singleton = true; + } else { + singleton = false; + } + } + (!singleton).then_some(tag) } pub(crate) fn esc(s: &str) -> String { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index ba5f95f66d..5820fc57bc 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -265,6 +265,8 @@ fn word_and_html_take_the_documents_language() { assert!(styles(Some(long)).contains(&format!("")), "{long}"); assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("")), "{long}"); let too_long = format!("en{}", "-abcdefgh".repeat(29)); + // Private use takes one-character subtags. + assert!(html_with_language(&pages, "Report", Some("en-x-a-b")).contains("")); for lang in [ None, Some(""), @@ -274,6 +276,10 @@ fn word_and_html_take_the_documents_language() { Some("bulg-BG"), Some("bg--BG"), Some("bg-"), + Some("en-x"), + Some("en-a"), + Some("en-a-x-private"), + Some("en-US-u-ca-x"), Some("en-US-abcdefghi"), Some(too_long.as_str()), Some("en-\"x\""), From a427cd2de59e981449ebdb8072493d69d15b5320 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:10:57 +0200 Subject: [PATCH 04/15] Word takes the script a language tag names pa-Arab-PK is Punjabi in Arabic script, which Word keeps as right-to-left text, but word_lang looked only at the language and wrote w:val. A script subtag (Arab, Hebr, Syrc, Thaa; Hans, Hant, Hani, Jpan, Kore, Hang, Hira, Kana; Latn, Cyrl, Grek) now decides over the language. It is read only in its own place, after the language and any extended languages, so private use such as en-x-arab names no script. --- crates/export/src/as_docx.rs | 20 ++++++++++++++++---- crates/export/src/tests.rs | 6 ++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 107f3676e2..4fa7edfce1 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -230,11 +230,23 @@ fn word_background(pages: &[Page]) -> Option { /// The `w:lang` that makes `tag` the language of a Word document's text. Word keeps one language /// per script: Chinese, Japanese and Korean are its East Asian text, Arabic, Hebrew, Persian and -/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. +/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. A +/// script the tag names (`pa-Arab`, `zh-Latn`) decides over its language. fn word_lang(tag: &str) -> String { - let attribute = match tag.split('-').next().unwrap_or_default().to_ascii_lowercase().as_str() { - "zh" | "ja" | "ko" => "w:eastAsia", - "ar" | "he" | "fa" | "ur" => "w:bidi", + let mut subtags = tag.split('-'); + let primary = subtags.next().unwrap_or_default().to_ascii_lowercase(); + // The script comes straight after the language and any three-letter extended languages; a + // singleton before it starts an extension or private use, which names no script. + let script = subtags + .find(|s| s.len() != 3 || !s.bytes().all(|b| b.is_ascii_alphabetic())) + .filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())) + .map(str::to_ascii_lowercase); + let attribute = match (script.as_deref(), primary.as_str()) { + (Some("arab" | "hebr" | "syrc" | "thaa"), _) => "w:bidi", + (Some("hans" | "hant" | "hani" | "jpan" | "kore" | "hang" | "hira" | "kana"), _) => "w:eastAsia", + (Some("latn" | "cyrl" | "grek"), _) => "w:val", + (_, "zh" | "ja" | "ko") => "w:eastAsia", + (_, "ar" | "he" | "fa" | "ur") => "w:bidi", _ => "w:val", }; format!("", esc(tag)) diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 5820fc57bc..baaf323117 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -259,6 +259,12 @@ fn word_and_html_take_the_documents_language() { assert!(styles(Some("ja")).contains(""), "East Asian text"); assert!(styles(Some("ar-EG")).contains(&defaults("")), "right-to-left text"); assert!(styles(Some("HE")).contains(""), "right-to-left text"); + // A script subtag decides over the language; private use names no script. + assert!(styles(Some("pa-Arab-PK")).contains(""), "Punjabi in Arabic script"); + assert!(styles(Some("sr-Latn")).contains(""), "Serbian in Latin script"); + assert!(styles(Some("zh-Latn")).contains(""), "Chinese in Latin script"); + assert!(styles(Some("en-x-arab")).contains(""), "private use"); + assert!(styles(Some("az-Latn-x-arab")).contains(""), "private use"); assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); // A tag longer than 35 characters is still a tag. let long = "en-US-u-ca-gregory-nu-latn-co-phonebk"; From 82116a5c7f37e4dac71858923240bf7ed783dba1 Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:27:24 -0400 Subject: [PATCH 05/15] fix(edit): an edited OCR phrase is drawn at one size (refs #3313) Recognize text sizes each word from its own box and a guess at how much of the em its letters fill, so one phrase's words come out several points apart (9 to 15.6 pt on a LaTeX scan). Editing a phrase drew and saved every word at its own size. An invisible block's size is now the median of its words' sizes (TextBlock::phrase_size). The styled rewrite draws every run at it and scales each word's width calibration by its own size over the phrase's, so words keep the width the scan shows them at; the single-style rewrite (a new family or bold) uses it in place of the first word's size; and the inline editor lays each word out at it while typing. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/text/blocks.rs | 7 +++ crates/edit/src/text/rewrite.rs | 9 ++-- crates/edit/src/text_regions/grouping.rs | 16 +++++++ crates/edit/src/text_regions/styled.rs | 19 +++++++- crates/engine/tests/it/ocr_edit.rs | 45 +++++++++++++++++++ crates/ui-egui/src/tools/edit_text_ui.rs | 43 ++++++++++++++++++ .../ui-egui/src/tools/edit_text_ui/layout.rs | 10 +++-- docs/editing-existing-text.md | 4 +- parity/acrobat-features.toml | 4 +- 9 files changed, 145 insertions(+), 12 deletions(-) diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 48fe956f4f..912fa873f4 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -37,6 +37,13 @@ pub struct TextBlock { } impl TextBlock { + /// The one size a rewrite and the editor set an OCR phrase in, in place of each word's own + /// (#3313): the block's size, which for invisible text is its words' median. `None` for + /// visible text, whose runs keep their sizes. + pub fn phrase_size(&self) -> Option { + (self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size) + } + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding /// source run. The texts are aligned word by word, so words left untouched between two edits /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index d27d23e0b5..b1111366f2 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -749,12 +749,13 @@ struct ParaStyle { k: f64, } -/// Spacing and scaling (text state), with the chosen values. -fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { +/// Spacing and scaling (text state), with the chosen values. `phrase` is the size an OCR phrase +/// is drawn at when none is chosen, in place of its first word's own (#3313). +fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option) -> Result<(String, ParaStyle), EditError> { let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let size = style.size.or(phrase).map_or(old_size, |pt| (pt / k).max(0.1)); let mut state = o.state.clone(); if let Some(c) = style.char_spacing { state.char_spacing = c / k; @@ -842,7 +843,7 @@ pub(crate) fn rewrite_block_from( let o = &first.origin; let last = members.last().copied().unwrap_or(first); let emit = &last.origin; - let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?; let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); let reuse = source.reuse; // The chosen style also determines the real Japanese fallback outlines and advances. diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 788499fafe..de7c47cd81 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -398,6 +398,11 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { for block in &mut blocks { block.reflow_text = reflow_text(block, lines); } + for block in blocks.iter_mut().filter(|block| block.invisible && block.lines.len() > 1) { + if let Some(size) = median_size(block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.size)) { + block.size = size; + } + } blocks } @@ -452,3 +457,14 @@ fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option< // measure leaves almost no room for the next word. (slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1) } + +/// The size an OCR phrase is set in. Recognize text sizes each word from its own box and a guess +/// at how much of the em its letters fill, so one phrase's words come out several points apart +/// (#3313); a rewrite draws the phrase at this one size. `None` when no size is usable. +fn median_size(sizes: impl Iterator) -> Option { + let mut sizes: Vec = sizes.filter(|size| size.is_finite() && *size > 0.0).collect(); + sizes.sort_by(f64::total_cmp); + let middle = sizes.len() / 2; + let upper = sizes.get(middle).copied()?; + if sizes.len().is_multiple_of(2) { sizes.get(middle.saturating_sub(1)).map(|lower| (lower + upper) / 2.0) } else { Some(upper) } +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7a9204a8c0..cc32894a54 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -321,6 +321,19 @@ fn calibration_factors( calibration } +/// The calibration factors fit each word to its box at its own size (its `Tf` in its text space, +/// which a range's size doesn't change). An OCR phrase drawn at one size needs the inverse ratio +/// too, so every word still spans the box the scan shows it in. +fn fit_to_phrase(calibration: &mut HashMap, lines: &[TextLine], phrase: Option) { + let Some(phrase) = phrase else { return }; + for (index, factor) in calibration.iter_mut() { + let own = lines.get(*index).and_then(|source| Some(source.origin.state.font.as_ref()?.1 * source.origin.k)); + if let Some(own) = own.filter(|own| own.is_finite() && *own > 0.0) { + *factor = (*factor * own / phrase).clamp(0.5, 2.0); + } + } +} + fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { let mut ranges: Vec<_> = style.ranges.iter().collect(); ranges.sort_by_key(|range| range.range.start); @@ -891,8 +904,9 @@ pub(crate) fn rewrite_styled_block( .prepare()?; let chars = prepared.values; let substituted = prepared.substituted; + let phrase_size = block.phrase_size(); let size_of = |source: &TextLine, index: usize| { - overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) + overrides.get(&index).and_then(|r| r.size).or(style.size).or(phrase_size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; // A source run may carry TJ kerning/justification that is absent from its text state. // Keep its measured advance when rebuilding the run instead of making it wrap on entry. @@ -910,7 +924,8 @@ pub(crate) fn rewrite_styled_block( spaces.borrow_mut().insert(name.to_owned(), w); w }; - let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); + let mut calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); + fit_to_phrase(&mut calibration, lines, phrase_size); let advance = |item: &(char, usize, String)| -> f64 { let source = lines.get(item.1).unwrap_or(first); let Some(m) = metrics.get(&item.2) else { return 0.0 }; diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 2d358e327e..fa33a84858 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -160,6 +160,51 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() { } } +/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill, +/// so one phrase's words come out several points apart (#3313). Here the first word is the +/// largest and the middle one the smallest; the phrase is set in 11 pt. +fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) { + scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 15.0), + scanned_word("Smith", 49.0, 78.0, 70.0, 9.0), + scanned_word("Example", 82.0, 124.0, 70.0, 11.0), + ]) +} + +/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps +/// the width the scan shows it at (#3313). +#[test] +fn an_edited_ocr_phrase_is_drawn_at_one_size() { + let (mut s, id) = uneven_phrase(); + let block = s.get(id).unwrap().text_blocks(0).remove(0); + assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size); + + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.trim()).collect::>(), ["John", "Example"]); + for line in &lines { + assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size); + } + // Each word keeps its width; the rest of the phrase closes up where Smith was. + assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); + assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect); + assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John"); +} + +/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from +/// the first run: that is the phrase's size too, not the first word's guess. +#[test] +fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() { + let (mut s, id) = uneven_phrase(); + let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() }; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert!(!lines.is_empty()); + for line in &lines { + assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size); + } +} + /// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write. fn stretched_visible() -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f2833de356..0a13a78309 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -1345,6 +1345,49 @@ C", &picked assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); } + /// Typing in a recognised phrase whose words OCR sized at 15, 9 and 11 pt lays every word out + /// at the phrase's one size, as the engine redraws it (#3313); each word used its own. + #[test] + fn typing_in_an_ocr_phrase_lays_every_word_out_at_one_size() { + use pdfcraft_engine::ocr::PlacedWord; + // A word box as OCR draws it around capitals: the cap height of a `size` pt word. + let word = |text: &str, x0: f64, x1: f64, size: f64| PlacedWord { + text: text.into(), + origin: [x0, 70.0], + across: [x1 - x0, 0.0], + up: [0.0, 0.718 * size], + confidence: None, + source: "ocrs".into(), + }; + let mut app = crate::PdfCraftApp::new(); + let png = pdfcraft_engine::export::encode_png(200, 100, &[235u8; 200 * 100 * 4]).unwrap(); + let bytes = app.session.create_from_images(&[("scan.png".into(), png)]).unwrap(); + app.open_bytes("scan.pdf", None, bytes.as_ref().clone()).unwrap(); + let id = app.views[0].id; + let words = vec![word("JOHN", 20.0, 45.0, 15.0), word("SMITH", 49.0, 78.0, 9.0), word("EXAMPLE", 82.0, 124.0, 11.0)]; + app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap(); + let doc = app.session.get(id).unwrap(); + let block = doc.text_blocks(0).remove(0); + assert!(block.invisible && block.lines.len() == 3, "one phrase of three recognised words: {block:?}"); + let (_, mut ed) = preview_app(); + ed.text = format!("{}S", block.editor_text); + ed.original = block.editor_text.clone(); + ed.look = look_of(&block); + ed.look0 = look_of(&block); + ed.source = Some((block, doc.text_lines(0))); + + let ctx = egui::Context::default(); + let mut sizes = Vec::new(); + ctx.run_ui(egui::RawInput::default(), |ui| { + let job = editor_job(ui, &ed, &ed.text, 1.0, f32::INFINITY); + sizes = job.sections.iter().map(|section| section.format.font_id.size).collect(); + }) + .textures_delta + .clear(); + assert!(sizes.len() >= 3, "a section for each word: {sizes:?}"); + assert!(sizes.iter().all(|size| (size - 11.0).abs() < 0.05), "every word at the phrase's 11 pt: {sizes:?}"); + } + #[test] fn a_refused_paragraph_stays_open_with_its_text() { let (mut app, mut ed) = preview_app(); diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 1946519203..ebcd923c6a 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -126,10 +126,14 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, if centered { job.halign = egui::Align::Center; } + // An OCR phrase is set in one size, as the engine redraws it: each word's own is a guess + // from its box. Its words still keep their widths. + let phrase = block.phrase_size(); for (range, index) in block.edited_spans(text) { let Some(line) = lines.get(index) else { continue }; - let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); - look.size = line.size; + let base = phrase.unwrap_or(line.size); + let mut look = source_look(&line.base_font, base, line.color, line.bold, line.italic); + look.size = base; if ed.look.family != ed.look0.family || ed.family_chosen { look.family = ed.look.family; } @@ -153,7 +157,7 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, font.family = FontFamily::Name(name.clone().into()); } let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x); - let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale; + let target_width = ((line.rect[2] - line.rect[0]) * look.size / base.max(0.01)) as f32 * scale; let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 }; job.append( text.get(range).unwrap_or_default(), diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 426058f7b7..d5776e0646 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -89,7 +89,9 @@ On a scanned page, recognised text (Scan & OCR) is an invisible layer over the p text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or the one chosen) and first covers the old words with the paper colour sampled around them. Each -word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The +word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. +OCR sizes each word from its own box, so a phrase's words can come out several points apart: the +editor and the rewrite set the whole phrase in one size, the median of its words' sizes. The cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface. This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7582b3f6bc..8d4960599a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6755,8 +6755,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text", "ocr.correct"] tools = ["text_edit", "ocr_recognize"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_edited_ocr_phrase_is_drawn_at_one_size", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping, and is set in one size, the median of its words' (OCR sizes each word from its own box, several points apart), both while typing and when applied (#3313). Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 7cb8ca3c01bb21e70dfb9137184be1a6cbdc43ad Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:53:26 -0400 Subject: [PATCH 06/15] fix(edit): OCR phrase size counts words and keeps widths past 2x (refs #3313) Review follow-ups: - The phrase's median counts each word once: neighbouring words OCR sized alike share a run, so 15, 9 and 9 pt gave 12 pt, not 9. - The styled rewrite's width calibration is not clamped again after the phrase ratio, so a word sized at more than twice the phrase keeps the width the scan shows it at. - The single-style rewrite (a new family or bold) scales the first word's Tz by its own size over the phrase's, so the line is as wide whatever size OCR guessed for that word. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/text/rewrite.rs | 5 +++ crates/edit/src/text_regions/grouping.rs | 8 +++- crates/edit/src/text_regions/styled.rs | 8 +++- crates/engine/tests/it/ocr_edit.rs | 50 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index b1111366f2..0a2941076b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -763,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option) -> Resul if let Some(sc) = style.scale { state.scale = (sc / 100.0).clamp(0.1, 10.0); } + // At the phrase's size, the first word's text matrix still carries its own width: scale it + // back, so the line is as wide as the scan whatever size OCR guessed for that word. + if phrase.is_some() && style.size.is_none() && style.scale.is_none() { + state.scale = (state.scale * old_size / size).clamp(0.1, 10.0); + } Ok((font_name, ParaStyle { state, size, old_size, k })) } diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index de7c47cd81..69b3a16850 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -399,7 +399,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { block.reflow_text = reflow_text(block, lines); } for block in blocks.iter_mut().filter(|block| block.invisible && block.lines.len() > 1) { - if let Some(size) = median_size(block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.size)) { + // Each word counts once: neighbouring words OCR sized alike share a run. + let words = block + .lines + .iter() + .filter_map(|i| lines.get(*i)) + .flat_map(|line| std::iter::repeat_n(line.size, line.text.split_whitespace().count().max(1))); + if let Some(size) = median_size(words) { block.size = size; } } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index cc32894a54..444cfdd22a 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -323,13 +323,17 @@ fn calibration_factors( /// The calibration factors fit each word to its box at its own size (its `Tf` in its text space, /// which a range's size doesn't change). An OCR phrase drawn at one size needs the inverse ratio -/// too, so every word still spans the box the scan shows it in. +/// too, so every word still spans the box the scan shows it in. The ratio is not clamped again: +/// a word OCR sized at three times the phrase's needs three times the calibration. fn fit_to_phrase(calibration: &mut HashMap, lines: &[TextLine], phrase: Option) { let Some(phrase) = phrase else { return }; for (index, factor) in calibration.iter_mut() { let own = lines.get(*index).and_then(|source| Some(source.origin.state.font.as_ref()?.1 * source.origin.k)); if let Some(own) = own.filter(|own| own.is_finite() && *own > 0.0) { - *factor = (*factor * own / phrase).clamp(0.5, 2.0); + let fitted = *factor * own / phrase; + if fitted.is_finite() { + *factor = fitted; + } } } } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index fa33a84858..c0dd805ddd 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -205,6 +205,56 @@ fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() { } } +/// Two neighbouring words OCR sized alike join into one run; the median still counts each word, +/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs. +#[test] +fn words_joined_into_one_run_each_count_toward_the_phrase_size() { + let (s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 15.0), + scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0), + scanned_word("JONES", 82.0, 112.0, 70.0, 9.0), + ]); + let doc = s.get(id).unwrap(); + assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run"); + let size = doc.text_blocks(0)[0].size; + assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}"); +} + +/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan. +#[test] +fn a_word_far_larger_than_its_phrase_keeps_its_width() { + let (mut s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 25.0), + scanned_word("Smith", 49.0, 78.0, 70.0, 10.0), + scanned_word("Example", 82.0, 124.0, 70.0, 10.5), + ]); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines[0].text.trim(), "John"); + assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); +} + +/// With a new font the phrase is one run in its first word's text state. How wide it is must not +/// depend on how far OCR misjudged that word's size: the same boxes give the same line. +#[test] +fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() { + let width = |first: f64| { + let (mut s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, first), + scanned_word("Smith", 49.0, 78.0, 70.0, 11.0), + scanned_word("Example", 82.0, 124.0, 70.0, 11.0), + ]); + let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() }; + // Short enough not to wrap in the phrase's box either way. + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap(); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1, "one line: {lines:?}"); + lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min) + }; + let (guessed, right) = (width(15.0), width(11.0)); + assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}"); +} + /// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write. fn stretched_visible() -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; From 9121f65564d8bedb9cbffc15c9ea817442abf59c Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:48:12 -0600 Subject: [PATCH 07/15] M10: OCR models load from bytes; recognised lines travel as JSON (ocr.ocr-web) The web build has no model files: it downloads them. `Ocr::from_bytes` loads the pair only when each file's SHA-256 matches ATTRIBUTION.toml (rten's ONNX parser can spin forever on bytes that aren't a model), and `OcrsRecognizer::from_ocr` wraps the result as an engine that stays available without files. The web build's workers send what they read back as JSON: `lines_to_json` / `lines_from_json` carry each word's box and, when it has one, its confidence; the reader credits the words to the engine the worker ran, and refuses anything else (a missing or non-finite number, too many words) with an error. --- Cargo.lock | 12 ++++ crates/ocr/Cargo.toml | 2 + crates/ocr/src/lib.rs | 139 ++++++++++++++++++++++++++++++++++++++++- crates/ocr/src/ocrs.rs | 42 +++++++++++-- 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6710dddeea..9cfe8c9c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4540,10 +4540,22 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "rten", + "serde_json", + "sha2 0.11.1", "tempfile", "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-ocr-worker" +version = "0.18.1" +dependencies = [ + "js-sys", + "pdfcraft-ocr", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "pdfcraft-optimize" version = "0.18.1" diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 1b04dbb522..60e66d46c9 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -14,6 +14,8 @@ workspace = true pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } +serde_json.workspace = true +sha2 = "0.11" thiserror.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index ff5b920dca..aac95f18c6 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -29,7 +29,9 @@ pub mod tesseract; pub use confidence::{ConfidenceBand, band_for, is_suspect, low_confidence}; pub use merge::{MergeStrategy, StrategyOutcome, mean_confidence, recognize_with_strategy, rover_merge}; -pub use ocrs::{ALPHABET, DETECTION_MODEL, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL}; +pub use ocrs::{ + ALPHABET, DETECTION_MODEL, DETECTION_MODEL_SHA256, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256, +}; pub use pdfcraft_fonts::TextPlan; pub use pdfcraft_fonts::helvetica_width; @@ -464,6 +466,58 @@ pub fn text_layer(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan) -> Vec< out } +/// The recognised lines as JSON, `[[["word", left, top, right, bottom], …], …]`, with the +/// word's confidence as a sixth item when it has one: how the web build's OCR workers send what +/// they found back to the page. +pub fn lines_to_json(lines: &[Line]) -> String { + let word = |w: &Word| { + let mut items = vec![serde_json::json!(w.text), w.rect[0].into(), w.rect[1].into(), w.rect[2].into(), w.rect[3].into()]; + items.extend(w.confidence.map(serde_json::Value::from)); + serde_json::Value::Array(items) + }; + serde_json::Value::Array(lines.iter().map(|l| serde_json::Value::Array(l.words.iter().map(word).collect())).collect()).to_string() +} + +/// At most this many words come back for one page (a dense page has a few thousand). +const MAX_JSON_WORDS: usize = 100_000; + +/// [`lines_to_json`] read back, the words credited to `source` (the engine the worker ran). +/// Anything else (a word without its box, a box or confidence that isn't a finite number, too +/// many words) is an error rather than a guess. +pub fn lines_from_json(json: &str, source: &str) -> Result, OcrError> { + let bad = |why: &str| OcrError::Recognize(format!("unreadable result: {why}")); + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| bad(&e.to_string()))?; + let lines = value.as_array().ok_or_else(|| bad("not a list of lines"))?; + let number = |v: &serde_json::Value| v.as_f64().map(|v| v as f32).filter(|v| v.is_finite()); + let mut count = 0usize; + let mut out = Vec::with_capacity(lines.len().min(MAX_JSON_WORDS)); + for line in lines { + let mut words = Vec::new(); + for word in line.as_array().ok_or_else(|| bad("a line is not a list of words"))? { + count += 1; + if count > MAX_JSON_WORDS { + return Err(bad("too many words")); + } + let (text, rect, confidence) = match word.as_array().map(Vec::as_slice) { + Some([serde_json::Value::String(text), l, t, r, b]) => (text, [l, t, r, b], None), + Some([serde_json::Value::String(text), l, t, r, b, c]) => (text, [l, t, r, b], Some(c)), + _ => return Err(bad("a word is not [text, left, top, right, bottom, confidence?]")), + }; + let mut r = [0f32; 4]; + for (slot, v) in r.iter_mut().zip(rect) { + *slot = number(v).ok_or_else(|| bad("a word's box is not finite numbers"))?; + } + let mut w = Word::new(text.clone(), r, source); + if let Some(c) = confidence { + w = w.with_confidence(number(c).ok_or_else(|| bad("a word's confidence is not a finite number"))?); + } + words.push(w); + } + out.push(Line { words }); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -854,6 +908,89 @@ mod tests { assert_eq!(ok.gray(-1, -1), Some(255)); } + /// The web build loads the models from downloaded bytes; they read the same text. + #[test] + fn reads_rendered_text_with_models_from_bytes() { + let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else { + eprintln!("skipped: ONNX OCR models not installed"); + return; + }; + let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap(); + let rec = OcrsRecognizer::from_ocr(ocr); + assert!(rec.available(), "a recogniser loaded from bytes needs no model files"); + let (w, h, px) = test_image::hello(); + let lines = rec.recognize(&OcrImage::new(w, h, px).unwrap(), &RecognizeOptions::default()).unwrap(); + let text: Vec = lines.iter().map(Line::text).collect(); + assert!(text.iter().any(|t| t.contains("HELL") && t.contains("WOR")), "{text:?}"); + } + + /// The web build's workers send lines back as JSON; they arrive unchanged, credited to the + /// engine the worker ran. + #[test] + fn lines_survive_json() { + let lines = vec![ + Line { + words: vec![ + Word::new("Total \"€250\"", [1.5, 2.0, 30.25, 12.0], "ocrs"), + Word::new("due", [32.0, 2.0, 50.0, 12.0], "ocrs").with_confidence(87.5), + ], + }, + Line::default(), + Line { words: vec![Word::new("日本語", [0.0, 20.0, 9.0, 30.0], "ocrs")] }, + ]; + assert_eq!(lines_from_json(&lines_to_json(&lines), "ocrs").unwrap(), lines); + assert_eq!(lines_from_json(&lines_to_json(&[]), "ocrs").unwrap(), Vec::::new()); + let other = lines_from_json(&lines_to_json(&lines), "tesseract").unwrap(); + assert!(other.iter().flat_map(|l| &l.words).all(|w| w.source == "tesseract")); + } + + /// A result that isn't what a worker sends is an error, not a crash or a guess. + #[test] + fn malformed_json_lines_are_an_error() { + for json in [ + "", + "{}", + "[1]", + "[[1]]", + "[[[\"a\", 1, 2, 3]]]", + "[[[\"a\", 1, 2, 3, 4, 5, 6]]]", + "[[[1, 1, 2, 3, 4]]]", + "[[[\"a\", \"1\", 2, 3, 4]]]", + "[[[\"a\", 1e999, 2, 3, 4]]]", + "[[[\"a\", null, 2, 3, 4]]]", + "[[[\"a\", 1, 2, 3, 4, null]]]", + "[[[\"a\", 1, 2, 3, 4, \"90\"]]]", + ] { + assert!(matches!(lines_from_json(json, "ocrs"), Err(OcrError::Recognize(_))), "{json}"); + } + let many = format!("[[{}]]", vec!["[\"a\",0,0,1,1]"; MAX_JSON_WORDS + 1].join(",")); + assert!(lines_from_json(&many, "ocrs").is_err()); + let deep = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000)); + assert!(lines_from_json(&deep, "ocrs").is_err()); + } + + /// A download that isn't a model (an HTML error page, a truncated file) is an error, not a + /// crash or a hang. + #[test] + fn models_from_bad_bytes_are_an_error() { + for (detection, recognition) in [(Vec::new(), Vec::new()), (b"".to_vec(), b"RTEN".to_vec()), (vec![0xff; 64], vec![0; 64])] { + assert!(matches!(Ocr::from_bytes(detection, recognition), Err(OcrError::Load(..)))); + } + } + + /// The hashes [`Ocr::from_bytes`] checks are the ones `cargo xtask models` verifies. + #[test] + fn model_hashes_match_attribution() { + let manifest = include_str!("../../../ATTRIBUTION.toml"); + for (file, sha256) in [(DETECTION_MODEL, DETECTION_MODEL_SHA256), (RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256)] { + let entry = manifest + .split("[[fetched]]") + .find(|e| e.contains(&format!("file = \"{file}\""))) + .unwrap_or_else(|| panic!("{file} is not in ATTRIBUTION.toml")); + assert!(entry.lines().any(|l| l == format!("sha256 = \"{sha256}\"")), "{file}: SHA-256 differs from ATTRIBUTION.toml"); + } + } + mod test_image { /// "HELLO WORLD" drawn with blocky 5×7 letters, 4 pixels per dot, black on white. pub fn hello() -> (u32, u32, Vec) { diff --git a/crates/ocr/src/ocrs.rs b/crates/ocr/src/ocrs.rs index 7f8feb47c4..94f2abfa18 100644 --- a/crates/ocr/src/ocrs.rs +++ b/crates/ocr/src/ocrs.rs @@ -32,6 +32,15 @@ pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")]; /// The model files, as named in ATTRIBUTION.toml. pub const DETECTION_MODEL: &str = "text-detection.onnx"; pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; +/// Their SHA-256s, as in ATTRIBUTION.toml. [`Ocr::from_bytes`] loads nothing else: rten's ONNX +/// parser can spin forever on bytes that aren't a model (64 × 0xff does). +pub const DETECTION_MODEL_SHA256: &str = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"; +pub const RECOGNITION_MODEL_SHA256: &str = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"; + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -84,9 +93,25 @@ pub struct Ocr { impl Ocr { pub fn load(models: &Models) -> Result { let load = |p: &Path| rten::Model::load_file(p).map_err(|e| OcrError::Load(p.display().to_string(), e.to_string())); + Self::with_models(load(&models.detection)?, load(&models.recognition)?) + } + + /// Load the models from the contents of their files, where there is no file system (the web + /// build downloads them). Bytes that aren't the files ATTRIBUTION.toml names are an error. + pub fn from_bytes(detection: Vec, recognition: Vec) -> Result { + let load = |name: &str, sha256: &str, data: Vec| { + if sha256_hex(&data) != sha256 { + return Err(OcrError::Load(name.into(), "not the expected model file (SHA-256 differs)".into())); + } + rten::Model::load(data).map_err(|e| OcrError::Load(name.into(), e.to_string())) + }; + Self::with_models(load(DETECTION_MODEL, DETECTION_MODEL_SHA256, detection)?, load(RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256, recognition)?) + } + + fn with_models(detection: rten::Model, recognition: rten::Model) -> Result { let params = ocrs::OcrEngineParams { - detection_model: Some(load(&models.detection)?), - recognition_model: Some(load(&models.recognition)?), + detection_model: Some(detection), + recognition_model: Some(recognition), alphabet: Some(ALPHABET.into()), ..Default::default() }; @@ -139,12 +164,21 @@ impl Ocr { /// The ocrs engine as a [`Recognizer`] (the default engine). pub struct OcrsRecognizer { engine: Ocr, + /// Loaded from bytes ([`OcrsRecognizer::from_ocr`]), not from model files: it stays + /// available without them. + in_memory: bool, } impl OcrsRecognizer { /// Load the models from `models`. pub fn load(models: &Models) -> Result { - Ok(OcrsRecognizer { engine: Ocr::load(models)? }) + Ok(OcrsRecognizer { engine: Ocr::load(models)?, in_memory: false }) + } + + /// A recogniser loaded some other way than from model files: the web build downloads the + /// models and loads them with [`Ocr::from_bytes`]. + pub fn from_ocr(engine: Ocr) -> OcrsRecognizer { + OcrsRecognizer { engine, in_memory: true } } /// Load the models found by [`Models::find`]. @@ -159,7 +193,7 @@ impl Recognizer for OcrsRecognizer { } fn available(&self) -> bool { - Models::find().is_some() + self.in_memory || Models::find().is_some() } fn languages(&self) -> Vec<(String, String)> { From 0bfb50a853fa75aebe6814d2ca2de350c3b4a55e Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:48:12 -0600 Subject: [PATCH 08/15] M10: read OCR jobs a page at a time; install a downloaded engine (ocr.ocr-web) `OcrJob::steps` splits `run` where the engine reads: `OcrSteps::next_page` renders the page, crops it to the region and preprocesses it, an engine reads `OcrShot::image`, and `OcrShot::place` maps the words back through the preprocessing and the crop. `run` is the same loop with `OcrShot::read`, so desktop results don't change. With only one engine, every strategy is that engine's reading, which is what the web build's workers return. `FileJob` splits `recognize_file` the same way (open, read, finish), keeping the skip-files probe on opening. `ocr::install` puts a recogniser loaded from downloaded bytes in the engine cache, so `recognizers`, `available`, `engine_unavailable_reason` and `engines_status` see it. --- crates/engine/src/ocr.rs | 539 ++++++++++++++++++++++++++----------- crates/engine/src/tests.rs | 29 ++ 2 files changed, 409 insertions(+), 159 deletions(-) diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index c4854d7e9f..2977406bdf 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -15,7 +15,8 @@ use pdfcraft_render::text::TextFilter; use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind}; pub use pdfcraft_ocr::{ - ConfidenceBand, LANGUAGES, MergeStrategy, Models, Ocr, OcrError, PlacedWord, Recognizer, band_for, is_suspect, low_confidence, + ConfidenceBand, DETECTION_MODEL, LANGUAGES, Line, MergeStrategy, Models, Ocr, OcrError, OcrImage, PlacedWord, RECOGNITION_MODEL, Recognizer, + StrategyOutcome, Word, band_for, is_suspect, lines_from_json, low_confidence, }; use crate::{DocId, Edit, EditError, Session}; @@ -257,6 +258,23 @@ struct EngineCache { ocrs: Option>, } +/// The engines built so far (see [`recognizers`] and [`install`]). +static ENGINES: Mutex> = Mutex::new(None); + +/// Share an ocrs recogniser loaded some other way than from the model files [`Models::find`] +/// looks for: the web build downloads the models and loads them with [`Ocr::from_bytes`]. From +/// then on [`recognizers`] hands it out and the engine counts as available. +pub fn install(ocr: Ocr) -> Arc { + let ocrs: Arc = Arc::new(pdfcraft_ocr::OcrsRecognizer::from_ocr(ocr)); + ENGINES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| EngineCache { ocrs: None }).ocrs = Some(ocrs.clone()); + ocrs +} + +/// Whether an ocrs recogniser has been loaded (from the model files, or by [`install`]). +fn ocrs_loaded() -> bool { + ENGINES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|c| c.ocrs.is_some()) +} + /// How long a negative engine probe (no program there) is remembered. A short TTL, not /// forever: a tesseract installed while the app runs is found by the next job, without a /// restart. An engine that was found is remembered for the session. @@ -408,10 +426,11 @@ fn models_installed_cached() -> bool { found } -/// The web build has no disk search worth caching. +/// The web build has no model files: the models count as installed once [`install`] has +/// loaded the ones it downloaded. #[cfg(target_arch = "wasm32")] fn models_installed_cached() -> bool { - Models::find().is_some() + ocrs_loaded() } /// The program's `--version` line, remembered per program path for the session (`ocr_status` @@ -473,7 +492,7 @@ pub fn engines_status() -> Vec { /// takes a location, because an agent must not choose which program runs; the tools report the /// default settings.) pub fn engines_status_for(settings: &OcrSettings) -> Vec { - let ocrs = Models::find().is_some(); + let ocrs = ocrs_loaded() || Models::find().is_some(); let mut out = vec![EngineStatus { id: "ocrs", available: ocrs, why_not: (!ocrs).then(models_missing), program: None, version: None }]; #[cfg(not(target_arch = "wasm32"))] { @@ -524,7 +543,6 @@ pub fn recognizers(settings: &OcrSettings) -> Result { let configured = configured_tesseract(settings); let tesseract = if matches!(settings.engine, EngineChoice::Tesseract | EngineChoice::Auto) { tesseract_engine(configured.as_deref()) } else { None }; - static ENGINES: Mutex> = Mutex::new(None); let mut slot = ENGINES.lock().unwrap_or_else(|e| e.into_inner()); let cache = slot.get_or_insert_with(|| EngineCache { ocrs: None }); if cache.ocrs.is_none() { @@ -567,7 +585,7 @@ fn models_missing() -> String { /// never waits. pub fn available() -> bool { let configured = std::env::var_os("PDFCRAFT_TESSERACT").map(|v| v.to_string_lossy().into_owned()); - Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some() + ocrs_loaded() || Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some() } /// Why `engine` cannot run right now, named for the UI's tooltips (the Engine dropdown @@ -703,138 +721,226 @@ impl OcrJob { /// Read the pages. `progress(done, total)` is called before each page; returning `false` /// stops (the pages read so far are returned). pub fn run(self, recognizers: &Recognizers, mut progress: impl FnMut(usize, usize) -> bool) -> Vec { - let config = - RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() }; - let mut r = PageRenderer::new(self.bytes.clone(), config); - let mut out = Vec::new(); let total = self.pages.len(); - let options = pdfcraft_ocr::RecognizeOptions { language: self.settings.language.clone() }; - for (done, &page) in self.pages.iter().enumerate() { - if !progress(done, total) { - break; + let mut steps = self.steps(); + let mut out = Vec::new(); + while progress(steps.done(), total) { + match steps.next_page() { + None => break, + Some(Err(skipped)) => out.push(skipped), + Some(Ok(shot)) => out.push(shot.read(recognizers)), } - let Some(info) = self.infos.get(page) else { continue }; - let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() }; - // The boxes of all text on the page, in view space: an earlier OCR layer's, and words - // only /ActualText names, so neither is read again. - let existing: Vec<[f32; 4]> = r - .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) - .text - .map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect()) - .unwrap_or_default(); - if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() && has_own_text(&mut r, page) { - out.push(skip("the page already contains text")); - continue; - } - // A scan is read at its own resolution (enlarging it only blurs the letters), at - // most the chosen one. - let scale = self.render_scale(page); - let shot = r.render(RenderRequest { page, scale, ..Default::default() }); - if let Some(e) = shot.error { - out.push(skip(&e)); - continue; - } - let full = match pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()) { - Ok(img) => img, - Err(e) => { - out.push(skip(&e.to_string())); - continue; - } - }; - // The optional region: crop the rendered page, clamped; an empty region skips the - // page with a named reason (nothing was asked of it). - let (image, offset) = match self.settings.region { - None => (full, [0.0f32; 2]), - Some(region) => match clamp_region(region, shot.width, shot.height) { - Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) { - Some(c) => (c, [x0 as f32, y0 as f32]), - None => { - out.push(skip("the selected region is empty")); - continue; - } - }, - None => { - out.push(skip("the selected region is outside the page")); - continue; - } - }, - }; - // Preprocessing sits between the page raster and recognition; its word boxes come - // back in processed coordinates and are mapped to the ORIGINAL raster's before - // placement. The render dpi is the real one (pixels per point × 72), never a - // fixed value. - let pre = pdfcraft_ocr::preprocess::preprocess( - image, - scale * 72.0, - &pdfcraft_ocr::preprocess::PreprocessOptions { - auto_rotate: self.settings.auto_rotate, - deskew: self.settings.deskew, - denoise: self.settings.denoise, - binarize: self.settings.binarize, - }, - ); - let (image, inverse) = match pre { - Ok(p) => (p.image, p.inverse_transform), - Err(e) => { - out.push(skip(&e.to_string())); - continue; - } - }; - let outcome = match pdfcraft_ocr::recognize_with_strategy( - recognizers.primary.as_ref(), - recognizers.secondary.as_deref(), - self.settings.strategy, - &image, - &options, - ) { - Ok(o) => o, - Err(e) => { - out.push(skip(&e.to_string())); - continue; - } - }; - let scale = shot.width as f32 / info.width.max(1e-3); - let to_user = |x: f32, y: f32| { - // processed pixels → original raster pixels (the exact inverse) → past the - // region crop → page view space. - let (ox, oy) = inverse.apply(f64::from(x), f64::from(y)); - let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale); - [u as f64, v as f64] - }; - // Validate recognition geometry and compare against existing text in view space, - // undoing preprocessing and region offsets before testing overlap. - let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| { - let [l, t, r, b] = w.rect; - let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold( - [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], - |mut bounds, (x, y)| { - let (x, y) = inverse.apply(f64::from(x), f64::from(y)); - let x = (x as f32 + offset[0]) / scale; - let y = (y as f32 + offset[1]) / scale; - bounds[0] = bounds[0].min(x); - bounds[1] = bounds[1].min(y); - bounds[2] = bounds[2].max(x); - bounds[3] = bounds[3].max(y); - bounds - }, - ); - covered(bounds, &existing) < ALREADY_ON_PAGE - }); - let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::>(); - let mut notes = outcome.notes; - // Only visible editable output needs display glyphs. Searchable output preserves - // every Unicode character with empty Type3 programs and ToUnicode maps. - if self.settings.output_mode == OutputMode::EditableText && !words.is_empty() { - let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing(); - if missing > 0 { - notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output")); - } - } - out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None, notes }); } progress(total, total); out } + + /// Read the pages one at a time, preparing each page's image and recognising it separately: + /// the web build prepares pages on the page and recognises them in Web Workers. + pub fn steps(self) -> OcrSteps { + let config = + RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() }; + let renderer = PageRenderer::new(self.bytes.clone(), config); + OcrSteps { job: self, renderer, next: 0 } + } +} + +/// [`OcrJob`] a page at a time: [`OcrSteps::next_page`] renders the next page, crops it to the +/// region and preprocesses it; an engine reads [`OcrShot::image`], and [`OcrShot::place`] puts +/// the words found on the page ([`OcrShot::read`] does both with the job's engines). +pub struct OcrSteps { + job: OcrJob, + renderer: PageRenderer, + next: usize, +} + +impl OcrSteps { + /// The number of pages to read. + pub fn total(&self) -> usize { + self.job.pages.len() + } + + /// The number of pages prepared (or skipped) so far. + pub fn done(&self) -> usize { + self.next + } + + /// The settings the job reads with. + pub fn settings(&self) -> &OcrSettings { + &self.job.settings + } + + /// Prepare the next page for recognition. `Err` is a page that isn't read (it already has + /// text, or couldn't be rendered or prepared), with the reason; `None` once every page is + /// done. + pub fn next_page(&mut self) -> Option> { + loop { + let page = *self.job.pages.get(self.next)?; + self.next += 1; + let Some(info) = self.job.infos.get(page).cloned() else { continue }; + return Some(self.prepare(page, info)); + } + } + + fn prepare(&mut self, page: usize, info: PageInfo) -> Result { + let settings = &self.job.settings; + let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() }; + let r = &mut self.renderer; + // The boxes of all text on the page, in view space: an earlier OCR layer's, and words + // only /ActualText names, so neither is read again. + let existing: Vec<[f32; 4]> = r + .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) + .text + .map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect()) + .unwrap_or_default(); + if settings.skip_text_pages && !settings.force_ocr && !existing.is_empty() && has_own_text(r, page) { + return Err(skip("the page already contains text")); + } + // A scan is read at its own resolution (enlarging it only blurs the letters), at + // most the chosen one. + let scale = self.job.render_scale(page); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = shot.error { + return Err(skip(&e)); + } + let full = pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()).map_err(|e| skip(&e.to_string()))?; + // The optional region: crop the rendered page, clamped; an empty region skips the + // page with a named reason (nothing was asked of it). + let (image, offset) = match settings.region { + None => (full, [0.0f32; 2]), + Some(region) => match clamp_region(region, shot.width, shot.height) { + Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) { + Some(c) => (c, [x0 as f32, y0 as f32]), + None => return Err(skip("the selected region is empty")), + }, + None => return Err(skip("the selected region is outside the page")), + }, + }; + // Preprocessing sits between the page raster and recognition; its word boxes come + // back in processed coordinates and are mapped to the ORIGINAL raster's before + // placement. The render dpi is the real one (pixels per point × 72), never a + // fixed value. + let pre = pdfcraft_ocr::preprocess::preprocess( + image, + scale * 72.0, + &pdfcraft_ocr::preprocess::PreprocessOptions { + auto_rotate: settings.auto_rotate, + deskew: settings.deskew, + denoise: settings.denoise, + binarize: settings.binarize, + }, + ) + .map_err(|e| skip(&e.to_string()))?; + Ok(OcrShot { + page, + image: Some(pre.image), + inverse: pre.inverse_transform, + offset, + pixels_per_point: shot.width as f32 / info.width.max(1e-3), + info, + existing, + strategy: settings.strategy, + options: pdfcraft_ocr::RecognizeOptions { language: settings.language.clone() }, + output_mode: settings.output_mode, + }) + } +} + +/// A page prepared for recognition: the image an engine reads, and what placing the words +/// found in it needs. +pub struct OcrShot { + pub page: usize, + /// The page's pixels as the engines read them: rendered, cropped to the region and + /// preprocessed (`None` once [`OcrShot::take_image`] has handed them on). + image: Option, + /// Processed pixels → the rendered raster's (the exact inverse of preprocessing). + inverse: pdfcraft_ocr::preprocess::InverseTransform, + /// Where the region crop starts in the rendered raster. + offset: [f32; 2], + /// The rendered raster's pixels per point of page view space. + pixels_per_point: f32, + info: PageInfo, + /// The boxes of the page's own text, in view space. + existing: Vec<[f32; 4]>, + strategy: MergeStrategy, + options: pdfcraft_ocr::RecognizeOptions, + output_mode: OutputMode, +} + +impl OcrShot { + /// What the engines read (`None` once taken). + pub fn image(&self) -> Option<&OcrImage> { + self.image.as_ref() + } + + /// Hand the image on (the web build sends it to a worker), keeping what placing the words + /// needs: the page's pixels need not stay in memory while the worker reads them. + pub fn take_image(&mut self) -> Option { + self.image.take() + } + + /// Recognise the image with `recognizers` (per the job's strategy) and place the words. + pub fn read(&self, recognizers: &Recognizers) -> OcrPage { + let Some(image) = &self.image else { return self.place(Err("the page's image was handed to another reader".into())) }; + let outcome = pdfcraft_ocr::recognize_with_strategy( + recognizers.primary.as_ref(), + recognizers.secondary.as_deref(), + self.strategy, + image, + &self.options, + ); + self.place(outcome.map_err(|e| e.to_string())) + } + + /// Put the lines recognised in [`Self::image`] on the page (or skip the page with the + /// error). A single engine's reading, which is all the web build's workers run, is + /// `StrategyOutcome { lines, ..Default::default() }`: every strategy reads that way when + /// there is no second engine. + pub fn place(&self, outcome: Result) -> OcrPage { + let page = self.page; + let outcome = match outcome { + Ok(o) => o, + Err(e) => return OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(e), notes: Vec::new() }, + }; + let (info, inverse, offset, scale) = (&self.info, self.inverse, self.offset, self.pixels_per_point); + let to_user = |x: f32, y: f32| { + // processed pixels → original raster pixels (the exact inverse) → past the + // region crop → page view space. + let (ox, oy) = inverse.apply(f64::from(x), f64::from(y)); + let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale); + [u as f64, v as f64] + }; + // Validate recognition geometry and compare against existing text in view space, + // undoing preprocessing and region offsets before testing overlap. + let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| { + let [l, t, r, b] = w.rect; + let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold( + [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], + |mut bounds, (x, y)| { + let (x, y) = inverse.apply(f64::from(x), f64::from(y)); + let x = (x as f32 + offset[0]) / scale; + let y = (y as f32 + offset[1]) / scale; + bounds[0] = bounds[0].min(x); + bounds[1] = bounds[1].min(y); + bounds[2] = bounds[2].max(x); + bounds[3] = bounds[3].max(y); + bounds + }, + ); + covered(bounds, &self.existing) < ALREADY_ON_PAGE + }); + let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::>(); + let mut notes = outcome.notes; + // Only visible editable output needs display glyphs. Searchable output preserves + // every Unicode character with empty Type3 programs and ToUnicode maps. + if self.output_mode == OutputMode::EditableText && !words.is_empty() { + let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing(); + if missing > 0 { + notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output")); + } + } + OcrPage { page, words, already_on_page: already.len(), skipped: None, notes } + } } /// Whether a word's pixel box may reach the content stream: width and height (right − left, @@ -1095,36 +1201,70 @@ pub fn recognize_file( recognizers: &Recognizers, progress: impl FnMut(usize, usize) -> bool, ) -> Result { - let mut s = Session::new(); - let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; - if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { - return Err(why); + match FileJob::open(name, bytes, password, settings)? { + Opened::Skipped(result) => Ok(result), + Opened::Read(mut file) => { + let job = file.job.take().ok_or("the document could not be read")?; + let pages = job.run(recognizers, progress); + file.finish(pages) + } } - let job = s.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?; - // The skip-files probe runs before any engine work: a file with text needs no engine at - // all, and the original bytes come back unchanged. - if settings.skip_text_files && !settings.force_ocr && job.has_text() { - let pages = job - .pages - .iter() - .map(|&page| OcrPage { - already_on_page: 0, - page, - words: Vec::new(), - skipped: Some("the file already contains text".into()), - notes: Vec::new(), - }) - .collect(); - return Ok(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) }); +} + +/// [`recognize_file`] in two halves, for callers that read the pages themselves (the web build +/// reads them in Web Workers): [`FileJob::open`], read `job`, then [`FileJob::finish`]. +pub struct FileJob { + session: Session, + id: DocId, + bytes: Arc>, + /// The pages to read (taken by the caller). + pub job: Option, +} + +/// What [`FileJob::open`] found: a file to read, or one the skip-files option passes over +/// (its result is final, and no engine is needed). +pub enum Opened { + Read(Box), + Skipped(FileResult), +} + +impl FileJob { + pub fn open(name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings) -> Result { + let mut session = Session::new(); + let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; + if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) { + return Err(why); + } + let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?; + // The skip-files probe runs before any engine work: a file with text needs no engine at + // all, and the original bytes come back unchanged. + if settings.skip_text_files && !settings.force_ocr && job.has_text() { + let pages = job + .pages + .iter() + .map(|&page| OcrPage { + already_on_page: 0, + page, + words: Vec::new(), + skipped: Some("the file already contains text".into()), + notes: Vec::new(), + }) + .collect(); + return Ok(Opened::Skipped(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) })); + } + Ok(Opened::Read(Box::new(FileJob { session, id, bytes, job: Some(job) }))) } - let pages = job.run(recognizers, progress); - if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { - return Ok(FileResult { bytes, pages, file_skipped: None }); + + /// Add the words found and save: the searchable file, or the original when nothing was found. + pub fn finish(mut self, pages: Vec) -> Result { + if self.session.apply_ocr(self.id, &pages).map_err(|e| e.to_string())? == 0 { + return Ok(FileResult { bytes: self.bytes, pages, file_skipped: None }); + } + // Checked like a save before the caller writes it (#2166). + let bytes = self.session.save_bytes(self.id).map_err(|e| e.to_string())?; + self.session.check_save(self.id, &bytes).map_err(|e| e.to_string())?; + Ok(FileResult { bytes, pages, file_skipped: None }) } - // Checked like a save before the caller writes it (#2166). - let bytes = s.save_bytes(id).map_err(|e| e.to_string())?; - s.check_save(id, &bytes).map_err(|e| e.to_string())?; - Ok(FileResult { bytes, pages, file_skipped: None }) } #[cfg(test)] @@ -1639,6 +1779,87 @@ mod tests { assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text")); } + /// The web build prepares pages with [`OcrJob::steps`] and has a worker read each image, + /// its lines coming back as JSON: the pages it ends with are the ones [`OcrJob::run`] gives, + /// with a region crop and preprocessing between the render and the engine. + #[test] + fn reading_page_by_page_matches_a_run() { + let mut s = Session::new(); + let bytes = s.create_from_text("t", "One page\nanother page").unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + let settings = OcrSettings { force_ocr: true, deskew: true, binarize: true, region: Some([40.0, 30.0, 900.0, 700.0]), ..Default::default() }; + let (r, fake) = fake_recognizers(); + let run = s.ocr_job(id, &[], settings.clone()).unwrap().run(&r, |_, _| true); + assert!(run.iter().all(|p| p.skipped.is_none() && p.words.len() == 1), "{run:?}"); + let mut steps = s.ocr_job(id, &[], settings).unwrap().steps(); + assert_eq!((steps.done(), steps.total()), (0, run.len())); + let mut stepped = Vec::new(); + while let Some(next) = steps.next_page() { + let shot = next.unwrap(); + let json = pdfcraft_ocr::lines_to_json(&fake.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap()); + let lines = lines_from_json(&json, "fake").map_err(|e| e.to_string()); + stepped.push(shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() }))); + } + assert_eq!(steps.done(), run.len()); + assert_eq!(stepped, run); + // An engine's failure skips the page with the reason, as in a run. + let mut steps = s.ocr_job(id, &[0], OcrSettings { force_ocr: true, ..Default::default() }).unwrap().steps(); + let mut shot = steps.next_page().unwrap().unwrap(); + let page = shot.place(Err("worker gone".into())); + assert_eq!(page.skipped.as_deref(), Some("worker gone")); + // Once the image is handed on, it can't be read here as well. + assert!(shot.take_image().is_some() && shot.image().is_none()); + assert!(shot.read(&r).skipped.is_some()); + assert!(steps.next_page().is_none()); + } + + /// A page the job doesn't read comes out of [`OcrSteps::next_page`] as skipped, with its + /// reason, before any engine is involved. + #[test] + fn page_by_page_skips_text_pages_without_an_engine() { + let mut s = Session::new(); + let bytes = s.create_from_text("t", "A page with text").unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + let mut steps = s.ocr_job(id, &[], OcrSettings::default()).unwrap().steps(); + let Some(Err(page)) = steps.next_page() else { panic!("the text page should be skipped") }; + assert_eq!(page.skipped.as_deref(), Some("the page already contains text")); + } + + /// The web build has no model files: a recogniser loaded from downloaded bytes is installed + /// and from then on is the engine every job reads with. + #[test] + fn an_installed_recognizer_is_available_and_handed_out() { + let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else { + eprintln!("skipped: ONNX OCR models not installed"); + return; + }; + let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap(); + let installed = install(ocr); + assert!(installed.available()); + assert!(available()); + let r = recognizers(&OcrSettings::default()).unwrap(); + assert!(Arc::ptr_eq(&r.primary, &installed), "the installed recogniser is the one handed out"); + assert!(engines_status().iter().any(|e| e.id == "ocrs" && e.available)); + } + + /// The batch's halves: a file to read opens with its job, and [`FileJob::finish`] saves + /// the searchable copy; a file the skip-files option passes over is final on opening. + #[test] + fn a_file_job_reads_in_two_halves() { + let s = Session::new(); + let bytes = s.create_from_text("t", "Already typed here").unwrap(); + let (r, fake) = fake_recognizers(); + let skip = OcrSettings { skip_text_files: true, ..Default::default() }; + let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") }; + assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text")); + let force = OcrSettings { force_ocr: true, ..Default::default() }; + let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") }; + let pages = file.job.take().unwrap().run(&r, |_, _| true); + let out = file.finish(pages).unwrap(); + assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None)); + assert_eq!(out, recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap(), "the same as reading it in one call"); + } + /// Two inputs of one name (from different folders, or differing only in case) would /// write the same output: both are found, each once. #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d230d098de..41912df9c5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5681,6 +5681,35 @@ fn backgrounds_and_watermarks_from_files() { assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err()); } +/// The web build reads a page at a time (prepared on the page, recognised in a worker, the +/// lines sent back as JSON); with the real ocrs engine that finds the same words as reading in +/// one go (needs the models: `cargo xtask models`; skipped without them). +#[test] +fn reading_a_page_at_a_time_finds_the_same_words() { + let settings = ocr::OcrSettings::default(); + let Ok(engines) = ocr::recognizers(&settings) else { + eprintln!("skipped: OCR models not installed"); + return; + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "Invoice 42: pay by Friday").unwrap(); + let typed = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(typed).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png.clone()), ("scan2.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, scan, None).unwrap(); + let at_once = s.ocr_job(id, &[], settings.clone()).unwrap().run(&engines, |_, _| true); + let mut steps = s.ocr_job(id, &[], settings).unwrap().steps(); + let mut one_by_one = Vec::new(); + while let Some(next) = steps.next_page() { + let shot = next.unwrap(); + let lines = engines.primary.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap(); + let sent = ocr::lines_from_json(&pdfcraft_ocr::lines_to_json(&lines), engines.primary.id()).map_err(|e| e.to_string()); + one_by_one.push(shot.place(sent.map(|lines| ocr::StrategyOutcome { lines, ..Default::default() }))); + } + assert_eq!(one_by_one, at_once); + assert!(at_once.len() == 2 && at_once.iter().all(|p| p.skipped.is_none() && !p.words.is_empty()), "{at_once:?}"); +} + /// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models: /// `cargo xtask models`; skipped without them). #[test] From a10906e99101cd25eebdac69c05ff9ae5344ef64 Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:48:12 -0600 Subject: [PATCH 09/15] M10: Recognize Text in the browser, read in Web Workers (ocr.ocr-web) The web build downloads the OCR models the first time Recognize Text (or Edit on a scanned page) needs them, installs the engine, and reads pages in up to two Web Workers (apps/pdfcraft-ocr-worker) built with WebAssembly SIMD, so the page keeps responding. The page prepares each page and sends its image to an idle worker, which runs ocrs and sends the lines back. Where no worker can start, the page reads with the installed engine. Answers go back to the run that sent the page (ocr_route), so a single run and a batch can overlap. Recognize Text in Multiple Files on the web keeps main's skipped-files bucket, low-confidence notes and cancel between files. Files chosen before the models are in wait for them. A scan Edit finds while no engine can read it waits for the models instead of counting as no scan. Trunk builds the worker beside the app; `cargo xtask web-worker-link` hashes its files and names it in index.html. The web zip ships the models in models/. --- .cargo/config.toml | 5 + apps/pdfcraft-ocr-worker/Cargo.toml | 18 + apps/pdfcraft-ocr-worker/src/main.rs | 101 +++++ apps/pdfcraft-web/Trunk.toml | 7 + apps/pdfcraft-web/index.html | 2 + crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/app.rs | 4 + crates/ui-egui/src/canvas.rs | 5 + crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/workflows.rs | 4 + crates/ui-egui/src/workflows/ocr_route.rs | 139 ++++++ crates/ui-egui/src/workflows/ocr_ui.rs | 270 ++++++++++- crates/ui-egui/src/workflows/ocr_web.rs | 521 ++++++++++++++++++++++ packaging/web/package.sh | 9 +- xtask/src/layers.rs | 3 +- xtask/src/main.rs | 6 + xtask/src/web.rs | 145 ++++++ 17 files changed, 1224 insertions(+), 28 deletions(-) create mode 100644 apps/pdfcraft-ocr-worker/Cargo.toml create mode 100644 apps/pdfcraft-ocr-worker/src/main.rs create mode 100644 crates/ui-egui/src/workflows/ocr_route.rs create mode 100644 crates/ui-egui/src/workflows/ocr_web.rs create mode 100644 xtask/src/web.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 5592118f6c..a1caaeaa38 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,7 @@ [alias] xtask = "run -p xtask --" + +# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR +# engine's kernels (rten-simd) and other vectorised code only use it when compiled in. +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+simd128"] diff --git a/apps/pdfcraft-ocr-worker/Cargo.toml b/apps/pdfcraft-ocr-worker/Cargo.toml new file mode 100644 index 0000000000..7360c13440 --- /dev/null +++ b/apps/pdfcraft-ocr-worker/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-ocr-worker" +description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +pdfcraft-ocr = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] } diff --git a/apps/pdfcraft-ocr-worker/src/main.rs b/apps/pdfcraft-ocr-worker/src/main.rs new file mode 100644 index 0000000000..a6a41e24eb --- /dev/null +++ b/apps/pdfcraft-ocr-worker/src/main.rs @@ -0,0 +1,101 @@ +//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't +//! stop the page responding and several pages are read at once. Trunk builds it alongside +//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers +//! (crates/ui-egui/src/workflows/ocr_ui.rs). +//! +//! Messages are plain objects with a `type`: +//! +//! - `ready` (to the page): the worker is listening; send the models now. +//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model +//! file. Answered with `loaded`, or `failed` with an `error`. +//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page +//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as +//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +#[cfg(target_arch = "wasm32")] +fn main() { + use wasm_bindgen::{JsCast, closure::Closure}; + + let Ok(scope) = js_sys::global().dyn_into::() else { + return; + }; + let reply_to = scope.clone(); + let mut ocr = None; + let on_message = Closure::::new(move |e: web_sys::MessageEvent| { + let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data())); + }); + scope.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + // The handler lives as long as the worker. + on_message.forget(); + let _ = scope.post_message(&worker::message(&[("type", "ready".into())])); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web"); +} + +#[cfg(target_arch = "wasm32")] +mod worker { + use pdfcraft_ocr::Ocr; + use wasm_bindgen::{JsCast, JsValue}; + + /// The models are about 12 MB together. + const MAX_MODEL_BYTES: u32 = 64 << 20; + /// The renderer keeps pages far smaller; anything larger isn't a page. + const MAX_PIXEL_BYTES: u64 = 1 << 30; + + pub fn message(fields: &[(&str, JsValue)]) -> JsValue { + let o = js_sys::Object::new(); + for (k, v) in fields { + let _ = js_sys::Reflect::set(&o, &(*k).into(), v); + } + o.into() + } + + /// Answer one message from the page. + pub fn handle(ocr: &mut Option, data: &JsValue) -> JsValue { + let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED); + let id = get("id"); + let reply = match get("type").as_string().as_deref() { + Some("models") => load(&get("detection"), &get("recognition")).map(|o| { + *ocr = Some(o); + message(&[("type", "loaded".into())]) + }), + Some("page") => match ocr.as_ref() { + None => Err("the text recognition models aren't loaded".into()), + Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels")) + .map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])), + }, + _ => Err("unknown message".into()), + }; + reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())])) + } + + fn bytes(v: &JsValue, max: u64) -> Result, String> { + let a = v.dyn_ref::().ok_or("expected a Uint8Array")?; + if u64::from(a.length()) > max { + return Err(format!("{} bytes is too large", a.length())); + } + Ok(a.to_vec()) + } + + fn load(detection: &JsValue, recognition: &JsValue) -> Result { + let max = u64::from(MAX_MODEL_BYTES); + Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string()) + } + + fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result { + let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32); + let (Some(w), Some(h)) = (side(width), side(height)) else { + return Err("the page size is not a positive whole number".into()); + }; + let px = bytes(pixels, MAX_PIXEL_BYTES)?; + if px.len() as u64 != u64::from(w) * u64::from(h) * 4 { + return Err("the pixels don't match the page size".into()); + } + ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string()) + } +} diff --git a/apps/pdfcraft-web/Trunk.toml b/apps/pdfcraft-web/Trunk.toml index 563c2267c1..f03ccc6ea1 100644 --- a/apps/pdfcraft-web/Trunk.toml +++ b/apps/pdfcraft-web/Trunk.toml @@ -9,3 +9,10 @@ filehash = true [serve] addresses = ["127.0.0.1"] port = 8766 + +# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the +# worker for the page (xtask/src/web.rs). +[[hooks]] +stage = "post_build" +command = "cargo" +command_arguments = ["xtask", "web-worker-link"] diff --git a/apps/pdfcraft-web/index.html b/apps/pdfcraft-web/index.html index 46b3a25e21..f93bb0812d 100644 --- a/apps/pdfcraft-web/index.html +++ b/apps/pdfcraft-web/index.html @@ -5,6 +5,8 @@ PdfCraft + +