diff --git a/Cargo.lock b/Cargo.lock index 4526947945..b943f231e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17" dependencies = [ "craft-winprint-gdi", "log", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "craft-winprint-gdi" version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f" dependencies = [ "windows-sys 0.61.2", ] @@ -8247,7 +8247,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" dependencies = [ "flate2", "fmt-derive", diff --git a/Cargo.toml b/Cargo.toml index 407e40040f..d919a90383 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. diff --git a/ROADMAP.md b/ROADMAP.md index 7214dafca6..59c3e13a04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) + +> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) + > **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) @@ -798,6 +802,9 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. | +| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. | +| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. | | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index d23dd15a57..a168b27101 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -48,7 +48,7 @@ fn main() { }); } // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never - // prompts; ask the browser to confirm instead while a document has unsaved work (#812). + // prompts; ask the browser to confirm instead while a document has unsaved work or an OCR review is open (#812). warn_before_unload(app.unsaved_flag.clone()); // Host modes (`host` below, over `craft-web-host`): `?host=parent`, a // same-origin page embedding PdfCraft in an iframe (such as the Nextcloud diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4f9863de59..feface85e4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1327,7 +1327,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses inferred tables, bordered only along the grid lines the page draws, and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 7bd7023541..6d360d3b34 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -31,16 +31,19 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> format!("{rpr}{}", esc(text)) } -/// One Word table: single borders when the page's rules draw its grid, explicit absent borders -/// otherwise (alignment alone cannot prove PDF rules), a grid sized from the detected column +/// One Word table: when the page's rules draw its grid, single inside borders and single outer +/// borders on the edges they rule; explicit absent borders otherwise (alignment alone cannot +/// prove PDF rules), a grid sized from the detected column /// edges, `gridSpan` for spanning cells, empty cells for holes. pub(crate) fn docx_table(t: &Table) -> String { let mut edges = t.cols.clone(); edges.push(t.rect[2]); let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - if t.ruled { + let [top, left, bottom, right] = t.ruled.unwrap_or_default(); + let inside = t.ruled.is_some(); + for (edge, drawn) in [("top", top), ("left", left), ("bottom", bottom), ("right", right), ("insideH", inside), ("insideV", inside)] { + if drawn { s.push_str(&format!("")); } else { s.push_str(&format!("")); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 851cbda753..2b96343ebc 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -68,9 +68,10 @@ pub struct Table { pub rect: [f64; 4], pub cols: Vec, pub rows: Vec>, - /// Whether the page's ruled lines separate every pair of neighbouring cells, so the writers - /// can draw the table's borders. - pub ruled: bool, + /// `Some` when the page's ruled lines separate every pair of neighbouring cells, so the + /// writers draw the inside borders; it holds which outer edges (top, left, bottom, right) the + /// lines also draw, as only those get a border. + pub ruled: Option<[bool; 4]>, } /// What goes into the output, in order. @@ -264,8 +265,11 @@ const RULE_BUDGET: usize = 4_000_000; /// Whether `rules` separate every pair of neighbouring cells of a table whose rows are `rows` /// (block indices, top to bottom) and whose columns start at `edges` and end at `right`: between /// two columns a vertical rule beside each row (unless a cell of that row spans across), and -/// between two rows a horizontal rule under each column. Every check spends `budget`. -fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64], right: f64, budget: &mut usize) -> bool { +/// between two rows a horizontal rule under each column. Every check spends `budget`. When they +/// do, says which outer edges (top, left, bottom, right) are ruled too: a rule over every column +/// above the first row or below the last, or beside every row left of the first column or right +/// of the last, no farther from the cells than the next row or column is. +fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64], right: f64, budget: &mut usize) -> Option<[bool; 4]> { let vertical: Vec<&[f64; 4]> = rules.iter().filter(|r| r[2] - r[0] <= RULE_THICK && r[3] - r[1] > RULE_THICK).collect(); let horizontal: Vec<&[f64; 4]> = rules.iter().filter(|r| r[3] - r[1] <= RULE_THICK && r[2] - r[0] > RULE_THICK).collect(); let mut find = |set: &[&[f64; 4]], hit: &dyn Fn(&[f64; 4]) -> bool| -> bool { @@ -295,7 +299,7 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64 let x = (r[0] + r[2]) / 2.0; lo + COL_TOL < x && x <= hi + COL_TOL && r[1] <= mid && mid <= r[3] && clear(x) }) { - return false; + return None; } } } @@ -307,11 +311,41 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64 let y = (r[1] + r[3]) / 2.0; below_top - COL_TOL <= y && y <= above_bottom + COL_TOL && r[0] <= x && x <= r[2] }) { - return false; + return None; } } } - true + let (Some(first), Some(last), Some(&left), Some(&last_col)) = (spans.first(), spans.last(), edges.first(), edges.last()) else { + return Some([false; 4]); + }; + // How far the next row or column is: an outer rule farther out than that frames something else. + let row_gap = |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>, side: usize| a.zip(b).map_or(0.0, |(a, b)| (a[side] - b[side]).abs()); + let top_gap = row_gap(Some(first), spans.get(1), 1); + let bottom_gap = row_gap(spans.len().checked_sub(2).and_then(|i| spans.get(i)), Some(last), 0); + let left_gap = edges.get(1).map_or(0.0, |e| e - left); + let right_gap = edges.len().checked_sub(2).and_then(|i| edges.get(i)).map_or(0.0, |e| last_col - e); + let mut over_columns = |lo: f64, hi: f64| { + mids.iter().all(|&x| { + find(&horizontal, &|r| { + let y = (r[1] + r[3]) / 2.0; + lo <= y && y <= hi && r[0] <= x && x <= r[2] + }) + }) + }; + let top = over_columns(first[1] - COL_TOL, first[1] + top_gap + COL_TOL); + let bottom = over_columns(last[0] - bottom_gap - COL_TOL, last[0] + COL_TOL); + let mut beside_rows = |lo: f64, hi: f64| { + spans.iter().all(|span| { + let mid = (span[0] + span[1]) / 2.0; + find(&vertical, &|r| { + let x = (r[0] + r[2]) / 2.0; + lo <= x && x <= hi && r[1] <= mid && mid <= r[3] + }) + }) + }; + let left_ruled = beside_rows(left - left_gap - COL_TOL, left + COL_TOL); + let right_ruled = beside_rows(right - COL_TOL, right.max(last_col + right_gap) + COL_TOL); + Some([top, left_ruled, bottom, right_ruled]) } /// Detect tables in a page's blocks from their text layout: columns are left edges shared by @@ -394,11 +428,13 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec> = run.iter().map(|row| row.iter().map(|(_, i)| *i).collect()).collect(); ruled(blocks, rules, &row_blocks, &edges, right, budget) + } else { + None }; - let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || is_ruled))) && ncols <= MAX_COLS; + let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || frame.is_some()))) && ncols <= MAX_COLS; if !ok { run.clear(); return; @@ -457,7 +493,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec
, Vec"), "{xml}"); @@ -887,7 +887,7 @@ fn a_ruled_two_by_two_grid_is_a_table() { ]; let (found, consumed) = tables(&blocks, &grid); assert_eq!(found.len(), 1); - assert!(found[0].ruled); + assert_eq!(found[0].ruled, Some([true; 4]), "the grid's frame is ruled on every side"); assert_eq!(found[0].rows.len(), 2); assert!(consumed.iter().all(|used| *used)); let p = Page { width: 320.0, height: 300.0, blocks: blocks.clone(), rules: grid, ..Page::default() }; @@ -899,3 +899,32 @@ fn a_ruled_two_by_two_grid_is_a_table() { let underlines = [[20.0, 245.0, 300.0, 245.0]]; assert!(tables(&blocks, &underlines).0.is_empty(), "a rule between the rows alone is no grid"); } + +/// A grid drawn with inside lines only gets inside borders in Word, not a box the page never +/// draws; an outer line far from the table (a page rule) is not its frame. +#[test] +fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() { + let blocks = vec![ + block("Name", 25.0, 250.0, 55.0, 261.0), + block("Value", 165.0, 250.0, 195.0, 261.0), + block("Alpha", 25.0, 225.0, 55.0, 236.0), + block("One", 165.0, 225.0, 185.0, 236.0), + ]; + let inside = vec![[20.0, 245.0, 300.0, 245.0], [160.0, 220.0, 160.0, 270.0]]; + let (found, _) = tables(&blocks, &inside); + assert_eq!(found.len(), 1); + assert_eq!(found[0].ruled, Some([false; 4])); + let xml = docx_table(&found[0]); + for edge in ["insideH", "insideV"] { + assert!(xml.contains(&format!("")), "{xml}"); + } + + let mut ruled_top = inside.clone(); + ruled_top.push([20.0, 270.0, 300.0, 270.0]); + ruled_top.push([0.0, 500.0, 320.0, 500.0]); + ruled_top.push([0.0, 0.0, 320.0, 0.0]); + assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it"); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 171273c102..a347934480 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -590,7 +590,8 @@ impl eframe::App for PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] self.watch_files(); if self.host_dirty.is_some() { - let dirty = self.first_dirty().is_some(); + // An OCR review not yet accepted or rejected is work the page would lose, too. + let dirty = self.work_at_risk(); if let Some(report) = self.host_dirty.as_mut() { report(dirty); } @@ -701,10 +702,20 @@ impl eframe::App for PdfCraftApp { if self.draws_window_frame() { window_controls::resize_edges(&ctx); } + chrome::tab_strip(self, ui); + chrome::mode_bar(self, ui); + // Another mode or the Home tab puts the OCR Verify screen away, as Exit does; Correct + // recognized text brings it back with its review as it was. + if self.ocr_verify.open && (self.active.is_none() || self.ocr_verify.shown_in.is_some_and(|m| m != self.mode)) { + self.ocr_verify.open = false; + } + self.ocr_verify.shown_in = self.ocr_verify.open.then_some(self.mode); if self.ocr_verify.open { - // The OCR Verify screen owns the window (its four panes); dialogs and notices + // The OCR Verify screen takes the document area (its four panes) under the window's + // tabs, menus and toolbar, which stay (#6485); the palette, dialogs and notices // still show above it. workflows::ocr_verify::show(self, ui); + palette::show(self, &ctx); dialogs::show(self, &ctx); self.show_progress(&ctx); widgets::toast(self, &ctx); @@ -715,8 +726,6 @@ impl eframe::App for PdfCraftApp { } return; } - chrome::tab_strip(self, ui); - chrome::mode_bar(self, ui); // Whichever way another tab came to the front, it shows instead of the expanded view. if self.all_tools_full && self.all_tools_tab != self.shown_tab() { self.all_tools_full = false; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8ba3e0fcdb..9190df6738 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2473,6 +2473,9 @@ fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); let Some(i) = pending else { return false }; app.views[i].close_requested = false; + if app.hold_close_for_ocr_review(i) { + return true; + } if app.has_unsaved_work(i) { app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); } else { diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 55d884cd9a..6713a91df6 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -14,8 +14,12 @@ pub enum CloseRequest { } impl PdfCraftApp { - /// Close a tab, asking first if it has unsaved changes. + /// Close a tab, asking first if it has unsaved changes. A document under OCR review stays + /// open until the review is accepted or rejected. pub fn request_close_tab(&mut self, index: usize) { + if self.hold_close_for_ocr_review(index) { + return; + } // Text typed into a field counts as an unsaved change. if self.has_unsaved_work(index) && let Some(id) = self.views.get(index).map(|v| v.id) @@ -27,7 +31,11 @@ impl PdfCraftApp { } /// File ▸ Close all: clean documents close at once; each one with unsaved changes asks. + /// While a document is under OCR review, nothing closes: the review comes forward first. pub fn close_all(&mut self) { + if self.hold_for_any_ocr_review() { + return; + } for i in (0..self.views.len()).rev() { if !self.has_unsaved_work(i) { self.close_tab(i); @@ -38,6 +46,33 @@ impl PdfCraftApp { } } + /// A document whose OCR review is still open (a run, or a reading not yet accepted or + /// rejected) does not close under it: closing would leave the review with nothing to apply + /// to (#6485). The document comes to the front with its review, and a notice says what to + /// do. Whether the close was held. + pub(crate) fn hold_close_for_ocr_review(&mut self, index: usize) -> bool { + let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; + if !self.ocr_verify.holds_reading_of(id) { + return false; + } + self.active = Some(index); + self.ocr_verify.open = true; + self.notify_tr("Accept or reject the OCR review before closing this document"); + true + } + + /// Close all and Quit close every document, so a document under OCR review holds them too + /// ([`Self::hold_close_for_ocr_review`]), even when it has no unsaved changes. Whether they + /// were held. + fn hold_for_any_ocr_review(&mut self) -> bool { + self.ocr_review_index().is_some_and(|i| self.hold_close_for_ocr_review(i)) + } + + /// The tab of the document whose OCR review is still open, if any. + fn ocr_review_index(&self) -> Option { + self.views.iter().position(|v| self.ocr_verify.holds_reading_of(v.id)) + } + /// File ▸ Revert (after the confirmation). pub fn revert_active(&mut self) { let Some((_, id)) = self.active_ids() else { return }; @@ -116,11 +151,21 @@ impl PdfCraftApp { } /// Close tab `index` for a close request, then ask about the next dirty document, or quit. - pub(super) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { + /// The save it waited for may have taken a later frame (the user choosing where), and an OCR + /// review started meanwhile holds the close as it holds a new one: the document stays open, + /// and Quit or Close all stops there. + pub(crate) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { + let whole = req == CloseRequest::Quit || req == CloseRequest::All; + if self.hold_close_for_ocr_review(index) || (whole && self.hold_for_any_ocr_review()) { + if req == CloseRequest::Quit { + self.forget_quit_session(); + } + return; + } self.close_tab(index); // A newer prompt wins: the user may have started another close while a save was pending, // and seen its question, which keeps counting as seen. - if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { + if whole && self.close_request.is_none() { match self.first_dirty() { Some(_) => { // A question about the next document is a new one. @@ -134,9 +179,13 @@ impl PdfCraftApp { } /// Request a normal application exit, preserving every unsaved document until answered. - /// Returns false while the existing save/discard/cancel prompt needs a decision. + /// Returns false while the existing save/discard/cancel prompt needs a decision, or while an + /// OCR review is still to be accepted or rejected. pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool { - if self.first_dirty().is_some() { + if self.hold_for_any_ocr_review() { + ctx.request_repaint(); + false + } else if self.first_dirty().is_some() { self.close_request = Some(CloseRequest::Quit); ctx.request_repaint(); false @@ -152,12 +201,21 @@ impl PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } - /// Publish whether any document has unsaved work, for the browser's reload warning (#812). + /// Publish whether leaving would lose work, for the browser's reload warning (#812): a + /// document with unsaved changes, or an OCR review not yet accepted or rejected, which holds + /// Close and Quit the same way. pub(crate) fn sync_unsaved_flag(&self) { - self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed); + self.unsaved_flag.store(self.work_at_risk(), std::sync::atomic::Ordering::Relaxed); } - /// Intercept window close while documents have unsaved changes. + /// Whether leaving would lose work: a document with unsaved changes, or an OCR review not yet + /// accepted or rejected. The browser's reload warning and the embedding page (`host_dirty`) + /// both hear this. + pub(crate) fn work_at_risk(&self) -> bool { + self.first_dirty().is_some() || self.ocr_review_index().is_some() + } + + /// Intercept window close while documents have unsaved changes or an OCR review is open. pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) { // egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'. ctx.options_mut(|o| o.quit_shortcuts.clear()); @@ -174,8 +232,10 @@ impl PdfCraftApp { if !ctx.input(|i| i.viewport().close_requested()) { return; } - // `first_dirty` counts text typed into a field as an unsaved change. - if !self.allow_quit && self.first_dirty().is_some() { + if !self.allow_quit && self.hold_for_any_ocr_review() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } else if !self.allow_quit && self.first_dirty().is_some() { + // `first_dirty` counts text typed into a field as an unsaved change. ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.close_request = Some(CloseRequest::Quit); } else { diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 9c6b543831..8a79506a1a 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -23,5 +23,6 @@ pub type OsEventsFn = Box Vec>; /// for saves; without it the browser downloads the file. pub type HostSaveFn = Box Result<(), String>>; -/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving. +/// Told every frame whether any tab has unsaved work, or an OCR review is not yet accepted or +/// rejected, so a host page can warn before leaving. pub type HostDirtyFn = Box; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f85a6d5e41..84a2a1adbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -467,13 +467,13 @@ pub struct PdfCraftApp { startup_superseded: bool, /// Asynchronous opens that failed (web `?file=` fetches), shown as a notice. pub failed_inbox: FailedInbox, - /// Mirrors "some document has unsaved work" each frame, so the web page's `beforeunload` handler can read it (#812). + /// Mirrors "some document has unsaved work, or an OCR review is open" each frame, so the web page's `beforeunload` handler can read it (#812). pub unsaved_flag: std::sync::Arc, /// Requests from the operating system, polled every frame (macOS Apple events). pub os_events: Option, /// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]). pub host_save: Option, - /// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]). + /// Reports unsaved work, or an open OCR review, to the embedding page (web, see [`HostDirtyFn`]). pub host_dirty: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). pub close_request: Option, diff --git a/crates/ui-egui/src/workflows/ocr_verify.rs b/crates/ui-egui/src/workflows/ocr_verify.rs index 4054339de4..450253a7fc 100644 --- a/crates/ui-egui/src/workflows/ocr_verify.rs +++ b/crates/ui-egui/src/workflows/ocr_verify.rs @@ -1,15 +1,16 @@ -//! Scan & OCR ▸ Correct recognized text: the OCR Verify screen. A full-workspace surface with -//! four panes (page list, source page, recognized text, zoom + word inspector) where a -//! recognition is reviewed before it is applied: words are colored by confidence, corrected, -//! removed (and restored), sent to a per-language user dictionary, and navigated by their -//! uncertainty. Accept applies the review — as one undoable step on the open document -//! (searchable), or as a new `_ocr.pdf` of visible text (editable). Reject throws the -//! reading away. +//! Scan & OCR ▸ Correct recognized text: the OCR Verify screen. It takes the document area, +//! under the window's tabs, menus and toolbar, with four panes (page list, source page, +//! recognized text, zoom + word inspector) where a recognition is reviewed before it is +//! applied: words are colored by confidence, corrected, removed (and restored), sent to a +//! per-language user dictionary, and navigated by their uncertainty. Accept applies the +//! review — as one undoable step on the open document (searchable), or as a new +//! `_ocr.pdf` of visible text (editable). Reject throws the reading away. //! //! The state machine is [`Phase`]: recognition runs on a worker thread ([`Phase::Running`], a //! real Cancel keeps the pages already read), results land in [`Phase::ReviewReady`], failures //! land in [`Phase::RecoverableError`] with the cause named and Run enabled again, and -//! Accept/Reject/Re-OCR are ignored outside ReviewReady instead of acting on nothing. +//! Accept/Reject/Re-OCR are ignored outside ReviewReady instead of acting on nothing (Reject +//! also throws away a reading whose Accept failed). use std::collections::BTreeSet; use std::path::PathBuf; @@ -25,7 +26,8 @@ use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; /// Where the screen stands. Run is enabled in Idle, ReviewReady and RecoverableError; the -/// review tools (Accept, Reject, navigation, the inspector, re-OCR) only in ReviewReady. +/// review tools (Accept, Reject, navigation, the inspector, re-OCR) only in ReviewReady, and +/// Reject also in RecoverableError while a reading is kept. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Phase { #[default] @@ -199,6 +201,15 @@ pub struct Preview { pub(crate) texture: Option, } +/// A page whose preview render failed. It is tried again once the document's bytes change (an +/// edit, a revert) or after another page rendered. `bytes` is the rendered copy, held weakly so +/// it is not kept alive: while it is held, no newer copy can take its address. +struct FailedPreview { + doc: DocId, + page: usize, + bytes: std::sync::Weak>, +} + /// A page or region re-read in flight ([`OcrVerify::start_reread`]): the page being read /// again (and the drawn region, in view-space points, for a region re-read), with the /// worker's progress. @@ -220,7 +231,8 @@ pub struct RereadProgress { pub struct OcrVerify { pub open: bool, pub phase: Phase, - /// The document being reviewed; `None` until a run starts. + /// The document being reviewed; before a run, the document in front (the source pane shows + /// its page). pub doc: Option, /// The recognition results, in page order, as read (review edits mutate them). pub pages: Vec, @@ -250,6 +262,11 @@ pub struct OcrVerify { /// Language of the captured recognition, independent of the interface locale. pub dictionary_language: String, pub(crate) preview: Option, + /// The last page whose preview could not be rendered: not rendered again on every frame. + preview_failed: Option, + /// The mode the screen was last drawn in (`None` while it is closed): switching to another + /// puts the screen away. + pub(crate) shown_in: Option, } fn dictionary_path(dir: &std::path::Path, language: &str) -> Result { @@ -294,6 +311,8 @@ impl Default for OcrVerify { dictionary_dir: None, dictionary_language: "en".into(), preview: None, + preview_failed: None, + shown_in: None, } } } @@ -308,11 +327,53 @@ impl OcrVerify { self.review = Review::default(); self.selected = None; self.region = None; - self.current = self.current.min(self.pages.len().saturating_sub(1)); + self.show_a_read_page(); self.phase = Phase::ReviewReady; self.load_dictionary_pref(); } + /// Show a page the reading has words for: the current page if it was read, else the first + /// page read. When every page was skipped, the current page if the reading has it, else its + /// first page; a reading with no pages (a run cancelled before its first page) keeps the + /// page the run was started on. + fn show_a_read_page(&mut self) { + if self.page(self.current).is_some_and(|p| p.skipped.is_none()) { + return; + } + if let Some(p) = self.pages.iter().find(|p| p.skipped.is_none()) { + self.current = p.page; + } else if self.page(self.current).is_none() + && let Some(p) = self.pages.first() + { + self.current = p.page; + } + } + + /// Whether the screen holds a run or a reading not yet accepted or rejected (a failed run + /// that read nothing holds none). + fn has_reading(&self) -> bool { + !(self.phase == Phase::Idle || (self.phase == Phase::RecoverableError && self.pages.is_empty())) + } + + /// Whether the screen holds a run or a reading of `doc`: closing that document would leave + /// the review with nothing to apply to. + pub(crate) fn holds_reading_of(&self, doc: DocId) -> bool { + self.doc == Some(doc) && self.has_reading() + } + + /// The info strip's PAGE cell: the shown page's number in the document over the document's + /// page count (`page_count`), the numbering the page list uses; "—" before a reading. + fn page_indicator(&self, page_count: Option) -> String { + if self.pages.is_empty() { + return "—".into(); + } + let shown = self.current.saturating_add(1); + match page_count { + Some(total) if total > 0 => format!("{shown} / {total}"), + _ => shown.to_string(), + } + } + /// The count of live (not removed, not empty) words across the review. pub fn word_count(&self) -> usize { self.pages @@ -395,6 +456,13 @@ impl OcrVerify { self.phase == Phase::ReviewReady } + /// Whether Reject has a reading to throw away: in review, and after a failed Accept, which + /// keeps the reading (a stale document, say). Its document stays open until the reading is + /// accepted or rejected, so Reject must not wait for a new run that would replace it. + pub fn can_reject(&self) -> bool { + self.in_review() || (self.phase == Phase::RecoverableError && !self.pages.is_empty()) + } + /// Commit the correction field for `key`. An empty (after trim) commit removes the word; /// the box never moves. Only in ReviewReady. pub fn correct(&mut self, key: (usize, usize), text: &str) -> bool { @@ -809,9 +877,9 @@ impl OcrVerify { } /// Reject: clear the words, the session, the selection and the flags; back to Idle. - /// Ignored outside ReviewReady. + /// Ignored outside ReviewReady, except after a failed Accept ([`Self::can_reject`]). pub fn reject(&mut self) -> bool { - if !self.in_review() { + if !self.can_reject() { return false; } *self = OcrVerify { open: self.open, dictionary_dir: self.dictionary_dir.take(), ..Default::default() }; @@ -896,14 +964,12 @@ impl OcrVerify { s.result = Some(result); } }; - #[cfg(not(target_arch = "wasm32"))] - if app.run_inline { - work(); - } else { - std::thread::Builder::new().name("pdfcraft-ocr-reread".into()).spawn(work).ok(); + // The OS can refuse a new thread: then nothing runs, and the page's words stay as they were. + if let Err(e) = launch(app, "pdfcraft-ocr-reread", work) { + self.message = format!("the re-read could not start: {e}"); + app.notify_error(self.message.clone()); + return; } - #[cfg(target_arch = "wasm32")] - work(); self.reread = Some(Reread { page, region, progress }); } @@ -1040,7 +1106,7 @@ impl OcrVerify { self.review = Review::default(); self.selected = None; self.region = None; - self.current = self.current.min(self.pages.len().saturating_sub(1)); + self.show_a_read_page(); let total = self.pages.len(); self.message = if cancelled { crate::i18n::fmt( @@ -1074,6 +1140,12 @@ impl OcrVerify { if self.reread.is_some() { return; // the page re-read in flight replaces these words; one worker at a time } + // A review of another document stays until it is accepted or rejected: a run here would + // replace its corrections. + if self.has_reading() && self.doc != Some(id) { + self.message = tl!("Accept or reject the OCR review of the other document before running OCR here").to_string(); + return; + } if let Some(why) = app.session.get(id).and_then(|d| d.read_only_reason.clone()) { self.message = why; self.phase = Phase::RecoverableError; @@ -1087,14 +1159,11 @@ impl OcrVerify { }; let settings = d.settings(); let Some(job) = app.session.ocr_job(id, &pages, settings.clone()) else { return }; - self.dictionary_language = job.settings.language.clone(); + let language = job.settings.language.clone(); + let guard = job.guard.clone(); + // The source pane shows the first page this run reads (the current page, for that scope). + let first = job.pages.first().copied().unwrap_or(0); let progress = Arc::new(Mutex::new(ocr_ui::OcrProgress { total: job.pages.len(), ..Default::default() })); - self.doc = Some(id); - self.guard = Some(job.guard.clone()); - self.current = 0; - self.cancelled = false; - self.phase = Phase::Running; - self.message = String::new(); let p = progress.clone(); let work = move || { let result = pdfcraft_engine::ocr::recognizers(&settings).map(|r| { @@ -1109,14 +1178,24 @@ impl OcrVerify { s.result = Some(result); } }; - #[cfg(not(target_arch = "wasm32"))] - if app.run_inline { - work(); - } else { - std::thread::Builder::new().name("pdfcraft-ocr-verify".into()).spawn(work).ok(); + // The OS can refuse a new thread. Then nothing runs: a review already on the screen stays + // as it was, and an empty screen shows why (Run stays enabled, and nothing holds the + // document open). + if let Err(e) = launch(app, "pdfcraft-ocr-verify", work) { + self.message = format!("the recognition could not start: {e}"); + if self.phase == Phase::Idle { + self.phase = Phase::RecoverableError; + } + app.notify_error(self.message.clone()); + return; } - #[cfg(target_arch = "wasm32")] - work(); + self.dictionary_language = language; + self.doc = Some(id); + self.guard = Some(guard); + self.current = first; + self.cancelled = false; + self.phase = Phase::Running; + self.message = String::new(); self.progress = Some(progress); self.poll(app); } @@ -1149,6 +1228,23 @@ impl OcrVerify { } } +/// Run `work` on a worker thread named `name` (inline under `run_inline` and on the web). +/// Whether it started: the OS can refuse a new thread. +fn launch(app: &PdfCraftApp, name: &str, work: impl FnOnce() + Send + 'static) -> std::io::Result<()> { + #[cfg(test)] + if tests::FAIL_LAUNCH.with(std::cell::Cell::get) { + return Err(std::io::Error::other("no thread for this test")); + } + #[cfg(not(target_arch = "wasm32"))] + if !app.run_inline { + return std::thread::Builder::new().name(name.to_string()).spawn(work).map(drop); + } + #[cfg(target_arch = "wasm32")] + let _ = (app, name); + work(); + Ok(()) +} + /// What a frame's controls asked for, applied after the drawing (the panes read `app` while /// the controls write it, so nothing mutates through an open borrow). #[derive(Default)] @@ -1186,9 +1282,10 @@ fn band_color(band: ConfidenceBand) -> egui::Color32 { } } -/// The whole-window OCR Verify screen (Scan & OCR ▸ Correct recognized text). +/// The OCR Verify screen (Scan & OCR ▸ Correct recognized text), in the document area. pub(crate) fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { use egui::{Key, Modifiers}; + follow_active_document(app); let ctx = ui.ctx().clone(); let t = Tokens::get(&ctx); let wants_text = ctx.egui_wants_keyboard_input(); @@ -1253,6 +1350,18 @@ pub(crate) fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { apply(app, n); } +/// With no reading in hand (before a run, or after a review was accepted or rejected), the +/// source pane shows the document in front at the page it is on, so the screen never opens on +/// a blank pane (#6485). A run or a review keeps the document and page it was made for. +fn follow_active_document(app: &mut PdfCraftApp) { + if app.ocr_verify.has_reading() { + return; + } + let active = app.active_ids(); + app.ocr_verify.doc = active.map(|(_, id)| id); + app.ocr_verify.current = active.and_then(|(i, _)| app.views.get(i)).map_or(0, |view| view.current); +} + /// A pane's caption; the focused pane's name is underlined (Ctrl+Tab moves the focus). fn pane_title(ui: &mut egui::Ui, t: &Tokens, name: &str, focused: bool) { ui.add_space(2.0); @@ -1320,7 +1429,7 @@ fn toolbar(app: &mut PdfCraftApp, ui: &mut egui::Ui, n: &mut Intents) { if ui.add_enabled_ui(accept_enabled, |ui| widgets::pill_button(ui, tl!("Accept"), false)).inner.clicked() { n.accept = true; } - if ui.add_enabled_ui(review, |ui| widgets::pill_button(ui, tl!("Reject"), false)).inner.clicked() { + if ui.add_enabled_ui(v.can_reject(), |ui| widgets::pill_button(ui, tl!("Reject"), false)).inner.clicked() { n.reject = true; } ui.separator(); @@ -1459,7 +1568,7 @@ fn info_strip(app: &PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let read = v.pages.iter().filter(|p| p.skipped.is_none()).count(); let avg = v.page(v.current).and_then(pdfcraft_engine::ocr::OcrPage::mean_confidence); let cells = [ - (tl!("PAGE"), if v.pages.is_empty() { "—".into() } else { format!("{} / {}", v.current + 1, v.pages.len()) }), + (tl!("PAGE"), v.page_indicator(v.doc.and_then(|d| app.session.get(d)).map(|d| d.info.pages.len()))), (tl!("AVG CONFIDENCE"), avg.map_or_else(|| "—".into(), |c| format!("{c:.0}%"))), (tl!("LOW-CONFIDENCE WORDS"), v.suspect_count().to_string()), (tl!("VERIFIED %"), format!("{}%", (v.verified_fraction() * 100.0).round() as i64)), @@ -1691,22 +1800,43 @@ pub fn legend_rows() -> [(ConfidenceBand, String); 3] { } /// Render (or re-render) the source pane's page preview when the shown page changed. One -/// synchronous render per page; the texture is cached until then. +/// synchronous render per page; the texture is cached until then, and a failed render is not +/// retried until the document changes ([`FailedPreview`]). fn ensure_preview(app: &mut PdfCraftApp, page: usize) { - let Some(doc) = app.ocr_verify.doc else { return }; - if app.ocr_verify.preview.as_ref().is_some_and(|p| p.doc == doc && p.page == page && p.texture.is_some()) { + let doc = app.ocr_verify.doc; + // A preview of another document or page never stands in for this one, even when this + // page cannot be rendered. + if !app.ocr_verify.preview.as_ref().is_some_and(|p| Some(p.doc) == doc && p.page == page) { + app.ocr_verify.preview = None; + } + let Some(doc) = doc else { return }; + if app.ocr_verify.preview.as_ref().is_some_and(|p| p.texture.is_some()) { return; } let Some(d) = app.session.get(doc) else { return }; let Some(info) = d.info.pages.get(page) else { return }; + // A page that failed to render fails again until its document changes: don't parse and + // render it on every frame. + let bytes = Arc::as_ptr(&d.bytes); + if app.ocr_verify.preview_failed.as_ref().is_some_and(|f| (f.doc, f.page) == (doc, page) && std::ptr::eq(f.bytes.as_ptr(), bytes)) { + return; + } + #[cfg(test)] + tests::PREVIEW_RENDERS.with(|n| n.set(n.get().saturating_add(1))); // Screen scale (the boxes are drawn on top of it); the recognition dpi is irrelevant here. let scale = 2.0_f32.min((900.0 / info.width.max(1.0)).min(1200.0 / info.height.max(1.0))).max(0.2); let config = pdfcraft_render::RenderConfig { password: d.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; let mut r = pdfcraft_render::PageRenderer::new(d.bytes.clone(), config); let shot = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale, ..Default::default() }); - if shot.error.is_some() || shot.width == 0 || shot.height == 0 { - return; // keep any earlier preview; a render failure names itself elsewhere + let failed = shot.error.is_some() || shot.width == 0 || shot.height == 0; + #[cfg(test)] + let failed = failed || tests::FAIL_RENDER.with(std::cell::Cell::get); + if failed { + // A render failure names itself elsewhere. + app.ocr_verify.preview_failed = Some(FailedPreview { doc, page, bytes: Arc::downgrade(&d.bytes) }); + return; } + app.ocr_verify.preview_failed = None; let scale = shot.width as f32 / info.width.max(1.0); let texture = app.ctx.as_ref().map(|ctx| { ctx.load_texture( @@ -2089,6 +2219,15 @@ mod tests { use super::*; use pdfcraft_engine::Session; + thread_local! { + /// [`launch`] refuses to start a worker, as the OS can. + pub(super) static FAIL_LAUNCH: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// How many page renders [`ensure_preview`] started. + pub(super) static PREVIEW_RENDERS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// [`ensure_preview`]'s render fails, as a page the renderer refuses does. + pub(super) static FAIL_RENDER: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + /// A word box that sits where a real placed word would (high on the page, reading left /// to right). fn word(text: &str, conf: Option) -> PlacedWord { @@ -2827,4 +2966,366 @@ mod tests { let r = pane_region([100.0, 50.0, 200.0, 150.0], &turned, img).expect("on the page"); assert_eq!((r.left(), r.top(), r.right(), r.bottom()), (50.0, 25.0, 100.0, 75.0)); } + + /// Correct recognized text on a document not yet read keeps the window's tabs, menus and + /// toolbar, and its source pane shows the document's page (#6485: the screen took the + /// whole window and the page area was blank). Another mode puts the screen away. + #[test] + fn correct_recognized_text_keeps_the_chrome_and_shows_the_page() { + use egui_kittest::kittest::Queryable; + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("scan.pdf", None, bytes.clone()).expect("fixture"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("ocr.correct")); + h.run_steps(3); + let id = h.state().active_ids().expect("a document is open").1; + h.get_by_label("Run OCR"); + assert!(h.query_by_label("Find tools and commands").is_some(), "the toolbar stays"); + assert!(h.query_by_label("scan.pdf").is_some(), "the document's tab stays"); + let v = &h.state().ocr_verify; + assert_eq!(v.doc, Some(id), "the screen shows the document in front"); + let preview = v.preview.as_ref().expect("the source page is rendered"); + assert_eq!((preview.doc, preview.page), (id, 0)); + assert!(preview.texture.is_some(), "and drawn"); + // A review with a correction in it, put away by another mode, comes back as it was. + let job = h.state_mut().session.ocr_job(id, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + h.state_mut().ocr_verify.load(id, job.guard.clone(), vec![page]); + assert!(h.state_mut().ocr_verify.correct((0, 0), "Edited")); + h.run_steps(2); + h.get_by_label("E-Sign").click(); + h.run_steps(3); + assert!(!h.state().ocr_verify.open, "another mode puts the screen away"); + assert!(h.state_mut().execute("ocr.correct")); + h.run_steps(3); + let v = &h.state().ocr_verify; + assert!(v.open, "Correct recognized text brings the screen back"); + assert_eq!((v.phase, v.doc), (Phase::ReviewReady, Some(id)), "with its review"); + assert_eq!(v.text_of((0, 0)), Some("Edited"), "and the correction in it"); + } + + /// After a run, the source pane shows a page with recognized words: a page skipped because + /// it already has text is passed over, unless every page was skipped. + #[test] + fn a_finished_run_shows_a_read_page_over_a_skipped_one() { + let skipped = |page| OcrPage { already_on_page: 0, page, words: Vec::new(), skipped: Some("has text".into()), notes: Vec::new() }; + let read = |page| OcrPage { already_on_page: 0, page, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + let mut v = OcrVerify { current: 0, ..Default::default() }; + v.finished(vec![skipped(0), read(1), read(2)], false); + assert_eq!(v.current, 1, "the first page read"); + let mut v = OcrVerify { current: 2, ..Default::default() }; + v.finished(vec![skipped(0), read(1), read(2)], false); + assert_eq!(v.current, 2, "the current page, when it was read"); + let mut v = OcrVerify { current: 1, ..Default::default() }; + v.finished(vec![skipped(0), skipped(1)], false); + assert_eq!(v.current, 1, "every page skipped: the current page stays"); + let mut v = OcrVerify { current: 5, ..Default::default() }; + v.finished(vec![skipped(0), skipped(1)], false); + assert_eq!(v.current, 0, "or the reading's first page, when it lacks the current one"); + } + + /// A current-page run cancelled before its page was read keeps showing that page. + #[test] + fn a_run_that_read_no_pages_keeps_its_page() { + let mut v = OcrVerify { current: 2, ..Default::default() }; + v.finished(Vec::new(), true); + assert_eq!(v.current, 2); + assert_eq!(v.phase, Phase::ReviewReady); + } + + /// PAGE pairs the shown page's number in the document with the document's page count, the + /// numbering the page list uses: a reading of page 3 alone is "3 / 5", never "3 / 1". + #[test] + fn page_indicator_numbers_pages_in_the_document() { + let mut v = OcrVerify::default(); + assert_eq!(v.page_indicator(Some(5)), "—", "no reading yet"); + v.finished(vec![OcrPage { already_on_page: 0, page: 2, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }], false); + assert_eq!(v.page_indicator(Some(5)), "3 / 5"); + assert_eq!(v.page_indicator(None), "3", "no count when the document is gone"); + assert_eq!(v.page_indicator(Some(0)), "3"); + } + + /// The cached preview is dropped once the source moves to a page it is not of, even when + /// the new page cannot be rendered: the pane never shows another page's image. + #[test] + fn a_preview_of_another_page_is_dropped() { + let mut app = PdfCraftApp::new(); + let text = app.session.create_from_text("t", "Recognize these scanned words").expect("fixture"); + let id = app.session.open("t.pdf", None, text, None).expect("fixture"); + app.ocr_verify.doc = Some(id); + ensure_preview(&mut app, 0); + assert!(app.ocr_verify.preview.as_ref().is_some_and(|p| (p.doc, p.page) == (id, 0)), "page 1 is rendered"); + ensure_preview(&mut app, 99); + assert!(app.ocr_verify.preview.is_none(), "a page that cannot render shows no image, not page 1's"); + ensure_preview(&mut app, 0); + app.ocr_verify.doc = None; + ensure_preview(&mut app, 0); + assert!(app.ocr_verify.preview.is_none(), "nor does a pane with no document"); + } + + /// Closing a document under OCR review, from another tab, Close all or its own tab, keeps it + /// open and brings its review forward; other documents close as usual, and once the review + /// is rejected the document closes. + #[test] + fn closing_the_reviewed_document_brings_its_review_forward() { + let mut app = PdfCraftApp::new(); + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + for name in ["a.pdf", "b.pdf", "c.pdf"] { + app.open_bytes(name, None, bytes.clone()).expect("fixture"); + } + let a = app.views.first().expect("a is open").id; + let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + app.ocr_verify.load(a, job.guard.clone(), vec![page]); + assert!(app.ocr_verify.correct((0, 0), "Edited")); + app.active = Some(2); + app.request_close_tab(0); + assert_eq!(app.views.len(), 3, "the reviewed document stays open"); + assert_eq!(app.active, Some(0), "and comes to the front"); + assert!(app.ocr_verify.open, "with its review"); + assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "as it was"); + assert!(app.toast.is_some(), "a notice says to accept or reject it first"); + app.ocr_verify.open = false; + app.request_close_tab(2); + assert_eq!(app.views.len(), 2, "another document closes as usual"); + app.active = Some(1); + app.close_all(); + assert_eq!(app.views.len(), 2, "Close all closes nothing while the review is open"); + assert_eq!(app.active, Some(0)); + assert!(app.ocr_verify.reject()); + app.request_close_tab(0); + assert_eq!(app.views.len(), 1, "after Reject the document closes"); + assert!(app.views.iter().all(|v| v.id != a)); + } + + /// Three open documents with a review of the first one, a word of it corrected. + fn reviewing_the_first_of_three() -> (PdfCraftApp, DocId) { + let mut app = PdfCraftApp::new(); + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + for name in ["a.pdf", "b.pdf", "c.pdf"] { + app.open_bytes(name, None, bytes.clone()).expect("fixture"); + } + let a = app.views.first().expect("a is open").id; + let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + app.ocr_verify.load(a, job.guard.clone(), vec![page]); + assert!(app.ocr_verify.correct((0, 0), "Edited")); + (app, a) + } + + /// A close that waited for a save (the user choosing where, while the app runs on) checks + /// again for a review started meanwhile: the reviewed document stays open, and Close all and + /// Quit stop there. + #[test] + fn a_close_after_a_save_holds_for_a_review_started_meanwhile() { + let (mut app, a) = reviewing_the_first_of_three(); + let ctx = egui::Context::default(); + app.active = Some(2); + app.close_and_continue(&ctx, 0, crate::CloseRequest::Tab(a)); + assert_eq!(app.views.len(), 3, "the reviewed document stays open"); + assert_eq!(app.active, Some(0), "and comes to the front"); + assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "with its review as it was"); + app.close_and_continue(&ctx, 1, crate::CloseRequest::All); + assert_eq!(app.views.len(), 3, "Close all closes nothing while the review is open"); + app.note_quit_session(); + app.close_and_continue(&ctx, 2, crate::CloseRequest::Quit); + assert_eq!(app.views.len(), 3, "Quit closes nothing either"); + assert!(!app.allow_quit && app.quit_session.is_none(), "and the quit is called off"); + assert!(app.ocr_verify.reject()); + app.close_and_continue(&ctx, 0, crate::CloseRequest::Tab(a)); + assert_eq!(app.views.len(), 2, "after Reject the document closes"); + } + + /// Run OCR on another document while a review is held leaves the review alone and says why. + #[test] + fn a_run_on_another_document_keeps_the_review() { + let (mut app, a) = reviewing_the_first_of_three(); + FAIL_LAUNCH.with(|f| f.set(true)); + app.active = Some(1); + with_state(&mut app, |v, app| v.start_run(app)); + FAIL_LAUNCH.with(|f| f.set(false)); + assert!(app.ocr_verify.message.contains("other document"), "{}", app.ocr_verify.message); + assert!(app.ocr_verify.holds_reading_of(a), "the review is still of the first document"); + assert_eq!(app.ocr_verify.phase, Phase::ReviewReady); + assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited")); + } + + /// The embedding page hears an open review as work it would lose, as the reload warning does, + /// and hears it go once the review is rejected. + #[test] + fn the_embedding_page_hears_an_open_review() { + let (mut app, _) = reviewing_the_first_of_three(); + let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = reports.clone(); + app.host_dirty = Some(Box::new(move |dirty| sink.lock().expect("not poisoned").push(dirty))); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| app); + h.run_steps(2); + assert_eq!(reports.lock().expect("not poisoned").last(), Some(&true), "an open review is work at risk"); + assert!(h.state_mut().ocr_verify.reject()); + h.run_steps(2); + assert_eq!(reports.lock().expect("not poisoned").last(), Some(&false), "rejected, nothing is at risk"); + } + + /// A run of one page shows that page when its reading lands, not page 1. + #[test] + fn a_finished_run_shows_a_page_it_read() { + let mut v = OcrVerify { current: 0, ..Default::default() }; + let page = OcrPage { already_on_page: 0, page: 2, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + v.finished(vec![page], false); + assert_eq!(v.current, 2); + assert!(v.page(v.current).is_some()); + } + + /// A run whose worker thread cannot start leaves nothing running: an empty screen shows why + /// and holds no document open, and a review already on the screen stays as it was. + #[test] + fn a_run_that_cannot_start_keeps_the_review_and_holds_nothing() { + let mut app = PdfCraftApp::new(); + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + for name in ["a.pdf", "b.pdf"] { + app.open_bytes(name, None, bytes.clone()).expect("fixture"); + } + let (a, b) = (app.views[0].id, app.views[1].id); + FAIL_LAUNCH.with(|f| f.set(true)); + app.active = Some(1); + with_state(&mut app, |v, app| v.start_run(app)); + assert_eq!(app.ocr_verify.phase, Phase::RecoverableError, "not left running"); + assert!(app.ocr_verify.message.contains("could not start"), "{}", app.ocr_verify.message); + assert!(app.ocr_verify.can_run(), "Run is enabled again"); + assert!(!app.ocr_verify.holds_reading_of(b)); + app.request_close_tab(1); + assert_eq!(app.views.len(), 1, "the document closes"); + // A review on the screen: a run that cannot start leaves it as it was. + let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + app.ocr_verify.load(a, job.guard.clone(), vec![page]); + assert!(app.ocr_verify.correct((0, 0), "Edited")); + with_state(&mut app, |v, app| v.start_run(app)); + FAIL_LAUNCH.with(|f| f.set(false)); + assert_eq!(app.ocr_verify.phase, Phase::ReviewReady); + assert_eq!(app.ocr_verify.doc, Some(a)); + assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "the correction is kept"); + assert!(app.ocr_verify.progress.is_none()); + } + + /// A failed Accept keeps the reading (Run is enabled again), and Reject still throws it + /// away without a new run; a failed run that read nothing has nothing to reject. + #[test] + fn reject_throws_away_a_reading_whose_accept_failed() { + let mut s = Session::new(); + let (id, mut v) = loaded(&mut s, &[Some(95.0)]); + assert!(v.correct((0, 0), "Corrected")); + s.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("fixture"); + assert!(v.accept(&mut s).is_err(), "the stale reading is refused"); + assert_eq!(v.phase, Phase::RecoverableError); + assert!(v.holds_reading_of(id), "its document stays open"); + assert!(v.can_reject()); + assert!(v.reject()); + assert_eq!(v.phase, Phase::Idle); + assert!(v.pages.is_empty()); + assert!(!v.holds_reading_of(id), "and can close"); + let mut failed = OcrVerify::default(); + failed.failed("no engine"); + assert!(!failed.can_reject()); + assert!(!failed.reject(), "a failed run that read nothing has nothing to reject"); + } + + /// The web build cannot hold a reload or a closed tab the way Quit is held, so the browser's + /// leave warning asks while a reading is under review, even when nothing is unsaved, and + /// stops asking once the review is rejected. + #[test] + fn the_web_leave_warning_asks_while_a_review_is_open() { + use std::sync::atomic::Ordering; + let mut app = PdfCraftApp::new(); + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + app.open_bytes("a.pdf", None, bytes).expect("fixture"); + let a = app.views[0].id; + let ctx = egui::Context::default(); + app.guard_quit(&ctx); + assert!(!app.unsaved_flag.load(Ordering::Relaxed), "nothing to lose yet"); + let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + app.ocr_verify.load(a, job.guard.clone(), vec![page]); + assert!(app.first_dirty().is_none(), "nothing is unsaved"); + app.guard_quit(&ctx); + assert!(app.unsaved_flag.load(Ordering::Relaxed), "the open review is work a reload would lose"); + assert!(app.ocr_verify.reject()); + app.guard_quit(&ctx); + assert!(!app.unsaved_flag.load(Ordering::Relaxed), "after Reject there is nothing to lose"); + } + + /// Quitting, from Quit or by closing the window, while a reading is under review keeps the + /// application open and brings the review forward, even when no document has unsaved + /// changes; once the review is rejected, the application quits. + #[test] + fn quitting_brings_an_open_review_forward() { + let mut app = PdfCraftApp::new(); + let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec(); + for name in ["a.pdf", "b.pdf"] { + app.open_bytes(name, None, bytes.clone()).expect("fixture"); + } + let a = app.views[0].id; + let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture"); + let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }; + app.ocr_verify.load(a, job.guard.clone(), vec![page]); + assert!(app.first_dirty().is_none(), "nothing is unsaved"); + app.active = Some(1); + let ctx = egui::Context::default(); + assert!(!app.request_quit(&ctx), "Quit is held"); + assert!(!app.allow_quit); + assert_eq!(app.active, Some(0), "the reviewed document comes to the front"); + assert!(app.ocr_verify.open, "with its review"); + assert!(app.toast.is_some(), "a notice says to accept or reject it first"); + assert!(app.close_request.is_none(), "no save question: nothing is unsaved"); + // Closing the window, as the operating system does. + app.active = Some(1); + app.ocr_verify.open = false; + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + ctx.begin_pass(raw); + app.guard_quit(&ctx); + let mut out = ctx.end_pass(); + out.textures_delta.clear(); + let commands: Vec<_> = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect(); + assert!(commands.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose)), "{commands:?}"); + assert_eq!(app.active, Some(0)); + assert!(app.ocr_verify.open); + assert_eq!(app.views.len(), 2, "nothing closed"); + assert!(app.ocr_verify.reject()); + assert!(app.request_quit(&ctx), "after Reject, Quit goes ahead"); + assert!(app.allow_quit); + } + + /// A page that cannot be rendered is not rendered again on every frame, only once its + /// document changes; a page that renders clears the failure. + #[test] + fn a_page_that_cannot_render_is_not_rendered_on_every_frame() { + let mut app = PdfCraftApp::new(); + let text = app.session.create_from_text("t", "Recognize these scanned words").expect("fixture"); + let id = app.session.open("t.pdf", None, text, None).expect("fixture"); + app.ocr_verify.doc = Some(id); + FAIL_RENDER.with(|f| f.set(true)); + let renders = || PREVIEW_RENDERS.with(std::cell::Cell::get); + let before = renders(); + ensure_preview(&mut app, 0); + assert!(app.ocr_verify.preview.is_none(), "the page cannot render"); + assert_eq!(renders(), before + 1); + for _ in 0..3 { + ensure_preview(&mut app, 0); + } + assert_eq!(renders(), before + 1, "repaints do not render it again"); + app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("an edit"); + ensure_preview(&mut app, 0); + assert_eq!(renders(), before + 2, "a changed document is tried again"); + // A page that renders clears the failure. + FAIL_RENDER.with(|f| f.set(false)); + app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("an edit"); + ensure_preview(&mut app, 0); + assert!(app.ocr_verify.preview.is_some()); + assert!(app.ocr_verify.preview_failed.is_none()); + } } diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index c1a987fbb3..ec6af6d7de 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -377,7 +377,7 @@ fn ocr_verify_screen_controls_keyboard_and_accept() { h.run_steps(4); assert!(h.state_mut().execute("ocr.correct")); h.run_steps(3); - // The screen owns the window and its controls are all present (no dead controls). + // The screen's controls are all present (no dead controls). h.get_by_label("Run OCR"); h.get_by_label("Reject"); h.get_by_label("Next uncertain"); diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index a0af321a01..18c323d2d0 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -87,7 +87,7 @@ fn ocr_verify_screen_controls_keyboard_and_accept() { h.run_steps(4); assert!(h.state_mut().execute("ocr.correct")); h.run_steps(3); - // The screen owns the window and its controls are all present (no dead controls). + // The screen's controls are all present (no dead controls). h.get_by_label("Run OCR"); h.get_by_label("Reject"); h.get_by_label("Next uncertain"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cc3e231979..288b7ca0cf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -80,7 +80,7 @@ features total). | Target | Acrobat | PdfCraft | Fidelity | Tests and evidence | |---|---|---|---|---| -| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial | +| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and inferred tables (bordered only along the grid lines the page draws); positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial | | Word 97–2003 (`.doc`) | yes | no | none | | | Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned | | PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial | diff --git a/docs/windows-printing.md b/docs/windows-printing.md new file mode 100644 index 0000000000..e96566fc3d --- /dev/null +++ b/docs/windows-printing.md @@ -0,0 +1,25 @@ +# Windows print jobs rejected before submission + +A report on PdfCraft 0.19.0 described the preview closing without output while Edge printed the same PDF. On the investigated Windows machine, the HUAWEI CV81Z driver accepted the PrintTicket (paper, grayscale, duplex and collation), and its capabilities XML parsed successfully. + +The native submission test failed before queueing: `StorageFile::GetFileFromPathAsync` returned `0x80070005` for the temporary PDF. Ordinary desktop file access could read it. This isolates one reproducible failure; it does not establish the cause of every report of an empty print queue. + +The winprint loader uses `CreateRandomAccessStreamOnFile` with read-only access, then calls `PdfDocument::LoadFromStreamAsync`. The stream is backed by the job file and reads on demand. There is no whole-job `InMemoryRandomAccessStream` copy, `DataWriter`, transfer loop or per-chunk `StoreAsync().join()`. The path is made absolute and embedded NUL characters are rejected before calling the native API. The temporary file remains alive throughout rendering and submission through craft-print's existing lifetime management. PDF decoding and rendering can still allocate memory; the change removes the loader's full-file copy, not all printing memory use. + +Upstream loader: https://github.com/storytold/winprint.rs/pull/15 (`51934db3`). Dependency follow-up: https://github.com/storytold/craft-libs/pull/636 (`7ee35a9f`, includes merged #605). PdfCraft updates only its normal `craft-print` dependency. There is no global patch or personal-fork source. The loader revision descends from `64e7afd4`, preserving the multi-threaded Direct2D factory fix for #5006. Upstream reviews are still required before release. + +The public `craft_print::rgb` GDI transport remains pinned to main's `64e7afd4` revision. It is independent of the internal PDF-loader pin `51934db3`: retaining the public source identity avoids incompatible Rust types when a downstream application also uses that GDI revision directly. PdfCraft resolves one GDI package at that stable revision. + +## Evidence + +- Original Windows loader: synthetic A4 submission failed at PDF loading with `0x80070005`. The earlier memory-stream prototype submitted the same HUAWEI job; physical output was not independently confirmed. +- Final file-backed loader: five focused tests pass, covering a hidden PDF, read-only stream behavior, reading the final byte of a 256 MiB file, a missing file, and a path containing NUL. +- The hidden-file test was observed failing with the original StorageFile loader. The read-only test was observed failing with the earlier in-memory implementation and passing with the final loader. +- Isolated craft-print suite with the final loader and multi-threaded Direct2D source: 63 passed, 3 hardware tests ignored. These diagnostic tests are not a full build of the final dependency graph; the disabled PDFium downloader build dependencies were omitted locally. +- The file-backed loader at `b334ebbb` was subsequently submitted natively to the HUAWEI CV81Z; the reporter confirmed physical output of the synthetic A4 page. This used an isolated diagnostic workspace and writable TEMP directory, not the packaged UI. See https://github.com/storytold/winprint.rs/pull/15#issuecomment-6704470691. +- The current loader pin `51934db3` also includes the review repairs: standard-library NUL validation, GetPage(0) before document/file cleanup, RAII temporary-file cleanup, and the explicit WinRT feature. In a complete Windows checkout with normal build dependencies, five loader tests and 14 Print Schema tests passed; injected failures left no temporary files behind. +- Full final-graph build/quality gates must be rerun for the updated pins. Packaged-UI and wider printer/duplex qualification remain unverified; single-page output does not establish those. + +When reporting a different failure, include the PdfCraft version, printer/driver name, displayed error and whether a job appears in the Windows queue. Saving the print-ready PDF and printing it in another viewer remains a workaround until the fix ships. + +The loader pin also includes a bounded 30-second wait in the large-file native stream regression test. Production `PdfDocument::LoadFromStreamAsync` still has no dedicated timeout; the terminal print-completion timeout does not cover document loading. Current dependency-graph CI must pass before merging; earlier-head results do not qualify this update. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c108221d44..96786186af 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6911,8 +6911,8 @@ milestone = "M10" status = "partial" commands = ["ocr.correct"] tools = ["ocr_words", "ocr_review_apply", "ocr_dictionary"] -evidence = ["crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_uses_recognition_language_instead_of_interface_locale", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_limit_and_normalization_failures_preserve_file_and_review", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_undo_persists_and_failed_undo_keeps_history", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_keeps_captured_language_when_the_draft_changes", "crates/automation/tests/it/automation.rs::ocr_review_corrections_keep_boxes_are_guarded_and_apply_once", "crates/automation/tests/it/automation.rs::ocr_dictionary_updates_are_atomic_bounded_and_confined", "crates/automation/tests/it/automation.rs::ocr_retained_reviews_have_eight_document_cap_and_close_releases_slot", "crates/ui-egui/src/workflows/ocr_verify.rs::accept_zero_words_fails_and_a_live_review_applies_once", "crates/ui-egui/src/workflows/ocr_verify.rs::legend_rows_carry_the_classifier_thresholds", "crates/ui-egui/src/workflows/ocr_verify.rs::suggestions_are_near_dictionary_and_page_words", "crates/ui-egui/tests/it/ocr.rs::ocr_verify_screen_controls_keyboard_and_accept", "crates/ui-egui/src/workflows/ocr_verify.rs::accepting_pages_one_by_one_then_the_review_applies_every_page_once", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_a_region_remaps_marks_and_selects_the_first_new_word"] -notes = "The OCR Verify screen: page list, source page with confidence-coloured word boxes (click-select, drag a region), recognised-text preview, zoom + word inspector; corrections commit on Enter (an empty commit removes), Remove/Restore, +Dictionary (a per-language user file), per-word Verified, suggestions within edit distance 2; F8/Shift+F8 walk the low-confidence suspects, Ctrl+T marks the page reviewed, Ctrl+Tab moves the pane focus; Re-OCR of a region or a page; Accept/Reject entire page; a real Cancel keeps the pages already read; a stale session is refused with its reason; review edits are undoable in the panel; Accept (searchable) is one undoable step through the guard path, and pages accepted one by one each apply once (the guard follows each accepted page); a re-read keeps every review mark on the word it was made for. Headless: ocr_words (retain_review) returns the words and a review handle, ocr_review_apply applies corrections at the stored boxes (an empty one removes the word) through the same stale-document guard, or discards the review, and ocr_dictionary edits the dictionary file. Still missing: suggestions, per-word Verified marks and region re-reads are UI-only, and Acrobat's suspect walk across a whole saved document (outside one review) is not offered." +evidence = ["crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_uses_recognition_language_instead_of_interface_locale", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_limit_and_normalization_failures_preserve_file_and_review", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_undo_persists_and_failed_undo_keeps_history", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_keeps_captured_language_when_the_draft_changes", "crates/automation/tests/it/automation.rs::ocr_review_corrections_keep_boxes_are_guarded_and_apply_once", "crates/automation/tests/it/automation.rs::ocr_dictionary_updates_are_atomic_bounded_and_confined", "crates/automation/tests/it/automation.rs::ocr_retained_reviews_have_eight_document_cap_and_close_releases_slot", "crates/ui-egui/src/workflows/ocr_verify.rs::accept_zero_words_fails_and_a_live_review_applies_once", "crates/ui-egui/src/workflows/ocr_verify.rs::legend_rows_carry_the_classifier_thresholds", "crates/ui-egui/src/workflows/ocr_verify.rs::suggestions_are_near_dictionary_and_page_words", "crates/ui-egui/tests/it/ocr.rs::ocr_verify_screen_controls_keyboard_and_accept", "crates/ui-egui/src/workflows/ocr_verify.rs::accepting_pages_one_by_one_then_the_review_applies_every_page_once", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_a_region_remaps_marks_and_selects_the_first_new_word", "crates/ui-egui/src/workflows/ocr_verify.rs::correct_recognized_text_keeps_the_chrome_and_shows_the_page", "crates/ui-egui/src/workflows/ocr_verify.rs::a_finished_run_shows_a_page_it_read", "crates/ui-egui/src/workflows/ocr_verify.rs::a_finished_run_shows_a_read_page_over_a_skipped_one", "crates/ui-egui/src/workflows/ocr_verify.rs::page_indicator_numbers_pages_in_the_document", "crates/ui-egui/src/workflows/ocr_verify.rs::closing_the_reviewed_document_brings_its_review_forward"] +notes = "The OCR Verify screen, in the document area under the window's tabs, menus and toolbar (another mode or the Home tab puts it away, as Exit does, and Correct recognized text brings the review back; closing the reviewed document brings its review forward to accept or reject first): page list, source page (the document in front, at its current page, before a run; a page the run recognized after one, over a page skipped for its text; PAGE is that page's number over the document's page count) with confidence-coloured word boxes (click-select, drag a region), recognised-text preview, zoom + word inspector; corrections commit on Enter (an empty commit removes), Remove/Restore, +Dictionary (a per-language user file), per-word Verified, suggestions within edit distance 2; F8/Shift+F8 walk the low-confidence suspects, Ctrl+T marks the page reviewed, Ctrl+Tab moves the pane focus; Re-OCR of a region or a page; Accept/Reject entire page; a real Cancel keeps the pages already read; a stale session is refused with its reason; review edits are undoable in the panel; Accept (searchable) is one undoable step through the guard path, and pages accepted one by one each apply once (the guard follows each accepted page); a re-read keeps every review mark on the word it was made for. Headless: ocr_words (retain_review) returns the words and a review handle, ocr_review_apply applies corrections at the stored boxes (an empty one removes the word) through the same stale-document guard, or discards the review, and ocr_dictionary edits the dictionary file. Still missing: suggestions, per-word Verified marks and region re-reads are UI-only, and Acrobat's suspect walk across a whole saved document (outside one review) is not offered." [[feature]] id = "ocr.adaptive-compression" @@ -7204,7 +7204,7 @@ 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." +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), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (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 other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf"