From c5c5d0d661b23c490192e8be4b119ade5bea543e Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:24:33 +0200 Subject: [PATCH] M2: Add word and character counts with optional content exclusions (#1780) * M2: Add word and character counts with optional content exclusions Retain the complete reviewed change, including prior integration resolutions, on current upstream. M2: Add document word counts and semantic exclusions * Refresh catalog hashes after merging main --------- Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 ++-- Cargo.lock | 1 + crates/automation/README.md | 4 + crates/automation/src/lib.rs | 23 ++ crates/automation/src/tools.rs | 6 + crates/automation/tests/automation.rs | 24 ++ crates/engine/Cargo.toml | 1 + crates/engine/README.md | 3 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/word_count.rs | 336 ++++++++++++++++++++++++++ crates/render/src/raster.rs | 8 + crates/render/src/text.rs | 71 +++++- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 6 + crates/ui-egui/src/i18n/ar.tsv | 16 ++ crates/ui-egui/src/i18n/bg.tsv | 16 ++ crates/ui-egui/src/i18n/cs.tsv | 16 ++ crates/ui-egui/src/i18n/de.tsv | 16 ++ crates/ui-egui/src/i18n/el.tsv | 16 ++ crates/ui-egui/src/i18n/es.tsv | 16 ++ crates/ui-egui/src/i18n/fr.tsv | 16 ++ crates/ui-egui/src/i18n/hu.tsv | 16 ++ crates/ui-egui/src/i18n/it.tsv | 16 ++ crates/ui-egui/src/i18n/ja.tsv | 16 ++ crates/ui-egui/src/i18n/kk.tsv | 16 ++ crates/ui-egui/src/i18n/lt.tsv | 16 ++ crates/ui-egui/src/i18n/pl.tsv | 16 ++ crates/ui-egui/src/i18n/pt-br.tsv | 16 ++ crates/ui-egui/src/i18n/ru.tsv | 16 ++ crates/ui-egui/src/i18n/sl.tsv | 16 ++ crates/ui-egui/src/i18n/te.tsv | 16 ++ crates/ui-egui/src/i18n/tr.tsv | 16 ++ crates/ui-egui/src/i18n/uk.tsv | 16 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 16 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 16 ++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/word_count_ui.rs | 225 +++++++++++++++++ docs/word-count.md | 52 ++++ parity/acrobat-features.toml | 12 + 40 files changed, 1130 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/word_count.rs create mode 100644 crates/ui-egui/src/word_count_ui.rs create mode 100644 docs/word-count.md diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 763dc4a21..4c7103baf 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1fd7a303d0c256893a3201eee403386f1501a5706af5903e843e9f5a8449d619" +sha256 = "a579e60a58e88ea83732609f2fb0921afa3d5d61082bea3be969db7ad6d3a12f" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e3e896ca45a954406a235b66085de6a3e6c8209a1d2a1164ac8ae88a1562cdfb" +sha256 = "eae078763f4d4b6267cce596d405e28102ebb872d9cb8bc375671cd4d264da9e" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8ec618949e296e540df72051ff284e5d082f3cd999202f727660f91a7dd3c7b9" +sha256 = "b94db0634220f21673b649be78e65bf5d9b2f939afd3992b1a5ab567d77677b0" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ffb55f4e78a7dacb6d76bc36f1f5e593b889d5f6680f1c53c8818b1b9cdce501" +sha256 = "6f2d46364ee4a703f6a05017d5cf6f2da4216bb83b577092b456db742a961d2a" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "714af0e8635275baec6f8a07dd96fc1976dfa5269d462549446985a8786bbb21" +sha256 = "0a44a27428691465074177508dde58475188fc3ad176d5f43a74dd4d518a2495" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fbdf46be923e9cc88cb62019108239d2a47fdd3cc54e87f30a70ad9497364180" +sha256 = "4320582e8212a8387bce630107e19ef2ffb78586d453d36872ac40a9bf917b1b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e0f031f7ec4c30e9750575792ae18920a0a42ad215f1c394632b52ca308ca65f" +sha256 = "0060ce0ff2ee45ac5d6ebfcc39eb9aec565006fee2867623e12572a1b4b80b5e" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4a3bbef4db399b02ec62804e4614a2dec42942f90da613854590cbc4b06ea082" +sha256 = "abeb8b6758e460d9d1fac894392f280fc2d0dadb30edce784ee0625eb318726c" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "41ab9886af95cc845ff8d7d89f2794e3663f5682c21e9f553480edb5499f5abf" +sha256 = "7fc8def1baa4680ac1821928c4b33c27ad15257b6be8ecaa43d6bad28d49d2b3" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1ecc78878e5c5151f1afc4be2cb15ba05095d35119a2bfadac02b0b921c52df4" +sha256 = "ef898359cd992adff06d1889536aea90246ae63443f5181e71a8b8b12cdfd4d8" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1e30d02a94f554abf24f23e84242bcc04688de5e3631873147504f900568d13a" +sha256 = "2ee8d548ad696820a9710937d4d27d5dfe529244cd20df2664a4192ded639408" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ae04d6fab0f225d01de7907d70dc58a9135d65f127f845c553344bcfd08fcd6d" +sha256 = "e7f2727e4ee17a2f9a37bf8d8bf563c625c8700e1389de0b79c22002b8d3919c" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7ff4cd557d7d3ee1ada2d667653db578bae8964f021366cb11d9d03f81d8cb32" +sha256 = "b1bb541fa2a453537c383b9346d8c9504b7559004ae94a851f18348de2910694" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fefc14be320cc7625b1c0a02cc26f27722b5d73052ef2d6056f3bb07363e5888" +sha256 = "f90f24f1d30c8e8cdc595e2b229e1aca35125b55af0fce5b26d0befbb31f1fc1" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6590241c4156d0da89011948e5fd65b363916d7724390d854d19cd21d3e9f440" +sha256 = "5bc5f87e61cc1feb64d9ae94ec39f218aa7abe6697065647152104197a356d48" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "12f0cfe67ad61741ebb96668137eb62a520ef3c4bb7961d451911c73d58ae0b8" +sha256 = "95d004849c3760587098ea0bfc658aa0d94887ff4f2a22d7f3d1565df5ddaf30" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5db1450bf0e71db21c9bcb994b4e7a529106ae52ffae2d6118997295e0ac8517" +sha256 = "ca109e277cbe196394fb50323c632ff65972f9aa60941e69cbb244857930eec0" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b34a98f97fb5ceb8f602ab005e859b070d72d73fd3def5a73a3f9eb6915a4d28" +sha256 = "aa1b6741974392a8b246bab04169608211d6d6f19f2adb000cf033a6d1a3ef2e" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17b984f557eb36908baf06880a3b540eadc39910283b37e0ae905175db320f45" +sha256 = "895f2f8e6b44f7cf7f383e13765e62b5875938ded5135119b3e0621f6375c3d5" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2daa34aacddccdbe9862932c5c6f0a0d8bdb86b07cc385a82f0ceaf9f1c4f333" +sha256 = "e5f36292f84ec43ae42479735f88c2667bef05f719ab11e47daaa517cd070f3e" [[asset]] @@ -3363,4 +3363,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "25f947c54e32c9bc7fb0411ade704fc464fbee7f8a73155f4cf23516311547ce" +sha256 = "25523c021600c5aecc778f53e707a114267a4463ab911fd83658a4c241ece6d3" diff --git a/Cargo.lock b/Cargo.lock index 3a63eb57d..d7dc06ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4067,6 +4067,7 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "unicode-segmentation", "url", ] diff --git a/crates/automation/README.md b/crates/automation/README.md index d702b7ed4..3d3a46c0f 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -30,6 +30,10 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS ## Text search and cache limits +`text_word_count` counts the current snapshot without filling the text cache. It returns totals +and per-page Unicode word/character counts, with optional tagged-table, tagged-code and +image/OCR exclusions. See [word count](../../docs/word-count.md) for counting rules and limits. + Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 247da4a55..285699473 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -186,6 +186,7 @@ impl Automation { "page_render" => return self.page_render(&a).map(|c| vec![c]), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, "text_find" => self.text_find(&a)?, "page_rotate" => { let degrees = a.int("degrees")?; @@ -1758,6 +1759,28 @@ impl Automation { Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) } + fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + fn text_extract(&mut self, a: &Args) -> Result { let doc = self.doc_to_copy(a)?; let id = doc.id; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 98bf2754a..4e684c721 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -171,6 +171,12 @@ pub fn tools() -> Vec { "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), + t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.") + .ro() + .cmd("file.word_count") + .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), + "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } + }), &["doc"])), t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.") .ro() .cmd("edit.column_select") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a6c6fad46..38d67f6d6 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -433,6 +433,29 @@ fn open_inspect_render_and_find() { assert_eq!((info.width, info.height), (200, 300)); } +#[test] +fn word_count_reads_selected_pages_and_unsaved_edits_without_mutating() { + let dir = workdir("word-count"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "doc_list", json!({})); + let report = ok(&mut a, "text_word_count", json!({ "doc": doc })); + assert_eq!(report["total"]["words"], 6); + assert_eq!(report["pages"].as_array().unwrap().len(), 3); + let subset = ok(&mut a, "text_word_count", json!({ "doc": doc, "pages": [3, 1], "exclude_tables": true })); + assert_eq!(subset["total"]["words"], 4); + assert_eq!(subset["pages"][0]["page"], 3); + assert_eq!(subset["unclassified_structure"], true); + assert_eq!(ok(&mut a, "doc_list", json!({})), before); + assert!(a.call("text_word_count", &json!({ "doc": doc, "pages": [4] })).is_err()); + ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [1] })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 4); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 6); + let def = tools().into_iter().find(|t| t.name == "text_word_count").unwrap(); + assert!(def.read_only && !def.destructive); +} + #[test] fn text_editor_background_is_read_only_and_removes_source_glyphs() { let dir = workdir("text-preview"); @@ -1599,6 +1622,7 @@ fn protecting_through_tools() { // Copying wasn't allowed either (#568): no text or images come out, though search works. for (tool, args) in [ ("text_extract", json!({ "doc": re })), + ("text_word_count", json!({ "doc": re })), ("text_paragraphs", json!({ "doc": re, "page": 1 })), ("doc_export_text", json!({ "doc": re, "path": "out.txt" })), ("doc_export_images", json!({ "doc": re, "folder": "pages" })), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eecbb35c4..7de028763 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -41,6 +41,7 @@ png = "0.18" url = "2.5" idna = "1.1" icu_properties = "2.3" +unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Already in the graph through lopdf; used only for the local UTC offset ("today"). diff --git a/crates/engine/README.md b/crates/engine/README.md index 982d2fb1a..4bf80e3f7 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -3,6 +3,9 @@ The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates. +`word_count::count` provides cancellable, page-at-a-time Unicode word and character statistics +with optional semantic exclusions; see [word-count behavior and limits](../../docs/word-count.md). + - **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** — `egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it. - The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cf4038645..228bdd0ff 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -190,6 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"), c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"), c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"), + c("file.word_count", "Word count…", FILE, None, Copying, "hash"), ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3b9a1e03..e1654e781 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod signature_scan; pub mod text_fonts; mod text_preview; mod upright_image; +pub mod word_count; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; diff --git a/crates/engine/src/word_count.rs b/crates/engine/src/word_count.rs new file mode 100644 index 000000000..bd31c27e4 --- /dev/null +++ b/crates/engine/src/word_count.rs @@ -0,0 +1,336 @@ +//! Read-only document statistics, processing one page at a time without rasterizing it. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, ObjRef, Object}; +use pdfcraft_render::{PageRenderer, text::TextFilter}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::export::ExportSource; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Options { + pub exclude_tables: bool, + pub exclude_code: bool, + /// Figure-marked text and invisible text (including OCR). This does not run OCR. + pub exclude_images: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Counts { + pub words: usize, + /// Unicode scalar values, including whitespace. + pub characters: usize, + pub characters_without_spaces: usize, +} + +impl Counts { + pub fn of(text: &str) -> Self { + Self { + words: text.unicode_words().count(), + characters: text.chars().count(), + characters_without_spaces: text.chars().filter(|c| !c.is_whitespace()).count(), + } + } + + fn add(&mut self, other: Self) { + self.words = self.words.saturating_add(other.words); + self.characters = self.characters.saturating_add(other.characters); + self.characters_without_spaces = self.characters_without_spaces.saturating_add(other.characters_without_spaces); + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + pub total: Counts, + /// Zero-based pages, in the requested order, without duplicates. + pub pages: Vec<(usize, Counts)>, + pub pages_without_text: usize, + /// Some structure could not be classified safely; that text was retained. + pub unclassified_structure: bool, +} + +/// Count the current document snapshot. `progress` can cancel between pages. A failed page is +/// an error, never a successful zero or an unlabelled partial total. +pub fn count(source: ExportSource, pages: &[usize], options: Options, progress: impl FnMut(usize) -> bool) -> Result { + crate::guard(|| count_inner(source, pages, options, progress))? +} + +fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut progress: impl FnMut(usize) -> bool) -> Result { + if pages.iter().any(|&page| page >= source.pages) { + return Err("the word-count page range is out of bounds".into()); + } + if !progress(0) { + return Err("word count cancelled".into()); + } + let (mut excluded, unclassified) = if options != Options::default() { exclusions(&source, options)? } else { (HashMap::new(), false) }; + let mut renderer = PageRenderer::new(source.bytes, source.config); + let mut report = Report { unclassified_structure: unclassified, ..Report::default() }; + let mut seen = HashSet::new(); + for &page in pages { + if !seen.insert(page) { + continue; + } + if !progress(report.pages.len()) { + return Err("word count cancelled".into()); + } + let filter = TextFilter { + tables: options.exclude_tables, + code: options.exclude_code, + images: options.exclude_images, + mcids: excluded.remove(&page).unwrap_or_default(), + }; + let text = renderer.filtered_text(page, filter).map_err(|e| format!("page {}: {e}", page.saturating_add(1)))?.plain_text(); + let counts = Counts::of(&text); + report.pages_without_text += usize::from(text.trim().is_empty()); + report.total.add(counts); + report.pages.push((page, counts)); + } + if !progress(report.pages.len()) { + return Err("word count cancelled".into()); + } + Ok(report) +} + +fn excluded_role(role: &[u8], options: Options) -> bool { + (options.exclude_tables && matches!(role, b"Table" | b"TR" | b"TD" | b"TH" | b"THead" | b"TBody" | b"TFoot")) + || (options.exclude_code && role == b"Code") + || (options.exclude_images && role == b"Figure") +} + +fn standard_role(doc: &pdfcraft_cos::Document, map: &Dict, role: &[u8]) -> Vec { + let mut role = role.to_vec(); + for _ in 0..32 { + let Some(next) = map.get(&role).map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)) else { break }; + if next == role { + break; + } + role = next; + } + role +} + +type Exclusions = HashMap>; + +/// Resolve page-local MCIDs through the structure hierarchy, including inherited roles and +/// RoleMap aliases. Form XObjects have a separate MCID namespace that the text device does not +/// expose: retain their page's MCID-tagged text instead of excluding an unrelated same-numbered +/// span. Explicit Table/Code/Figure marked-content tags still work on those pages. +fn exclusions(source: &ExportSource, options: Options) -> Result<(Exclusions, bool), String> { + let doc = pdfcraft_cos::Document::open_with_password(source.bytes.clone(), source.config.password.as_deref()).map_err(|e| e.to_string())?; + let catalog = doc.root().and_then(|id| doc.get(id).as_dict().cloned()).unwrap_or_default(); + let Some(root) = catalog.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else { + return Ok((HashMap::new(), options.exclude_tables || options.exclude_code)); + }; + let roles = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let pages = pdfcraft_model::pages(&doc); + let page_index: HashMap<_, _> = pages.iter().enumerate().map(|(index, page)| (page.obj, index)).collect(); + let mut stack = root.get(b"K").map(|k| vec![(k.clone(), None::, false, 0usize)]).unwrap_or_default(); + let mut seen = HashSet::new(); + let mut out: Exclusions = HashMap::new(); + let mut unclassified = false; + let mut visited = 0usize; + while let Some((object, page, excluded, depth)) = stack.pop() { + visited += 1; + if visited > 100_000 || depth > 128 { + unclassified = true; + break; + } + if let Some(id) = object.as_ref() + && !seen.insert(id) + { + unclassified = true; + continue; + } + let object = doc.resolve(&object); + let mut keep = |id: i64, page: Option| { + if excluded { + if let Some((page, id)) = page.and_then(|p| page_index.get(&p).copied()).zip(i32::try_from(id).ok().filter(|id| *id >= 0)) { + out.entry(page).or_default().insert(id); + } else { + unclassified = true; + } + } + }; + match &*object { + Object::Int(id) => keep(*id, page), + Object::Array(items) => { + if stack.len().saturating_add(items.len()) > 100_000 { + unclassified = true; + break; + } + stack.extend(items.iter().rev().map(|child| (child.clone(), page, excluded, depth + 1))); + } + Object::Dict(dict) => { + let page = dict.get(b"Pg").and_then(Object::as_ref).or(page); + if dict.contains(b"Stm") { + unclassified = true; + } else if let Some(id) = dict.get(b"MCID").and_then(Object::as_int) { + keep(id, page); + } else if let Some(kids) = dict.get(b"K") { + let excluded = excluded || dict.name(b"S").is_some_and(|role| excluded_role(&standard_role(&doc, &roles, role), options)); + stack.push((kids.clone(), page, excluded, depth + 1)); + } + } + _ => {} + } + } + for (index, page) in pages.iter().enumerate() { + if !out.contains_key(&index) { + continue; + } + let resources = page.dict.get(b"Resources").map(|o| doc.resolve(o)); + let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); + let has_form = objects.as_ref().and_then(|o| o.as_dict()).is_some_and(|d| { + d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))) + }); + if has_form { + out.remove(&index); + unclassified = true; + } + } + Ok((out, unclassified)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(bodies: &[&str], structure: &str) -> ExportSource { + let kids = (0..bodies.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::>().join(" "); + let mut objects = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {structure} >>"), + format!("<< /Type /Pages /Kids [{kids}] /Count {} /MediaBox [0 0 200 300] >>", bodies.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + for (i, body) in bodies.iter().enumerate() { + objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + i * 2)); + objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + ExportSource { bytes: std::sync::Arc::new(bytes), config: Default::default(), pages: bodies.len(), sizes: vec![(200.0, 300.0); bodies.len()] } + } + + const MARKED: &str = "BT /F1 12 Tf 10 270 Td (Body words) Tj ET + /Table BMC BT /F1 12 Tf 10 240 Td (Table words) Tj ET EMC + /Code BMC BT /F1 12 Tf 10 210 Td (Code words) Tj ET EMC + /Figure BMC BT /F1 12 Tf 10 180 Td (Figure words) Tj ET EMC + BT /F1 12 Tf 3 Tr 10 150 Td (OCR words) Tj ET"; + + #[test] + fn word_count_filters_are_independent_and_do_not_change_normal_extraction() { + let src = source(&[MARKED], ""); + for (options, expected) in [ + (Options::default(), 10), + (Options { exclude_tables: true, ..Options::default() }, 8), + (Options { exclude_code: true, ..Options::default() }, 8), + (Options { exclude_images: true, ..Options::default() }, 6), + (Options { exclude_tables: true, exclude_code: true, exclude_images: true }, 2), + ] { + assert_eq!(count(src.clone(), &[0], options, |_| true).unwrap().total.words, expected); + } + let mut renderer = PageRenderer::new(src.bytes, src.config); + assert_eq!(Counts::of(&renderer.filtered_text(0, TextFilter::default()).unwrap().plain_text()).words, 10); + assert_eq!(renderer.stats().rasterizations, 0); + } + + #[test] + fn word_count_resolves_structure_roles_and_page_local_mcids() { + let body = "BT /F1 12 Tf 10 260 Td (Body words) Tj ET /Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Tagged words) Tj ET EMC"; + let src = source( + &[body, body], + "/StructTreeRoot << /RoleMap << /Grid /Table >> /K [<< /S /Grid /Pg 4 0 R /K << /S /Span /K 0 >> >> << /S /Code /Pg 6 0 R /K << /Type /MCR /MCID 0 >> >>] >>", + ); + let tables = count(src.clone(), &[0, 1], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(tables.pages.iter().map(|(_, c)| c.words).collect::>(), vec![2, 4]); + assert!(!tables.unclassified_structure); + let code = count(src, &[1, 0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(code.pages.iter().map(|(_, c)| c.words).collect::>(), vec![2, 4]); + } + + #[test] + fn word_count_handles_empty_duplicate_invalid_and_cancelled_ranges() { + let src = source(&["BT /F1 12 Tf 10 220 Td (Two words) Tj ET", ""], ""); + let report = count(src.clone(), &[1, 0, 1], Options::default(), |_| true).unwrap(); + assert_eq!(report.total.words, 2); + assert_eq!(report.pages.len(), 2); + assert_eq!(report.pages_without_text, 1); + assert_eq!(count(src.clone(), &[], Options::default(), |_| true).unwrap().total, Counts::default()); + assert!(count(src.clone(), &[2], Options::default(), |_| true).unwrap_err().contains("bounds")); + assert!(count(src.clone(), &[0, 1], Options::default(), |done| done < 1).unwrap_err().contains("cancelled")); + let invalid = ExportSource { bytes: std::sync::Arc::new(b"not a PDF".to_vec()), ..src }; + assert!(count(invalid, &[0], Options::default(), |_| true).is_err()); + } + + #[test] + fn word_count_actual_text_cannot_reintroduce_excluded_words() { + let all = source(&["/Span << /ActualText (OCR words) >> BDC BT /F1 12 Tf 3 Tr 10 220 Td (xx) Tj ET EMC"], ""); + let images = Options { exclude_images: true, ..Options::default() }; + assert_eq!(count(all.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 2); + assert_eq!(count(all, &[0], images, |_| true).unwrap().total.words, 0); + let mixed = source( + &[ + "/Span << /ActualText (Body and code) >> BDC BT /F1 12 Tf 10 220 Td (body) Tj ET /Code BMC BT /F1 12 Tf 50 220 Td (code) Tj ET EMC EMC", + ], + "", + ); + assert_eq!(count(mixed.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 3); + assert!(count(mixed, &[0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap_err().contains("replacement text")); + } + + #[test] + fn word_count_cyclic_structure_is_bounded_and_reported() { + // The structure reaches the catalog again instead of a structure child. + let src = source(&[MARKED], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K [0 1 0 R] >> >> /K 1 0 R"); + let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert!(report.unclassified_structure); + } + + #[test] + fn word_count_retains_form_namespaces_and_reports_the_limitation() { + let body = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Page words) Tj ET EMC /Fm Do"; + let mut src = source(&[body], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K 0 >> >>"); + let mut doc = pdfcraft_cos::Document::open(src.bytes.clone()).unwrap(); + let page = ObjRef::new(4, 0); + let mut resources = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::Name(b"XObject".to_vec())); + form.set(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())); + form.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 200.into(), 300.into()])); + form.set(b"Resources".to_vec(), resources.clone()); + let form = doc.add(pdfcraft_cos::Stream::from_raw(form, b"/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 180 Td (Form words) Tj ET EMC".to_vec())); + let mut objects = Dict::new(); + objects.set(b"Fm".to_vec(), form); + resources.set(b"XObject".to_vec(), objects); + // Inherit resources from the page tree too. + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + src.bytes = std::sync::Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(report.total.words, 4, "the unrelated form's MCID must not be excluded"); + assert!(report.unclassified_structure); + } + + #[test] + fn unicode_words_ignore_punctuation_and_keep_apostrophes() { + assert_eq!(Counts::of("Hello, Welt! don't … 123").words, 4); + assert_eq!(Counts::of("Привет мир").words, 2); + assert_eq!(Counts::of("\t \n...").words, 0); + assert_eq!(Counts::of("ä b").characters, 3); + assert_eq!(Counts::of("ä b").characters_without_spaces, 2); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d1425717d..60a0ecc66 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -433,6 +433,14 @@ impl PageRenderer { self.stats } + /// Extract one page for statistics, with semantic exclusions. Never rasterizes or panics. + pub fn filtered_text(&mut self, page: usize, filter: crate::text::TextFilter) -> Result { + let pdf = self.pdf.as_ref().ok_or("the document could not be parsed")?; + self.stats.text_interpretations = self.stats.text_interpretations.saturating_add(1); + catch_unwind(AssertUnwindSafe(|| crate::text::extract_page_filtered(pdf, page, &self.settings, filter))) + .map_err(|e| format!("text extraction failed: {}", panic_message(&e)))? + } + /// Render one page. Never panics. pub fn render(&mut self, req: RenderRequest) -> RenderedPage { let start = Stopwatch::start(); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 92b6be98e..ae5ffa9a9 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -14,7 +14,27 @@ use hayro::hayro_interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +/// Semantic exclusions for statistics. Untagged text is retained; no layout guess is made. +#[derive(Clone, Debug, Default)] +pub struct TextFilter { + pub tables: bool, + pub code: bool, + /// Exclude Figure-marked text and invisible text layers, including OCR. + pub images: bool, + /// Page-local MCIDs whose structure ancestors match the selected exclusions. + pub mcids: HashSet, +} + +impl TextFilter { + fn excludes(&self, tag: &[u8], mcid: Option) -> bool { + (self.tables && matches!(tag, b"Table" | b"TR" | b"TH" | b"TD" | b"THead" | b"TBody" | b"TFoot")) + || (self.code && tag == b"Code") + || (self.images && tag == b"Figure") + || mcid.is_some_and(|id| self.mcids.contains(&id)) + } +} mod search; @@ -338,6 +358,10 @@ struct TextDevice { actual: Option<(usize, String, Vec)>, /// Every `/ActualText` replacement made on the page. replaced: Vec, + filter: TextFilter, + excluded_depth: Option, + actual_filtered: bool, + partial_replacement: bool, } /// An `/ActualText` replacement: the glyph standing for it in `TextDevice::glyphs` (none for an @@ -402,7 +426,16 @@ fn merge(drawn: &[Drawn], text: String) -> Option { impl TextDevice { fn new() -> Self { - TextDevice { glyphs: Vec::new(), depth: 0, actual: None, replaced: Vec::new() } + TextDevice { + glyphs: Vec::new(), + depth: 0, + actual: None, + replaced: Vec::new(), + filter: TextFilter::default(), + excluded_depth: None, + actual_filtered: false, + partial_replacement: false, + } } /// Ends the open `/ActualText` replacement (ISO 32000-2 §14.9.4): its glyphs become one glyph @@ -415,6 +448,12 @@ impl TextDevice { /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. fn end_actual_text(&mut self) { let Some((_, text, drawn)) = self.actual.take() else { return }; + if std::mem::take(&mut self.actual_filtered) { + // ActualText has no mapping from replacement words to individual glyphs. A mixed + // span cannot be filtered honestly; a wholly excluded span can simply be omitted. + self.partial_replacement |= !drawn.is_empty(); + return; + } let Some(glyph) = merge(&drawn, text.clone()) else { self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); return; @@ -516,7 +555,11 @@ impl<'a> Device<'a> for TextDevice { fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) { + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, mode: &GlyphDrawMode) { + if self.excluded_depth.is_some() || (self.filter.images && matches!(mode, GlyphDrawMode::Invisible)) { + self.actual_filtered |= self.actual.is_some(); + return; + } // Inside an `/ActualText` replacement only the glyph's box matters. let replaced = self.actual.is_some(); let text = match glyph.as_unicode() { @@ -597,12 +640,15 @@ impl<'a> Device<'a> for TextDevice { fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} fn pop_transparency_group(&mut self) {} - fn begin_marked_content(&mut self, _: &[u8], _: Option) { + fn begin_marked_content(&mut self, tag: &[u8], mcid: Option) { self.depth = self.depth.saturating_add(1); + if self.excluded_depth.is_none() && self.filter.excludes(tag, mcid) { + self.excluded_depth = Some(self.depth); + } } fn actual_text(&mut self, text: &[u8]) { // A replacement nested in another is already covered by the outer one. - if self.actual.is_none() { + if self.actual.is_none() && self.excluded_depth.is_none() { self.actual = Some((self.depth, pdfcraft_cos::PdfString::literal(text).to_text(), Vec::new())); } } @@ -610,14 +656,21 @@ impl<'a> Device<'a> for TextDevice { if self.actual.as_ref().is_some_and(|(depth, _, _)| *depth == self.depth) { self.end_actual_text(); } + if self.excluded_depth == Some(self.depth) { + self.excluded_depth = None; + } self.depth = self.depth.saturating_sub(1); } } /// Extract the text of one page. Panics inside the interpreter are the caller's to catch. pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSettings) -> Option { + extract_page_filtered(pdf, page, settings, TextFilter::default()).ok() +} + +pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &InterpreterSettings, filter: TextFilter) -> Result { let pages = pdf.pages(); - let p = pages.get(page)?; + let p = pages.get(page).ok_or("the page could not be read")?; let (w, h) = p.render_dimensions(); let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, @@ -626,9 +679,13 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting let initial = p.initial_transform(true).to_kurbo(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); let mut dev = TextDevice::new(); + dev.filter = filter; interpret_page(p, &mut ctx, &mut dev); // A replacement the content never closed ends with the page. dev.finish(); + if dev.partial_replacement { + return Err("a replacement text span mixes included and excluded content; disable the exclusions to count this page".into()); + } let glyphs = dev.glyphs; // Text on a curved path: glyphs that each continue the previous one's line while the // direction turns. Each such run is one line. @@ -743,7 +800,7 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } - Some(text) + Ok(text) } /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1788e7823..5a28ce9b2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -174,6 +174,7 @@ impl PdfCraftApp { "file.close_all" => self.close_all(), "file.revert" => self.dialog = Some(Dialog::Revert), "file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)), + "file.word_count" => self.dialog = Some(Dialog::WordCount), "protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)), // A restricted document's security changes once its permissions password is entered. "protect.password" | "protect.remove" if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.can_unlock()) => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6eceff17e..5e091ee92 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { link_prompt(app, ctx); remote_link_prompt(app, ctx); crate::updates::dialog(app, ctx); + if app.dialog != Some(Dialog::WordCount) { + app.word_count_draft.cancel(); + } let Some(dialog) = app.dialog else { app.props_draft = None; app.view_draft = None; @@ -82,6 +85,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { _ => 520.0, }); match dialog { + Dialog::WordCount => { + close = crate::word_count_ui::body(ui, app); + } Dialog::CreateImages => { let (go, cancel) = crate::create_ui::image_import_body(ui, app); images_now = go; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ca8fe40e1..a0869d3e0 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2378,3 +2378,19 @@ animation effects Off معطّلة Mark as artifact وضع علامة كعنصر زخرفي Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي Mark content as artifact وضع علامة على المحتوى كعنصر زخرفي + Word count عدد الكلمات + Current page only الصفحة الحالية فقط + Exclude tagged tables استبعاد الجداول الموسومة + Exclude tagged code blocks استبعاد كتل التعليمات البرمجية الموسومة + Exclude image/OCR text استبعاد نص الصور والتعرف الضوئي على الحروف + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. تعتمد الاستثناءات على وسوم PDF. تبقى الجداول والتعليمات البرمجية غير الموسومة ضمن العدد. يستبعد خيار الصور النص غير المرئي أيضًا. + Words الكلمات + Characters الأحرف + Characters without spaces الأحرف دون مسافات + Pages counted الصفحات المحسوبة + Some pages contain no countable text. Scanned images need OCR first. لا تحتوي بعض الصفحات على نص قابل للعد. تتطلب الصور الممسوحة ضوئيًا التعرف الضوئي على الحروف أولًا. + Some content could not be classified safely and was kept in the count. تعذر تصنيف بعض المحتوى بشكل موثوق، لذا بقي ضمن العدد. + Counting words… {n} pages read جارٍ عد الكلمات… تمت قراءة {n} من الصفحات + Word counting stopped unexpectedly. توقف عد الكلمات بشكل غير متوقع. + Word count… عدد الكلمات… +@id file.word_count عدد الكلمات… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 706bfe2ca..1d2ea72bd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2320,3 +2320,19 @@ animation effects Off Изключени Mark as artifact Маркиране като артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт Mark content as artifact Маркиране на съдържание като артефакт + Word count Брой думи + Current page only Само текущата страница + Exclude tagged tables Изключване на маркираните таблици + Exclude tagged code blocks Изключване на маркираните блокове с код + Exclude image/OCR text Изключване на текст от изображения/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Изключването използва PDF тагове. Немаркираните таблици и код остават включени. Изключването на изображения премахва и невидимия текст. + Words Думи + Characters Знаци + Characters without spaces Знаци без интервали + Pages counted Преброени страници + Some pages contain no countable text. Scanned images need OCR first. Някои страници не съдържат текст за преброяване. Сканираните изображения първо изискват OCR. + Some content could not be classified safely and was kept in the count. Част от съдържанието не може да бъде класифицирана надеждно и е включена в броя. + Counting words… {n} pages read Преброяване на думи… прочетени страници: {n} + Word counting stopped unexpectedly. Преброяването на думи спря неочаквано. + Word count… Брой думи… +@id file.word_count Брой думи… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 547dea259..4431dfc52 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2431,3 +2431,19 @@ animation effects Off Vypnuto Mark as artifact Označit jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odeberte tuto značku listu z pořadí čtení a označte její přímý obsah jako artefakt Mark content as artifact Označit obsah jako artefakt + Word count Počet slov + Current page only Pouze aktuální stránka + Exclude tagged tables Vynechat označené tabulky + Exclude tagged code blocks Vynechat označené bloky kódu + Exclude image/OCR text Vynechat text obrázků a OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Vynechávání využívá tagy PDF. Neoznačené tabulky a kód zůstávají zahrnuty. Vynechání obrázků odstraní i neviditelný text. + Words Slova + Characters Znaky + Characters without spaces Znaky bez mezer + Pages counted Započítané stránky + Some pages contain no countable text. Scanned images need OCR first. Některé stránky neobsahují text k počítání. U naskenovaných obrázků je nejprve nutné OCR. + Some content could not be classified safely and was kept in the count. Část obsahu nebylo možné spolehlivě zařadit a zůstala zahrnuta v počtu. + Counting words… {n} pages read Počítání slov… přečtené stránky: {n} + Word counting stopped unexpectedly. Počítání slov se neočekávaně zastavilo. + Word count… Počet slov… +@id file.word_count Počet slov… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 289a6ee93..9ea54ff50 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2358,3 +2358,19 @@ animation effects Off Aus Mark as artifact Als Artefakt markieren Remove this leaf tag from reading order and mark its direct content as an artifact Dieses Blatt-Tag aus der Lesereihenfolge entfernen und seinen direkten Inhalt als Artefakt markieren Mark content as artifact Inhalt als Artefakt markieren +@id file.word_count Wörter zählen… + Word count Wörter zählen + Word count… Wörter zählen… + Current page only Nur aktuelle Seite + Exclude tagged tables Als Tabellen gekennzeichnete Inhalte ausschließen + Exclude tagged code blocks Als Codeblöcke gekennzeichnete Inhalte ausschließen + Exclude image/OCR text Bildtext und OCR-Text ausschließen + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Ausschlüsse verwenden PDF-Tags. Tabellen und Code ohne Tags werden weiterhin gezählt. Beim Ausschluss von Bildtext wird auch unsichtbarer Text ausgeschlossen. + Words Wörter + Characters Zeichen + Characters without spaces Zeichen ohne Leerzeichen + Pages counted Gezählte Seiten + Some pages contain no countable text. Scanned images need OCR first. Einige Seiten enthalten keinen zählbaren Text. Führen Sie für gescannte Bilder zuerst eine Texterkennung durch. + Some content could not be classified safely and was kept in the count. Einige Inhalte konnten nicht sicher zugeordnet werden und wurden mitgezählt. + Counting words… {n} pages read Wörter werden gezählt… {n} Seiten gelesen + Word counting stopped unexpectedly. Die Wortzählung wurde unerwartet beendet. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 341637c85..227a06698 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2367,3 +2367,19 @@ animation effects Off Ανενεργά Mark as artifact Σήμανση ως τεχνούργημα Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα Mark content as artifact Σήμανση περιεχομένου ως τεχνούργημα + Word count Καταμέτρηση λέξεων + Current page only Μόνο η τρέχουσα σελίδα + Exclude tagged tables Εξαίρεση πινάκων με ετικέτες + Exclude tagged code blocks Εξαίρεση μπλοκ κώδικα με ετικέτες + Exclude image/OCR text Εξαίρεση κειμένου εικόνων/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Οι εξαιρέσεις χρησιμοποιούν ετικέτες PDF. Οι πίνακες και ο κώδικας χωρίς ετικέτες παραμένουν στην καταμέτρηση. Η εξαίρεση εικόνων αφαιρεί και το αόρατο κείμενο. + Words Λέξεις + Characters Χαρακτήρες + Characters without spaces Χαρακτήρες χωρίς κενά + Pages counted Σελίδες που καταμετρήθηκαν + Some pages contain no countable text. Scanned images need OCR first. Ορισμένες σελίδες δεν περιέχουν κείμενο για καταμέτρηση. Οι σαρωμένες εικόνες χρειάζονται πρώτα OCR. + Some content could not be classified safely and was kept in the count. Ορισμένο περιεχόμενο δεν ήταν δυνατό να ταξινομηθεί αξιόπιστα και παρέμεινε στην καταμέτρηση. + Counting words… {n} pages read Καταμέτρηση λέξεων… αναγνώστηκαν {n} σελίδες + Word counting stopped unexpectedly. Η καταμέτρηση λέξεων σταμάτησε απροσδόκητα. + Word count… Καταμέτρηση λέξεων… +@id file.word_count Καταμέτρηση λέξεων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b904e8034..01dc5853c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2487,3 +2487,19 @@ animation effects Off Desactivados Mark as artifact Marcar como artefacto Remove this leaf tag from reading order and mark its direct content as an artifact Quite esta etiqueta hoja del orden de lectura y marque su contenido directo como artefacto Mark content as artifact Marcar contenido como artefacto + Word count Recuento de palabras + Current page only Solo la página actual + Exclude tagged tables Excluir tablas etiquetadas + Exclude tagged code blocks Excluir bloques de código etiquetados + Exclude image/OCR text Excluir texto de imágenes/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Las exclusiones usan etiquetas PDF. Las tablas y el código sin etiquetas siguen incluidos. Excluir imágenes también elimina el texto invisible. + Words Palabras + Characters Caracteres + Characters without spaces Caracteres sin espacios + Pages counted Páginas contadas + Some pages contain no countable text. Scanned images need OCR first. Algunas páginas no contienen texto que se pueda contar. Las imágenes escaneadas requieren OCR primero. + Some content could not be classified safely and was kept in the count. Parte del contenido no se pudo clasificar de forma fiable y se mantuvo en el recuento. + Counting words… {n} pages read Contando palabras… {n} páginas leídas + Word counting stopped unexpectedly. El recuento de palabras se detuvo inesperadamente. + Word count… Recuento de palabras… +@id file.word_count Recuento de palabras… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6d1140e4a..7ae7d14d1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2357,3 +2357,19 @@ animation effects Off Désactivés Mark as artifact Marquer comme artefact Remove this leaf tag from reading order and mark its direct content as an artifact Retirez cette balise feuille de l’ordre de lecture et marquez son contenu direct comme artefact Mark content as artifact Marquer le contenu comme artefact + Word count Nombre de mots + Current page only Page actuelle uniquement + Exclude tagged tables Exclure les tableaux balisés + Exclude tagged code blocks Exclure les blocs de code balisés + Exclude image/OCR text Exclure le texte des images/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Les exclusions utilisent les balises PDF. Les tableaux et le code non balisés restent inclus. L’exclusion des images retire aussi le texte invisible. + Words Mots + Characters Caractères + Characters without spaces Caractères sans espaces + Pages counted Pages comptées + Some pages contain no countable text. Scanned images need OCR first. Certaines pages ne contiennent aucun texte à compter. Les images numérisées nécessitent d’abord une reconnaissance OCR. + Some content could not be classified safely and was kept in the count. Certains contenus n’ont pas pu être classés de manière fiable et restent inclus dans le décompte. + Counting words… {n} pages read Comptage des mots… {n} pages lues + Word counting stopped unexpectedly. Le comptage des mots s’est arrêté de manière inattendue. + Word count… Nombre de mots… +@id file.word_count Nombre de mots… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38744f2e0..254214f4d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2356,3 +2356,19 @@ animation effects Off Ki Mark as artifact Megjelölés műtárgyként Remove this leaf tag from reading order and mark its direct content as an artifact Távolítsa el ezt a levélcímkét az olvasási sorrendből, és jelölje meg a közvetlen tartalmát műtárgyként Mark content as artifact Tartalom megjelölése műtárgyként + Word count Szavak száma + Current page only Csak az aktuális oldal + Exclude tagged tables Címkézett táblázatok kizárása + Exclude tagged code blocks Címkézett kódblokkok kizárása + Exclude image/OCR text Képek és OCR szövegének kizárása + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. A kizárások PDF-címkéket használnak. A címkézetlen táblázatok és kódok továbbra is beleszámítanak. A képek kizárása a láthatatlan szöveget is eltávolítja. + Words Szavak + Characters Karakterek + Characters without spaces Karakterek szóközök nélkül + Pages counted Megszámolt oldalak + Some pages contain no countable text. Scanned images need OCR first. Egyes oldalakon nincs megszámolható szöveg. A beolvasott képeken először OCR-t kell futtatni. + Some content could not be classified safely and was kept in the count. Egyes tartalmakat nem lehetett megbízhatóan besorolni, ezért beleszámítanak az eredménybe. + Counting words… {n} pages read Szavak számlálása… {n} oldal beolvasva + Word counting stopped unexpectedly. A szavak számlálása váratlanul leállt. + Word count… Szavak száma… +@id file.word_count Szavak száma… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 01beadf96..47b32de76 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2366,3 +2366,19 @@ animation effects Off Disattivati Mark as artifact Segna come artefatto Remove this leaf tag from reading order and mark its direct content as an artifact Rimuovi questo tag foglia dall'ordine di lettura e segna il suo contenuto diretto come artefatto Mark content as artifact Segna il contenuto come artefatto + Word count Conteggio parole + Current page only Solo la pagina corrente + Exclude tagged tables Escludi le tabelle con tag + Exclude tagged code blocks Escludi i blocchi di codice con tag + Exclude image/OCR text Escludi il testo di immagini/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Le esclusioni usano i tag PDF. Le tabelle e il codice senza tag restano inclusi. L’esclusione delle immagini rimuove anche il testo invisibile. + Words Parole + Characters Caratteri + Characters without spaces Caratteri senza spazi + Pages counted Pagine conteggiate + Some pages contain no countable text. Scanned images need OCR first. Alcune pagine non contengono testo da contare. Le immagini acquisite richiedono prima il riconoscimento OCR. + Some content could not be classified safely and was kept in the count. Alcuni contenuti non sono stati classificati in modo affidabile e sono rimasti nel conteggio. + Counting words… {n} pages read Conteggio parole… {n} pagine lette + Word counting stopped unexpectedly. Il conteggio delle parole si è interrotto in modo imprevisto. + Word count… Conteggio parole… +@id file.word_count Conteggio parole… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index feb2bc821..91782e93c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2357,3 +2357,19 @@ animation effects Off オフ Mark as artifact アーティファクトとしてマーク Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします Mark content as artifact コンテンツをアーティファクトとしてマーク + Word count 文字数と単語数 + Current page only 現在のページのみ + Exclude tagged tables タグ付きの表を除外 + Exclude tagged code blocks タグ付きのコードブロックを除外 + Exclude image/OCR text 画像・OCRのテキストを除外 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 除外にはPDFタグを使用します。タグのない表やコードは集計に含まれます。画像の除外では非表示のテキストも除外されます。 + Words 単語数 + Characters 文字数 + Characters without spaces 空白を除く文字数 + Pages counted 集計したページ数 + Some pages contain no countable text. Scanned images need OCR first. 一部のページには集計できるテキストがありません。スキャン画像には先にOCRを実行してください。 + Some content could not be classified safely and was kept in the count. 正確に分類できなかった内容は集計に含まれています。 + Counting words… {n} pages read 単語を集計中… {n}ページ読み取り済み + Word counting stopped unexpectedly. 単語の集計が予期せず停止しました。 + Word count… 文字数と単語数… +@id file.word_count 文字数と単語数… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 09187a460..ec9d2e5c4 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2355,3 +2355,19 @@ animation effects Off Өшірулі Mark as artifact Артефакт ретінде белгілеу Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз Mark content as artifact Мазмұнды артефакт ретінде белгілеу + Word count Сөздер саны + Current page only Тек ағымдағы бет + Exclude tagged tables Белгіленген кестелерді есепке алмау + Exclude tagged code blocks Белгіленген код блоктарын есепке алмау + Exclude image/OCR text Сурет/OCR мәтінін есепке алмау + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Есептен шығару PDF тегтерін пайдаланады. Тегсіз кестелер мен код есепте қалады. Суреттерді алып тастау көрінбейтін мәтінді де алып тастайды. + Words Сөздер + Characters Таңбалар + Characters without spaces Бос орынсыз таңбалар + Pages counted Есептелген беттер + Some pages contain no countable text. Scanned images need OCR first. Кейбір беттерде санауға болатын мәтін жоқ. Сканерленген суреттерге алдымен OCR қажет. + Some content could not be classified safely and was kept in the count. Кейбір мазмұнды сенімді түрде жіктеу мүмкін болмады, сондықтан ол есепте қалды. + Counting words… {n} pages read Сөздер саналуда… {n} бет оқылды + Word counting stopped unexpectedly. Сөздерді санау күтпеген жерден тоқтады. + Word count… Сөздер саны… +@id file.word_count Сөздер саны… diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2f723a452..08e3b3d42 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2601,3 +2601,19 @@ key Space Tarpas Mark as artifact Pažymėti kaip artefaktą Remove this leaf tag from reading order and mark its direct content as an artifact Pašalinkite šią lapo žymą iš skaitymo tvarkos ir pažymėkite jos tiesioginį turinį kaip artefaktą Mark content as artifact Pažymėti turinį kaip artefaktą + Word count Žodžių skaičius + Current page only Tik dabartinis puslapis + Exclude tagged tables Neįtraukti pažymėtų lentelių + Exclude tagged code blocks Neįtraukti pažymėtų kodo blokų + Exclude image/OCR text Neįtraukti vaizdų ir OCR teksto + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Išimtys naudoja PDF žymas. Nepažymėtos lentelės ir kodas lieka įtraukti. Vaizdų išimtis pašalina ir nematomą tekstą. + Words Žodžiai + Characters Simboliai + Characters without spaces Simboliai be tarpų + Pages counted Suskaičiuoti puslapiai + Some pages contain no countable text. Scanned images need OCR first. Kai kuriuose puslapiuose nėra skaičiuojamo teksto. Nuskaitytiems vaizdams pirmiausia reikia OCR. + Some content could not be classified safely and was kept in the count. Dalies turinio nepavyko patikimai klasifikuoti, todėl jis liko įtrauktas į skaičių. + Counting words… {n} pages read Skaičiuojami žodžiai… perskaityta puslapių: {n} + Word counting stopped unexpectedly. Žodžių skaičiavimas netikėtai sustojo. + Word count… Žodžių skaičius… +@id file.word_count Žodžių skaičius… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7dd7c0dba..4d777c46e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2337,3 +2337,19 @@ animation effects Off Wyłączone Mark as artifact Oznacz jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Usuń ten znacznik liścia z kolejności odczytu i oznacz jego bezpośrednią zawartość jako artefakt Mark content as artifact Oznacz zawartość jako artefakt + Word count Liczba słów + Current page only Tylko bieżąca strona + Exclude tagged tables Wyklucz oznaczone tabele + Exclude tagged code blocks Wyklucz oznaczone bloki kodu + Exclude image/OCR text Wyklucz tekst obrazów/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Wykluczenia używają znaczników PDF. Nieoznaczone tabele i kod nadal są uwzględniane. Wykluczenie obrazów usuwa również niewidoczny tekst. + Words Słowa + Characters Znaki + Characters without spaces Znaki bez spacji + Pages counted Policzone strony + Some pages contain no countable text. Scanned images need OCR first. Niektóre strony nie zawierają tekstu do policzenia. Zeskanowane obrazy wymagają najpierw OCR. + Some content could not be classified safely and was kept in the count. Części treści nie udało się wiarygodnie sklasyfikować, więc pozostała uwzględniona w liczbie. + Counting words… {n} pages read Liczenie słów… odczytane strony: {n} + Word counting stopped unexpectedly. Liczenie słów zostało nieoczekiwanie przerwane. + Word count… Liczba słów… +@id file.word_count Liczba słów… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 451345162..61f6d67b1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2371,3 +2371,19 @@ animation effects Off Desativados Mark as artifact Marcar como artefato Remove this leaf tag from reading order and mark its direct content as an artifact Remova esta tag folha da ordem de leitura e marque seu conteúdo direto como artefato Mark content as artifact Marcar conteúdo como artefato + Word count Contagem de palavras + Current page only Somente a página atual + Exclude tagged tables Excluir tabelas com tags + Exclude tagged code blocks Excluir blocos de código com tags + Exclude image/OCR text Excluir texto de imagens/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. As exclusões usam tags de PDF. Tabelas e código sem tags continuam incluídos. A exclusão de imagens também remove o texto invisível. + Words Palavras + Characters Caracteres + Characters without spaces Caracteres sem espaços + Pages counted Páginas contadas + Some pages contain no countable text. Scanned images need OCR first. Algumas páginas não contêm texto para contar. As imagens digitalizadas precisam primeiro de OCR. + Some content could not be classified safely and was kept in the count. Parte do conteúdo não pôde ser classificada de forma confiável e foi mantida na contagem. + Counting words… {n} pages read Contando palavras… {n} páginas lidas + Word counting stopped unexpectedly. A contagem de palavras parou inesperadamente. + Word count… Contagem de palavras… +@id file.word_count Contagem de palavras… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 890e72e9a..10f02a553 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2270,3 +2270,19 @@ animation effects Off Выкл Mark as artifact Пометить как артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт Mark content as artifact Пометить содержимое как артефакт + Word count Количество слов + Current page only Только текущая страница + Exclude tagged tables Исключить таблицы с тегами + Exclude tagged code blocks Исключить блоки кода с тегами + Exclude image/OCR text Исключить текст изображений и OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Исключения используют теги PDF. Таблицы и код без тегов остаются в подсчёте. Исключение изображений также удаляет невидимый текст. + Words Слова + Characters Символы + Characters without spaces Символы без пробелов + Pages counted Подсчитанные страницы + Some pages contain no countable text. Scanned images need OCR first. На некоторых страницах нет текста для подсчёта. Для отсканированных изображений сначала требуется OCR. + Some content could not be classified safely and was kept in the count. Часть содержимого не удалось надёжно классифицировать, поэтому она осталась в подсчёте. + Counting words… {n} pages read Подсчёт слов… прочитано страниц: {n} + Word counting stopped unexpectedly. Подсчёт слов неожиданно остановился. + Word count… Количество слов… +@id file.word_count Количество слов… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index adafdca00..9de0d84a4 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2362,3 +2362,19 @@ animation effects Off Izklopljeni Mark as artifact Označi kot artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odstranite to listno oznako iz vrstnega reda branja in označite njeno neposredno vsebino kot artefakt Mark content as artifact Označi vsebino kot artefakt + Word count Število besed + Current page only Samo trenutna stran + Exclude tagged tables Izključi označene tabele + Exclude tagged code blocks Izključi označene bloke kode + Exclude image/OCR text Izključi besedilo slik/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Izključitve uporabljajo oznake PDF. Neoznačene tabele in koda ostanejo vključene. Izključitev slik odstrani tudi nevidno besedilo. + Words Besede + Characters Znaki + Characters without spaces Znaki brez presledkov + Pages counted Preštete strani + Some pages contain no countable text. Scanned images need OCR first. Nekatere strani ne vsebujejo besedila za štetje. Optično prebrane slike najprej potrebujejo OCR. + Some content could not be classified safely and was kept in the count. Nekatere vsebine ni bilo mogoče zanesljivo razvrstiti, zato je ostala vključena v štetje. + Counting words… {n} pages read Štetje besed… prebrane strani: {n} + Word counting stopped unexpectedly. Štetje besed se je nepričakovano ustavilo. + Word count… Število besed… +@id file.word_count Število besed… diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 6a56688ac..4f9101909 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2270,3 +2270,19 @@ animation effects Off ఆఫ్ Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి Mark content as artifact కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించు + Word count పదాల లెక్క + Current page only ప్రస్తుత పేజీ మాత్రమే + Exclude tagged tables ట్యాగ్ చేసిన పట్టికలను మినహాయించు + Exclude tagged code blocks ట్యాగ్ చేసిన కోడ్ బ్లాక్‌లను మినహాయించు + Exclude image/OCR text చిత్రాలు/OCR వచనాన్ని మినహాయించు + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. మినహాయింపులు PDF ట్యాగ్‌లను ఉపయోగిస్తాయి. ట్యాగ్‌లు లేని పట్టికలు, కోడ్ లెక్కలో ఉంటాయి. చిత్రాలను మినహాయిస్తే కనిపించని వచనం కూడా తొలగించబడుతుంది. + Words పదాలు + Characters అక్షరాలు + Characters without spaces ఖాళీలు లేని అక్షరాలు + Pages counted లెక్కించిన పేజీలు + Some pages contain no countable text. Scanned images need OCR first. కొన్ని పేజీల్లో లెక్కించగల వచనం లేదు. స్కాన్ చేసిన చిత్రాలకు ముందుగా OCR అవసరం. + Some content could not be classified safely and was kept in the count. కొంత కంటెంట్‌ను విశ్వసనీయంగా వర్గీకరించలేకపోయాం, కాబట్టి దాన్ని లెక్కలో ఉంచాం. + Counting words… {n} pages read పదాలను లెక్కిస్తోంది… {n} పేజీలు చదివింది + Word counting stopped unexpectedly. పదాల లెక్క అనుకోకుండా ఆగిపోయింది. + Word count… పదాల లెక్క… +@id file.word_count పదాల లెక్క… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index af37483b4..6283ad581 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2353,3 +2353,19 @@ animation effects Off Kapalı Mark as artifact Yapıt olarak işaretle Remove this leaf tag from reading order and mark its direct content as an artifact Bu yaprak etiketini okuma sırasından kaldırın ve doğrudan içeriğini yapıt olarak işaretleyin Mark content as artifact İçeriği yapıt olarak işaretle + Word count Sözcük sayısı + Current page only Yalnızca geçerli sayfa + Exclude tagged tables Etiketli tabloları hariç tut + Exclude tagged code blocks Etiketli kod bloklarını hariç tut + Exclude image/OCR text Görüntü/OCR metnini hariç tut + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Hariç tutma işlemleri PDF etiketlerini kullanır. Etiketsiz tablolar ve kod sayılmaya devam eder. Görüntüleri hariç tutmak görünmez metni de kaldırır. + Words Sözcükler + Characters Karakterler + Characters without spaces Boşluksuz karakterler + Pages counted Sayılan sayfalar + Some pages contain no countable text. Scanned images need OCR first. Bazı sayfalarda sayılabilecek metin yok. Taranmış görüntüler için önce OCR gerekir. + Some content could not be classified safely and was kept in the count. Bazı içerikler güvenilir biçimde sınıflandırılamadığı için sayıma dahil edildi. + Counting words… {n} pages read Sözcükler sayılıyor… {n} sayfa okundu + Word counting stopped unexpectedly. Sözcük sayımı beklenmedik şekilde durdu. + Word count… Sözcük sayısı… +@id file.word_count Sözcük sayısı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c0c4e83d1..86944c469 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2379,3 +2379,19 @@ animation effects Off Вимкнено Mark as artifact Позначити як артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт Mark content as artifact Позначити вміст як артефакт + Word count Кількість слів + Current page only Лише поточна сторінка + Exclude tagged tables Виключити таблиці з тегами + Exclude tagged code blocks Виключити блоки коду з тегами + Exclude image/OCR text Виключити текст зображень та OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Виключення використовують теги PDF. Таблиці й код без тегів залишаються в підрахунку. Виключення зображень також видаляє невидимий текст. + Words Слова + Characters Символи + Characters without spaces Символи без пробілів + Pages counted Пораховані сторінки + Some pages contain no countable text. Scanned images need OCR first. Деякі сторінки не містять тексту для підрахунку. Для відсканованих зображень спочатку потрібне OCR. + Some content could not be classified safely and was kept in the count. Частину вмісту не вдалося надійно класифікувати, тому вона залишилася в підрахунку. + Counting words… {n} pages read Підрахунок слів… прочитано сторінок: {n} + Word counting stopped unexpectedly. Підрахунок слів несподівано зупинився. + Word count… Кількість слів… +@id file.word_count Кількість слів… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f914db633..9cd784dca 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2321,3 +2321,19 @@ animation effects Off 关 Mark as artifact 标记为工件 Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件 Mark content as artifact 将内容标记为工件 + Word count 字词统计 + Current page only 仅当前页 + Exclude tagged tables 排除带标签的表格 + Exclude tagged code blocks 排除带标签的代码块 + Exclude image/OCR text 排除图像/OCR文本 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 排除规则使用PDF标签。无标签的表格和代码仍计入统计。排除图像时也会排除不可见文本。 + Words 词数 + Characters 字符数 + Characters without spaces 不含空白的字符数 + Pages counted 已统计页数 + Some pages contain no countable text. Scanned images need OCR first. 部分页面没有可统计的文本。扫描图像需要先进行OCR识别。 + Some content could not be classified safely and was kept in the count. 部分内容无法可靠分类,已保留在统计中。 + Counting words… {n} pages read 正在统计字词… 已读取{n}页 + Word counting stopped unexpectedly. 字词统计意外停止。 + Word count… 字词统计… +@id file.word_count 字词统计… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6f6d0ca2f..3a3f5ad6d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2311,3 +2311,19 @@ animation effects Off 關 Drawing scale changes. Use Actual size for full-scale plans. 圖面比例會改變。如需原尺寸的圖面,請使用「實際大小」。 Done adding text 完成新增文字 Discard this text (Esc) 捨棄此文字(Esc) + Word count 字詞統計 + Current page only 僅目前頁面 + Exclude tagged tables 排除帶標籤的表格 + Exclude tagged code blocks 排除帶標籤的程式碼區塊 + Exclude image/OCR text 排除影像/OCR文字 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 排除規則使用PDF標籤。沒有標籤的表格和程式碼仍計入統計。排除影像時也會排除不可見文字。 + Words 詞數 + Characters 字元數 + Characters without spaces 不含空白的字元數 + Pages counted 已統計頁數 + Some pages contain no countable text. Scanned images need OCR first. 部分頁面沒有可統計的文字。掃描影像需要先進行OCR辨識。 + Some content could not be classified safely and was kept in the count. 部分內容無法可靠分類,已保留在統計中。 + Counting words… {n} pages read 正在統計字詞… 已讀取{n}頁 + Word counting stopped unexpectedly. 字詞統計意外停止。 + Word count… 字詞統計… +@id file.word_count 字詞統計… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b01a1e9bf..aef83403d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,7 @@ mod print_ui; mod signature_drag; pub mod units; mod web_print; +mod word_count_ui; pub use print_ui::{Handling as PrintHandling, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -262,6 +263,7 @@ struct GridDrop { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { + WordCount, CreateImages, Properties(PropsTab), About, @@ -740,6 +742,7 @@ pub struct PdfCraftApp { pub bookmark_rename: Option<(Vec, String)>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, + pub(crate) word_count_draft: word_count_ui::Draft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// Set Page Boxes dialog state. @@ -1042,6 +1045,7 @@ impl PdfCraftApp { text_style: content_ui::default_style(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, + word_count_draft: word_count_ui::Draft::default(), } } @@ -2418,6 +2422,7 @@ impl PdfCraftApp { "watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)), "background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), + "word-count" => Some(Dialog::WordCount), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "accessibility-options" => Some(Dialog::AccessibilityOptions), diff --git a/crates/ui-egui/src/word_count_ui.rs b/crates/ui-egui/src/word_count_ui.rs new file mode 100644 index 000000000..1aa999c49 --- /dev/null +++ b/crates/ui-egui/src/word_count_ui.rs @@ -0,0 +1,225 @@ +//! Read-only statistics for a document snapshot. At most one worker per dialog, with cancellation +//! between pages and no stale results after edits, page changes or changed exclusions. + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, +}; + +use pdfcraft_engine::{ + DocId, + export::ExportSource, + word_count::{self, Options, Report}, +}; + +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Key { + doc: DocId, + generation: u64, + page: Option, + options: Options, +} + +struct Job { + key: Key, + receiver: mpsc::Receiver>, + cancel: Arc, + done: Arc, +} + +impl Drop for Job { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub(crate) struct Draft { + options: Options, + current_page: bool, + key: Option, + job: Option, + result: Option>, +} + +impl Draft { + pub(crate) fn cancel(&mut self) { + if let Some(job) = &self.job { + job.cancel.store(true, Ordering::Relaxed); + } + self.key = None; + self.result = None; + } + + fn update(&mut self, key: Key, source: impl FnOnce() -> ExportSource, ctx: &egui::Context, inline: bool) { + if self.key != Some(key) { + self.cancel(); + self.key = Some(key); + } + if let Some(job) = &self.job { + let received = match job.receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err(tl!("Word counting stopped unexpectedly.").into())), + }; + if let Some(result) = received { + if job.key == key && !job.cancel.load(Ordering::Relaxed) { + self.result = Some(result); + } + self.job = None; + } + } + if self.job.is_some() || self.result.is_some() { + return; + } + let source = source(); + let pages: Vec<_> = key.page.map_or_else(|| (0..source.pages).collect(), |page| vec![page]); + let (sender, receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicUsize::new(0)); + self.job = Some(Job { key, receiver, cancel: cancel.clone(), done: done.clone() }); + let ctx = ctx.clone(); + let work = move || { + let result = word_count::count(source, &pages, key.options, |n| { + done.store(n, Ordering::Relaxed); + ctx.request_repaint(); + !cancel.load(Ordering::Relaxed) + }); + let _ = sender.send(result); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if !inline { + if let Err(error) = std::thread::Builder::new().name("word-count".into()).spawn(work) { + self.job = None; + self.result = Some(Err(error.to_string())); + } + return; + } + let _ = inline; + work(); + } +} + +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool { + ui.heading(tl!("Word count")); + let Some((view, id)) = app.active_ids() else { return true }; + let Some(doc) = app.session.get(id) else { return true }; + if let Err(error) = doc.check_copying() { + app.word_count_draft.cancel(); + ui.label(error.to_string()); + return false; + } + let draft = &mut app.word_count_draft; + ui.add(widgets::checkbox(&mut draft.current_page, tl!("Current page only"))); + ui.separator(); + ui.add(widgets::checkbox(&mut draft.options.exclude_tables, tl!("Exclude tagged tables"))); + ui.add(widgets::checkbox(&mut draft.options.exclude_code, tl!("Exclude tagged code blocks"))); + ui.add(widgets::checkbox(&mut draft.options.exclude_images, tl!("Exclude image/OCR text"))); + ui.label(tl!("Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text.")); + let key = Key { doc: id, generation: doc.edit_generation(), page: draft.current_page.then_some(app.views[view].current), options: draft.options }; + draft.update(key, || doc.export_source(), ui.ctx(), app.run_inline); + ui.separator(); + match &draft.result { + Some(Ok(report)) => { + egui::Grid::new("word-count-totals").show(ui, |ui| { + for (label, value) in [ + (tl!("Words"), report.total.words), + (tl!("Characters"), report.total.characters), + (tl!("Characters without spaces"), report.total.characters_without_spaces), + (tl!("Pages counted"), report.pages.len()), + ] { + ui.label(label); + ui.strong(value.to_string()); + ui.end_row(); + } + }); + if report.pages_without_text > 0 { + ui.label(tl!("Some pages contain no countable text. Scanned images need OCR first.")); + } + if report.unclassified_structure { + ui.label(tl!("Some content could not be classified safely and was kept in the count.")); + } + } + Some(Err(error)) => { + ui.colored_label(ui.visuals().error_fg_color, error); + } + None => { + ui.spinner(); + let done = draft.job.as_ref().map_or(0, |job| job.done.load(Ordering::Relaxed)); + ui.label(crate::i18n::fmt(tl!("Counting words… {n} pages read"), &[("n", &done.to_string())])); + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn word_count_dialog_counts_changes_pages_and_closes() { + for inline in [true, false] { + let bytes = pdfcraft_engine::Session::new().create_from_text("Words", "Hello world\u{c}Another three words").unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.run_inline = inline; + app.set_option("language", "en").unwrap(); + app.open_bytes("words.pdf", None, bytes.as_ref().clone()).unwrap(); + app.run_command("file.word_count"); + app + }); + let wait = |h: &mut Harness<'_, PdfCraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().word_count_draft.result.is_none() { + assert!(std::time::Instant::now() < deadline, "word-count worker timed out"); + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + h.run_steps(2); + }; + wait(&mut h); + assert_eq!(h.state().dialog, Some(Dialog::WordCount)); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 5); + h.get_by_label("Current page only").click(); + h.run_steps(1); + wait(&mut h); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 2); + h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0] }); + h.run_steps(1); + wait(&mut h); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 3); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().word_count_draft.result.is_none()); + } + } + + #[test] + fn word_count_cancelled_worker_cannot_publish_an_old_result() { + let key = Key { doc: DocId(1), generation: 0, page: None, options: Options::default() }; + let (tx, rx) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let mut draft = Draft { + key: Some(key), + job: Some(Job { key, receiver: rx, cancel: cancel.clone(), done: Arc::new(AtomicUsize::new(0)) }), + ..Draft::default() + }; + draft.cancel(); + assert!(cancel.load(Ordering::Relaxed)); + let bytes = pdfcraft_engine::Session::new().create_blank(200.0, 300.0, 1).unwrap(); + let source = || ExportSource { bytes: bytes.clone(), pages: 1, sizes: vec![(200.0, 300.0)], config: Default::default() }; + draft.update(key, source, &egui::Context::default(), true); + assert!(draft.result.is_none()); // still one worker, no competing replacement + tx.send(Ok(Report { total: word_count::Counts { words: 999, ..Default::default() }, ..Default::default() })).unwrap(); + draft.update(key, source, &egui::Context::default(), true); + draft.update(key, source, &egui::Context::default(), true); + assert_eq!(draft.result.unwrap().unwrap().total.words, 0); + } +} diff --git a/docs/word-count.md b/docs/word-count.md new file mode 100644 index 000000000..4b3d02cba --- /dev/null +++ b/docs/word-count.md @@ -0,0 +1,52 @@ +# Word count + +File ▸ Word count counts the current document, including unsaved edits. Select **Current page +only** to count just the displayed page. On desktop, counting runs in the background, one page +at a time. Closing the dialog or changing its options cancels the old count between pages. + +Words use Unicode word boundaries (UAX #29); punctuation alone is not a word. Characters are +Unicode scalar values, not bytes or displayed glyphs. “Characters without spaces” removes all +Unicode whitespace. Characters include the line breaks and spaces reconstructed by text +extraction. The total is the sum of page counts, with no extra separator between pages. + +The three independent exclusions are off by default: + +- **Tagged tables:** Table, TR, TH, TD, THead, TBody and TFoot content. +- **Tagged code blocks:** Code content. +- **Image/OCR text:** Figure content and invisible text, including typical OCR layers. Invisible + text can have other uses too. Visible OCR text without Figure tags is not identifiable as OCR. + +Tags can be explicit marked-content tags or page-local MCIDs classified through the PDF's +structure tree, including parent roles and RoleMap aliases. These are semantic exclusions, not +visual table/code recognition. Untagged or unsupported structure stays in the count; the dialog +reports when structure classification is incomplete. On pages with Form XObjects, structure-tree +MCID exclusions are conservatively disabled because forms have separate MCID namespaces. +Explicit Table/Code/Figure marked content still works there. Cyclic and oversized structure +graphs are bounded and reported as incomplete. + +PDF `/ActualText` replacements are respected. If one replacement spans both included and +excluded glyphs, there is no reliable mapping of its words to the selected content: counting +fails with an explanation instead of silently returning the wrong total. Disable exclusions to +count that page. Unreadable pages likewise fail the operation, without a misleading partial total. + +Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages. +Copy restrictions are respected. Counting never edits, rasterizes or saves the document, and +keeps only one page's extracted text at a time, plus the resulting page statistics. + +## CLI and MCP + +`text_word_count` is the shared read-only automation tool: + +```json +{"tool":"text_word_count","args":{"doc":1,"pages":[1,3],"exclude_tables":true,"exclude_code":true,"exclude_images":false}} +``` + +Omit `pages` to count all pages. Page numbers are 1-based; duplicates count once. The result +contains `total`, per-page `pages` entries with `page` and `counts`, `pages_without_text`, and +`unclassified_structure`. Each count has `words`, `characters`, and `characters_without_spaces`. +`pages_without_text` is measured after exclusions, so it can also include pages whose entire +text was deliberately excluded. + +The engine entry point is `pdfcraft_engine::word_count::count`. Its page indexes are 0-based; +its progress callback can cancel between pages. Frontends must enforce document copy +permissions before supplying an `ExportSource`. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a7c5f8300..c9f302cf1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -13,6 +13,18 @@ areas = { A = "core", B = "view", C = "edit", D = "organize", E = "comment", F = # A. Core document model and fidelity # ================================================================================================== +[[feature]] +id = "core.word-count" +title = "Word and character count" +area = "A" +tier = "P2" +milestone = "M2" +status = "shipped" +commands = ["file.word_count"] +tools = ["text_word_count"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." + [[feature]] id = "core.open-pdf" title = "Open PDF files"