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 <echelon@gmail.com>
This commit is contained in:
Sir Clangington III.
2026-10-15 02:24:33 -04:00
committed by GitHub
co-authored by LabDennis Brandon Thomas
parent 5098728078
commit c5c5d0d661
40 changed files with 1130 additions and 28 deletions
+21 -21
View File
@@ -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"
Generated
+1
View File
@@ -4067,6 +4067,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
"unicode-segmentation",
"url",
]
+4
View File
@@ -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
+23
View File
@@ -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<Value> {
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::<Vec<_>>(),
"pages_without_text": report.pages_without_text,
"unclassified_structure": report.unclassified_structure,
}))
}
fn text_extract(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc_to_copy(a)?;
let id = doc.id;
+6
View File
@@ -171,6 +171,12 @@ pub fn tools() -> Vec<ToolDef> {
"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")
+24
View File
@@ -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" })),
+1
View File
@@ -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").
+3
View File
@@ -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
+1
View File
@@ -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"),
+1
View File
@@ -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};
+336
View File
@@ -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<Report, String> {
crate::guard(|| count_inner(source, pages, options, progress))?
}
fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut progress: impl FnMut(usize) -> bool) -> Result<Report, String> {
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<u8> {
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<usize, HashSet<i32>>;
/// 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::<ObjRef>, 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<ObjRef>| {
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::<Vec<_>>().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<_>>(), 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<_>>(), 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);
}
}
+8
View File
@@ -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<crate::PageText, String> {
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();
+64 -7
View File
@@ -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<i32>,
}
impl TextFilter {
fn excludes(&self, tag: &[u8], mcid: Option<i32>) -> 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<Drawn>)>,
/// Every `/ActualText` replacement made on the page.
replaced: Vec<Replaced>,
filter: TextFilter,
excluded_depth: Option<usize>,
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<Drawn> {
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<SoftMask<'a>>, _: 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<i32>) {
fn begin_marked_content(&mut self, tag: &[u8], mcid: Option<i32>) {
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<PageText> {
extract_page_filtered(pdf, page, settings, TextFilter::default()).ok()
}
pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &InterpreterSettings, filter: TextFilter) -> Result<PageText, String> {
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.
+1
View File
@@ -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()) => {
+6
View File
@@ -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;
+16
View File
@@ -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 عدد الكلمات…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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 Брой думи…
Can't render this file because it contains an unexpected character in line 8 and column 16.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 51.
+16
View File
@@ -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 Καταμέτρηση λέξεων…
Can't render this file because it contains an unexpected character in line 469 and column 9.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 60.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 39.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 7 and column 44.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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 文字数と単語数…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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 Сөздер саны…
Can't render this file because it contains an unexpected character in line 816 and column 9.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 395 and column 9.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+16
View File
@@ -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 Количество слов…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+16
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+16
View File
@@ -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 పదాల లెక్క…
Can't render this file because it contains an unexpected character in line 13 and column 16.
+16
View File
@@ -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ı…
Can't render this file because it contains an unexpected character in line 815 and column 9.
+16
View File
@@ -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 Кількість слів…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -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 字词统计…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+16
View File
@@ -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 字詞統計…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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<usize>, 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),
+225
View File
@@ -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<usize>,
options: Options,
}
struct Job {
key: Key,
receiver: mpsc::Receiver<Result<Report, String>>,
cancel: Arc<AtomicBool>,
done: Arc<AtomicUsize>,
}
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<Key>,
job: Option<Job>,
result: Option<Result<Report, String>>,
}
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);
}
}
+52
View File
@@ -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`.
+12
View File
@@ -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"