diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index cf4433b9..e262bb5d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -63,7 +63,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 = "09dec62d351c0bdfd33358b15f7d03a55ad6940571741def05e8ed899c4097d6" +sha256 = "6d81592677f2b4eb3b97ba5810de9752da969545d91897e27033f37310696ffc" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" diff --git a/crates/a11y/README.md b/crates/a11y/README.md index f00f429b..0a653f13 100644 --- a/crates/a11y/README.md +++ b/crates/a11y/README.md @@ -4,7 +4,7 @@ Layer L4: accessibility (execution plan M12.3). Today: the Accessibility Checker ```rust let report = check(&doc, &Options::default()); // 31 of 32 rules (colour contrast off) -let html = report_html(&report, "file.pdf", "2026-10-02"); +let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); // or an interface's words ``` - **32 rules in 7 categories**, as in Acrobat's full check: Document (permission flag, diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 7e8c14a8..a6910823 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -1,5 +1,8 @@ //! The Document, Page Content and Forms rules: catalog settings, page content (tagged or //! artifact, text, images, fonts) and annotations. +//! +//! Every sentence a rule here can say is listed in [`crate::WORDINGS`], so interfaces can say it in +//! their own language; a test in this crate keeps that list complete. use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -144,8 +147,8 @@ fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()) } -fn doc_finding(message: impl Into) -> Vec { - vec![Finding { page: None, message: message.into() }] +fn doc_finding(sentence: &'static str) -> Vec { + vec![Finding::plain(None, sentence)] } pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec { @@ -167,10 +170,10 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned())); let mut out = Vec::new(); if !cat.contains(b"StructTreeRoot") { - out.push(Finding { page: None, message: "The document has no tags (structure tree)".into() }); + out.push(Finding::plain(None, "The document has no tags (structure tree)")); } if !matches!(marked, Some(Object::Bool(true))) { - out.push(Finding { page: None, message: "The document isn't marked as tagged (MarkInfo Marked)".into() }); + out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)")); } out } @@ -185,7 +188,7 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S let mut out = Vec::new(); let info = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().cloned()).unwrap_or_default(); if text_of(doc, &info, b"Title").is_none() { - out.push(Finding { page: None, message: "The document has no title".into() }); + out.push(Finding::plain(None, "The document has no title")); } let shows = cat .get(b"ViewerPreferences") @@ -193,13 +196,17 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S .and_then(|v| v.as_dict().and_then(|d| d.get(b"DisplayDocTitle").map(|x| matches!(&*doc.resolve(x), Object::Bool(true))))) .unwrap_or(false); if !shows { - out.push(Finding { page: None, message: "The window shows the file name, not the title (Initial View > Show)".into() }); + out.push(Finding::plain(None, "The window shows the file name, not the title (Initial View > Show)")); } out } Rule::Bookmarks => { let has = cat.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().map(|d| d.contains(b"First"))).unwrap_or(false); - if pages.len() > 20 && !has { doc_finding(format!("{} pages and no bookmarks", pages.len())) } else { Vec::new() } + if pages.len() > 20 && !has { + vec![Finding::worded(None, "{n} pages and no bookmarks", vec![("{n}", pages.len().to_string())])] + } else { + Vec::new() + } } _ => Vec::new(), } @@ -226,7 +233,10 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz return doc_finding("The document isn't tagged, so none of its content is"); } for (p, n) in &scan.untagged { - out.push(Finding { page: Some(*p), message: format!("Page {}: {n} untagged content item{}", p + 1, if *n == 1 { "" } else { "s" }) }); + // One item and several items are separate wordings, so a language can agree the + // noun with the number its own way. + let pattern = if *n == 1 { "Page {p}: {n} untagged content item" } else { "Page {p}: {n} untagged content items" }; + out.push(Finding::worded(Some(*p), pattern, vec![("{p}", (p + 1).to_string()), ("{n}", n.to_string())])); } } Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => { @@ -240,8 +250,17 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz _ => st != b"Widget" && !MULTIMEDIA.contains(&st) && st != b"PrinterMark", }; if applies && !(tagged && a.contains(b"StructParent")) { - let what = if st == b"Widget" { "Form field".to_string() } else { format!("{} annotation", subtype_name(&a)) }; - out.push(Finding { page: Some(p), message: format!("{what} on page {} isn't tagged", p + 1) }); + // A whole sentence each way, rather than a noun put in front of one: a form + // field and an annotation of some subtype read differently in other + // languages. The subtype itself is the PDF's own name and goes in as it is. + let mut values = vec![("{p}", (p + 1).to_string())]; + let pattern = if st == b"Widget" { + "Form field on page {p} isn't tagged" + } else { + values.push(("{}", subtype_name(&a))); + "{} annotation on page {p} isn't tagged" + }; + out.push(Finding::worded(Some(p), pattern, values)); } } } @@ -250,7 +269,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz for &p in list { let Some(page) = pages.get(p) else { continue }; if !annots(page).is_empty() && page.dict.name(b"Tabs") != Some(b"S") { - out.push(Finding { page: Some(p), message: format!("Page {} doesn't tab in structure order", p + 1) }); + out.push(Finding::worded(Some(p), "Page {p} doesn't tab in structure order", vec![("{p}", (p + 1).to_string())])); } } } @@ -262,8 +281,18 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz && let Some(f) = doc.get(*r).as_dict() && !maps_to_unicode(doc, f) { - let name = f.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "(unnamed)".into()); - out.push(Finding { page: Some(*p), message: format!("Font {name} on page {} doesn't map its characters to Unicode", p + 1) }); + // A font with no name gets its own whole sentence, so another language can + // word that rather than translate a parenthesis. The name itself is the + // document's own and goes in as it stands. + let page = ("{p}", (p + 1).to_string()); + out.push(match f.name(b"BaseFont") { + Some(n) => Finding::worded( + Some(*p), + "Font {} on page {p} doesn't map its characters to Unicode", + vec![("{}", String::from_utf8_lossy(n).into_owned()), page], + ), + None => Finding::worded(Some(*p), "Font (unnamed) on page {p} doesn't map its characters to Unicode", vec![page]), + }); } } } @@ -284,10 +313,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz } let fd = doc.get(field).as_dict().cloned().unwrap_or_default(); if text_of(doc, &fd, b"TU").is_none() { - out.push(Finding { - page: Some(p), - message: format!("Field \"{}\" on page {} has no description (tooltip)", full_name(doc, field), p + 1), - }); + out.push(Finding::worded( + Some(p), + "Field \"{}\" on page {p} has no description (tooltip)", + vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())], + )); } } } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 514c5b71..60b26102 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -20,7 +20,7 @@ mod structure; pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; pub use headings::{Heading, headings}; -pub use report::report_html; +pub use report::{REPORT_WORDS, report_html}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { @@ -407,13 +407,154 @@ impl Status { } /// One problem: where it is and what is wrong. +/// +/// The sentence comes in two forms. [`message`](Finding::message) is it, in English, ready to read. +/// [`pattern`](Finding::pattern) is the same sentence with a named hole where each value went, and +/// [`values`](Finding::values) is what filled them, so an interface can say it in another language +/// instead of showing the English one: it translates the pattern and puts the same values back. +/// `message` is always `pattern` with `values` in it, because [`Finding::worded`] builds it. +/// +/// Every hole holds the document's own data — an element type, a font name, a count — and goes in +/// as it stands, with one exception: a hole named `{what}` holds another wording of ours, still with +/// its own holes in it. An interface translates that wording too and puts it in the frame first, and +/// then fills every hole in what it has; [`Finding::nested`] builds such a finding. #[derive(Clone, Debug, PartialEq)] pub struct Finding { /// 0-based page, when the problem is on a page. pub page: Option, pub message: String, + /// The English sentence with a hole, such as `{p}` or `{what}`, where each value went. + pub pattern: &'static str, + /// What goes in each of `pattern`'s holes, by the hole's name (`"{p}"`, braces included). + pub values: Vec<(&'static str, String)>, } +impl Finding { + /// A finding whose sentence says the same thing however the document is put together. + pub fn plain(page: Option, sentence: &'static str) -> Self { + Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() } + } + + /// A finding built from a wording and what goes in its holes. The English `message` is filled + /// in here, so it and `pattern` can never drift apart. + pub fn worded(page: Option, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self { + Finding { page, message: say(pattern, &values), pattern, values } + } + + /// A finding that says where the problem is and, in `{what}`, what is wrong, each worded + /// separately so the two read in whichever order the language reads them. + /// + /// `what` is one of our own wordings and may have holes of its own; they are filled from + /// `values` along with the frame's. It goes in before the values do — it is ours, not the + /// document's — which is what lets its holes be filled at all. + pub fn nested(page: Option, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self { + let message = say(&pattern.replace("{what}", what), &values); + values.push(("{what}", what.to_owned())); + Finding { page, message, pattern, values } + } + + /// This finding as one sentence, said in the language `words` translates into. + /// + /// The frame is translated, the wording in `{what}` — if the frame has one — is translated and + /// goes in, and only then does the document's own data, in a single pass, so nothing the + /// document itself says can be read as a hole. With `&|s: &str| s.to_owned()` this is + /// [`message`](Finding::message). + pub fn said(&self, words: Words) -> String { + let mut frame = words(self.pattern); + if let Some((_, what)) = self.values.iter().find(|(name, _)| *name == "{what}") { + frame = frame.replace("{what}", &words(what)); + } + say(&frame, &self.values) + } +} + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{p}`; what is not in `values` is left as it is written. +/// Values go in during a single pass over the wording, so a value that itself contains braces is +/// never read as a hole: the document's own words reach the screen as they stand. +/// +/// An interface says a [`Finding`] in its own language with this, after translating the pattern +/// and, if there is one, the wording in `{what}`. +pub fn say(pattern: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(pattern.len() + 16); + let mut rest = pattern; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace, and `}` is one byte, so this is a char boundary; `get` + // rather than slicing in case it ever isn't. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} + +/// Every sentence the checker can say: each [`Finding::pattern`] and each wording that goes in a +/// `{what}` hole, in English, with its holes still in it. +/// +/// An interface that shows the report in another language translates these, which is the whole +/// reason they are gathered here; `wordings_are_complete` fails if a rule says something that is +/// not on the list. +pub const WORDINGS: &[&str] = &[ + // Document rules. + "The security settings don't allow screen readers to read the text", + "No page has text: the document appears to be images of pages", + "The document has no tags (structure tree)", + "The document isn't marked as tagged (MarkInfo Marked)", + "No document language is set", + "The document has no title", + "The window shows the file name, not the title (Initial View > Show)", + "{n} pages and no bookmarks", + // Page content and forms. + "The document isn't tagged, so none of its content is", + "Page {p}: {n} untagged content item", + "Page {p}: {n} untagged content items", + "Form field on page {p} isn't tagged", + "{} annotation on page {p} isn't tagged", + "Page {p} doesn't tab in structure order", + "Font {} on page {p} doesn't map its characters to Unicode", + "Font (unnamed) on page {p} doesn't map its characters to Unicode", + "Field \"{}\" on page {p} has no description (tooltip)", + // The structure rules: the two frames, then what goes in their `{what}`. + "{ty} element: {what}", + "{ty} element on page {p}: {what}", + "no alternate text", + "its alternate text is inside another element's and will never be read", + "alternate text on an element with no page content", + "alternate text hides an annotation inside it", + "not in a Table, THead, TBody or TFoot", + "not in a TR", + "no header cells (TH)", + "rows have between {min} and {max} columns", + "no summary", + "not in an L (list)", + "not in an LI (list item)", + "the first heading is an H{n}; headings should not skip levels", + "H{n} follows an H{d}; headings should not skip levels", + // The summary at the end of a long list. + AND_MORE, +]; + #[derive(Clone, Debug, PartialEq)] pub struct RuleResult { pub rule: Rule, @@ -458,6 +599,9 @@ impl Report { /// Findings beyond this many per rule are summarised ("… and n more"). const MAX_FINDINGS: usize = 200; +/// What the summary of the findings beyond [`MAX_FINDINGS`] says. +const AND_MORE: &str = "… and {n} more"; + /// Run the full check. pub fn check(doc: &Document, options: &Options) -> Report { let pages = pdfcraft_model::pages(doc); @@ -495,7 +639,7 @@ pub fn check(doc: &Document, options: &Options) -> Report { if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); - findings.push(Finding { page: None, message: format!("… and {more} more") }); + findings.push(Finding::worded(None, AND_MORE, vec![("{n}", more.to_string())])); } let status = if findings.is_empty() { Status::Passed } else { Status::Failed }; results.push(RuleResult { rule, status, findings }); diff --git a/crates/a11y/src/report.rs b/crates/a11y/src/report.rs index 7b042ba9..97f8a4ba 100644 --- a/crates/a11y/src/report.rs +++ b/crates/a11y/src/report.rs @@ -1,48 +1,76 @@ //! The accessibility report: a self-contained HTML page with the summary and, per category, //! each rule's status, description and problems. +//! +//! The page is written in whichever language `words` translates into, down to its `lang` attribute, +//! so it reads the way the interface that saved it reads. Only the file name, the date and what the +//! findings say about the document itself come from elsewhere. -use crate::{Category, Report, Status}; +use crate::{Category, Report, Status, Words}; fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } -/// The report for `file_name`, dated `date` (as the caller formats it). -pub fn report_html(report: &Report, file_name: &str, date: &str) -> String { +/// The page's own words, in the order it says them: its heading, what it calls the file and the +/// date, the two things the summary can say, and the headers of the table. +/// +/// The rest of what the page says it gets from elsewhere — the categories, the rules and the +/// findings name themselves — so an interface has these to translate and those to translate; a test +/// in this crate keeps the list in step with the page. +pub const REPORT_WORDS: &[&str] = &[ + "Accessibility Report", + "Filename", + "Report created", + "Summary", + "The checker found problems which may prevent the document from being fully accessible.", + "The checker found no problems in this document.", + "Detailed Report", + "Rule Name", + "Status", + "Description", +]; + +/// The report for `file_name`, dated `date` (as the caller formats it), written in the language +/// `words` translates into and marked `lang` (`"en"` and `&|s: &str| s.to_owned()` for English). +pub fn report_html(report: &Report, file_name: &str, date: &str, lang: &str, words: Words) -> String { + let title = esc(&words("Accessibility Report")); let mut h = String::new(); - h.push_str("\nAccessibility Report\n\n

Accessibility Report

\n"); - h.push_str(&format!("

Filename: {}
Report created: {}

\n", esc(file_name), esc(date))); - h.push_str("

Summary

\n

"); - h.push_str(if report.count(Status::Failed) > 0 { - "The checker found problems which may prevent the document from being fully accessible." + h.push_str(&format!("\n

{title}

\n")); + h.push_str(&format!("

{}: {}
{}: {}

\n", esc(&words("Filename")), esc(file_name), esc(&words("Report created")), esc(date))); + h.push_str(&format!("

{}

\n

", esc(&words("Summary")))); + // Each outcome is a whole sentence, and each is handed over on its own, so the list in + // `REPORT_WORDS` can be read off this file. + h.push_str(&if report.count(Status::Failed) > 0 { + esc(&words("The checker found problems which may prevent the document from being fully accessible.")) } else { - "The checker found no problems in this document." + esc(&words("The checker found no problems in this document.")) }); h.push_str("

\n
    \n"); for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] { - h.push_str(&format!("
  • {}: {}
  • \n", s.label(), report.count(s))); + h.push_str(&format!("
  • {}: {}
  • \n", esc(&words(s.label())), report.count(s))); } - h.push_str("
\n

Detailed Report

\n"); + h.push_str(&format!("\n

{}

\n", esc(&words("Detailed Report")))); + let (name, status, description) = (esc(&words("Rule Name")), esc(&words("Status")), esc(&words("Description"))); for c in Category::ALL { - h.push_str(&format!("

{}

\n\n", c.label())); + h.push_str(&format!("

{}

\n
Rule NameStatusDescription
\n", esc(&words(c.label())))); for r in report.in_category(c) { h.push_str(&format!( "
{name}{status}{description}
{}{}{}", - esc(r.rule.name()), + esc(&words(r.rule.name())), r.status, - r.status.label(), - esc(r.rule.description()) + esc(&words(r.status.label())), + esc(&words(r.rule.description())) )); if !r.findings.is_empty() { h.push_str("
    "); for f in &r.findings { - h.push_str(&format!("
  • {}
  • ", esc(&f.message))); + h.push_str(&format!("
  • {}
  • ", esc(&f.said(words)))); } h.push_str("
"); } diff --git a/crates/a11y/src/structure.rs b/crates/a11y/src/structure.rs index 0ebb8d71..2a481148 100644 --- a/crates/a11y/src/structure.rs +++ b/crates/a11y/src/structure.rs @@ -5,6 +5,9 @@ //! tells a [`Visitor`] about each element as it opens and as it closes. What the rules keep is //! bounded by the elements still open, plus, for a table, the row spans that reach into later //! rows. Findings are kept in document order, up to the report's limit. +//! +//! Every sentence a rule here can say is listed in [`crate::WORDINGS`], so interfaces can say it in +//! their own language; a test in this crate keeps that list complete. use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; @@ -15,6 +18,9 @@ use pdfcraft_model::Page; use crate::{Finding, MAX_FINDINGS, Rule}; +/// Why a table's rows are irregular; `{min}` and `{max}` are the narrowest and widest row. +const IRREGULAR: &str = "rows have between {min} and {max} columns"; + /// Standard structure types (PDF 1.7 and 2.0); anything else goes through the role map. const STANDARD: &[&[u8]] = &[ b"Document", @@ -449,10 +455,26 @@ fn row_owner(path: &[Frame]) -> Option { } } -fn finding(e: &Frame, what: &str) -> Finding { - let ty = String::from_utf8_lossy(&e.ty); - let at = e.page.map(|p| format!(" on page {}", p + 1)).unwrap_or_default(); - Finding { page: e.page, message: format!("{ty} element{at}: {what}") } +/// A finding about element `e`: which element it is, and in `{what}` what is wrong with it. +/// +/// `what` is a wording, not a finished sentence, and `extra` fills any holes it has of its own. +/// With and without a page are separate wordings so each reads the way the language reads it; +/// `{ty}` is the structure tag's own name and goes in as it stands. +fn worded(e: &Frame, what: &'static str, extra: Vec<(&'static str, String)>) -> Finding { + let mut values = extra; + values.push(("{ty}", String::from_utf8_lossy(&e.ty).into_owned())); + match e.page { + Some(p) => { + values.push(("{p}", p.saturating_add(1).to_string())); + Finding::nested(e.page, "{ty} element on page {p}: {what}", what, values) + } + None => Finding::nested(None, "{ty} element: {what}", what, values), + } +} + +/// A finding whose `{what}` wording has no holes of its own. +fn finding(e: &Frame, what: &'static str) -> Finding { + worded(e, what, Vec::new()) } /// Columns `start..end` that a row span covers, with the rows still to come after this one. @@ -685,8 +707,16 @@ impl Visitor for Checks { let prev = self.heading; if level > prev + 1 { self.add(Rule::HeadingNesting, e.index, || { - let what = if prev == 0 { format!("the first heading is an H{level}") } else { format!("H{level} follows an H{prev}") }; - finding(e, &format!("{what}; headings should not skip levels")) + // A whole sentence each way: what is wrong with the first heading and what is + // wrong with a later one are not the same remark with a word swapped. + let mut values = vec![("{n}", level.to_string())]; + let what = if prev == 0 { + "the first heading is an H{n}; headings should not skip levels" + } else { + values.push(("{d}", prev.to_string())); + "H{n} follows an H{d}; headings should not skip levels" + }; + worded(e, what, values) }); } self.heading = level; @@ -705,7 +735,9 @@ impl Visitor for Checks { self.add(Rule::TableHeaders, e.index, || finding(e, "no header cells (TH)")); } if let Some((narrow, wide)) = table.range.filter(|(lo, hi)| lo != hi) { - self.add(Rule::TableRegularity, e.index, || finding(e, &format!("rows have between {narrow} and {wide} columns"))); + self.add(Rule::TableRegularity, e.index, || { + worded(e, IRREGULAR, vec![("{min}", narrow.to_string()), ("{max}", wide.to_string())]) + }); } } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 24a6a348..9aa3f57c 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -347,9 +347,15 @@ fn rules_have_ids_and_the_report_lists_them() { let per: Vec = Category::ALL.iter().map(|c| Rule::ALL.iter().filter(|r| r.category() == *c).count()).collect(); assert_eq!(per, [8, 9, 2, 5, 5, 2, 1]); let doc = pdf(&good(), "/Info 8 0 R"); - let html = report_html(&check(&doc, &Options::default()), "a.pdf", "2026-10-02"); + let report = check(&doc, &Options::default()); + let html = report_html(&report, "a.pdf", "2026-10-02", "en", &in_english); assert!(html.contains("a<b>.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5")); assert!(html.contains("

Headings

")); + // In another language every word of ours is said in it, down to the `lang`; the file name is the + // file's own and stays as it is. + let other = report_html(&report, "a.pdf", "2026-10-02", "es", &|w| format!("«{w}»")); + assert!(other.contains("") && other.contains("

«Headings»

") && other.contains("a<b>.pdf")); + assert!(!other.contains(">Headings<"), "an English heading is left in the report: {other}"); } fn figures_doc() -> Document { @@ -464,3 +470,93 @@ fn an_untagged_document_has_no_headings() { objs[0] = "<< /Type /Catalog /Pages 2 0 R >>".into(); assert!(headings(&pdf(&objs, "")).is_empty()); } + +/// The text of every `"…"` literal with a space in it, outside comments and byte strings. +fn sentences(source: &str) -> Vec { + let b = source.as_bytes(); + let (mut out, mut i) = (Vec::new(), 0); + while i < b.len() { + if b[i] == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i] == b'"' { + let byte_string = i > 0 && b[i - 1] == b'b'; + let start = i + 1; + i = start; + while i < b.len() && b[i] != b'"' { + i += if b[i] == b'\\' { 2 } else { 1 }; + } + // Quotes and backslashes are ASCII, so this never cuts a character in two. + let text = source.get(start..i.min(b.len())).unwrap_or_default(); + if !byte_string && text.contains(' ') { + out.push(text.replace("\\\"", "\"")); + } + i += 1; + } else { + i += 1; + } + } + out +} + +/// Every sentence the rules can say is listed in `WORDINGS`, so an interface showing the report in +/// another language has something to translate. The two rule files hold no prose of their own, so a +/// literal with a space in it is a sentence a person reads. +#[test] +fn wordings_are_complete() { + let mut missing = Vec::new(); + for source in [include_str!("content.rs"), include_str!("structure.rs")] { + for sentence in sentences(source) { + if !WORDINGS.contains(&sentence.as_str()) { + missing.push(sentence); + } + } + } + assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}"); +} + +/// Every word the report page says of its own is listed in `REPORT_WORDS`, and every word on that +/// list is one the page says: the page hands each over to `words` as a literal, so the two can be +/// read off one another. +#[test] +fn report_words_are_complete() { + let source = include_str!("report.rs"); + let mut said = Vec::new(); + let mut rest = source; + while let Some(at) = rest.find("words(\"") { + rest = &rest[at + 7..]; + let Some(end) = rest.find('"') else { break }; + said.push(&rest[..end]); + rest = &rest[end..]; + } + let missing: Vec<&&str> = said.iter().filter(|word| !REPORT_WORDS.contains(word)).collect(); + assert!(missing.is_empty(), "add these to REPORT_WORDS, and a translation of each to every interface: {missing:#?}"); + let unsaid: Vec<&&str> = REPORT_WORDS.iter().filter(|word| !said.contains(word)).collect(); + assert!(unsaid.is_empty(), "REPORT_WORDS lists words the page no longer says: {unsaid:#?}"); +} + +/// `say` puts the values in, and what it puts in is never read as a hole of its own: a document that +/// names a font `{p}` cannot make a finding say which page it is on. +#[test] +fn say_fills_holes_once() { + let values = [("{p}", "7".to_string()), ("{}", "{p} {n}".to_string())]; + assert_eq!( + say("Font {} on page {p} doesn't map its characters to Unicode", &values), + "Font {p} {n} on page 7 doesn't map its characters to Unicode" + ); + // A hole with nothing for it, and a brace with nothing closing it, are left as they stand. + assert_eq!(say("{a} {p} {", &values), "{a} 7 {"); +} + +/// A finding whose frame holds another wording says the whole thing in English, holes and all +/// filled, and still hands the interface the two wordings to translate. +#[test] +fn a_nested_finding_says_itself() { + let values = vec![("{min}", "1".to_string()), ("{max}", "2".to_string()), ("{ty}", "Table".to_string()), ("{p}", "3".to_string())]; + let f = Finding::nested(Some(2), "{ty} element on page {p}: {what}", "rows have between {min} and {max} columns", values); + assert_eq!(f.message, "Table element on page 3: rows have between 1 and 2 columns"); + assert_eq!(f.said(&in_english), f.message, "saying it in English is the message"); + // Both wordings are translated, each on its own, and the document's words are left alone. + assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»"); +} diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 92ea4e95..f23ee24f 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -84,7 +84,9 @@ impl Automation { let r = doc.accessibility_check(&o).ok_or_else(|| failed("the document can't be read for checking"))?; let path = self.resolve(a.str("path")?, true)?; let (y, m, d) = self.session.today(); - let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}")); + // A script's report is in English, like the rest of what the tools say and answer with. + let words = pdfcraft_engine::a11y::in_english; + let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}"), "en", &words); write_atomic(&path, html.as_bytes())?; let mut out = report_json(&r); out["path"] = json!(path.to_string_lossy()); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 3f73ebec..c2e43a3a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -256,12 +256,12 @@ impl Automation { "doc_split" => self.doc_split(&a)?, "edit_undo" => { let id = self.doc(&a)?.id; - let label = self.session.undo(id).map_err(failed)?; + let label = self.session.undo(id).map_err(failed)?.to_string(); json!({ "undone": label, "document": summary(self.doc(&a)?) }) } "edit_redo" => { let id = self.doc(&a)?.id; - let label = self.session.redo(id).map_err(failed)?; + let label = self.session.redo(id).map_err(failed)?.to_string(); json!({ "redone": label, "document": summary(self.doc(&a)?) }) } "command_list" => self.command_list(&a)?, diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c6bed635..7701f612 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -70,7 +70,8 @@ impl Automation { "timestamp_time": s.timestamp_time.map(|t| t.to_string()), "modification": modification, "changes": changes, - "details": s.details, + // Scripts read this, so the sentences stay in English, whatever the interface reads in. + "details": s.details.iter().map(|d| d.message.clone()).collect::>(), })) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b02911de..9760ee42 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -393,13 +393,22 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) } } +/// The edit `spec` names now, with the wording that names it ("Undo {}"), so an interface can say +/// each part in its own language. `None` for every other command, and when there is nothing to undo. +pub fn current_edit<'a>(spec: &CommandSpec, session: &'a Session, active: Option) -> Option<(&'static str, &'a crate::Label)> { + let doc = active.and_then(|id| session.get(id))?; + match spec.id { + "edit.undo" => doc.undo_label().map(|l| ("Undo {}", l)), + "edit.redo" => doc.redo_label().map(|l| ("Redo {}", l)), + _ => None, + } +} + /// The label to show for `spec` now ("Undo Rotate page" rather than "Undo"). pub fn current_label(spec: &CommandSpec, session: &Session, active: Option) -> String { - let doc = active.and_then(|id| session.get(id)); - match (spec.id, doc) { - ("edit.undo", Some(d)) => d.can_undo().map(|l| format!("Undo {l}")).unwrap_or_else(|| spec.label.into()), - ("edit.redo", Some(d)) => d.can_redo().map(|l| format!("Redo {l}")).unwrap_or_else(|| spec.label.into()), - _ => spec.label.into(), + match current_edit(spec, session, active) { + Some((wording, label)) => wording.replacen("{}", label.as_str(), 1), + None => spec.label.into(), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 850661ad..cb602e13 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -209,7 +209,7 @@ fn scope_of(edit: &Edit) -> Scope { } /// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (String, pdfcraft_cos::Document, Keys, Scope); +type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); /// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). #[derive(Clone)] @@ -414,11 +414,21 @@ impl Document { } pub fn can_undo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l.as_str()) + self.undo_label().map(Label::as_str) } pub fn can_redo(&self) -> Option<&str> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l.as_str()) + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) } pub fn editable(&self) -> bool { @@ -1249,134 +1259,354 @@ pub enum Edit { }, } +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"]; + impl Edit { /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> String { + pub fn label(&self) -> Label { match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", pages.len()), - Edit::InsertBlankPage { .. } => "Insert blank page".into(), - Edit::DuplicatePages { pages } => plural("Duplicate page", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", pages.len()), - Edit::SetPageBox { .. } => "Set page boxes".into(), - Edit::SetInfo { key, .. } => format!("Change {key}"), - Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"), - Edit::AddBookmark { .. } => "Add bookmark".into(), - Edit::RenameBookmark { .. } => "Rename bookmark".into(), - Edit::DeleteBookmark { .. } => "Delete bookmark".into(), - Edit::MoveBookmark { .. } => "Move bookmark".into(), - Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(), - Edit::BookmarksFromStructure => "New bookmarks from structure".into(), - Edit::NumberPages { .. } => "Number pages".into(), - Edit::AddMeasurement(m) => format!("Measure {}", m.kind.name()), - Edit::SetMeasurementScale { .. } => "Set measurement scale".into(), - Edit::AddAnnotation(a) => format!("Add {}", annotation_noun(&a.shape)), - Edit::AddCustomStamp { .. } => "Add stamp".into(), - Edit::DeleteAnnotation { .. } => "Delete comment".into(), - Edit::SetAnnotationContents { .. } => "Edit comment".into(), - Edit::ReplyToAnnotation { .. } => "Reply".into(), - Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()), - Edit::ReplaceText { .. } => "Replace text".into(), - Edit::EraseInk { .. } => "Erase".into(), - Edit::MarkAnnotation { marked: true, .. } => "Mark with checkmark".into(), - Edit::MarkAnnotation { .. } => "Remove checkmark".into(), - Edit::LockAnnotation { locked: true, .. } => "Lock comment".into(), - Edit::LockAnnotation { .. } => "Unlock comment".into(), - Edit::MoveAnnotation { .. } => "Move comment".into(), - Edit::ResizeAnnotation { .. } => "Resize comment".into(), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(), - Edit::SetFieldValue { name, .. } => format!("Fill in {name}"), - Edit::SetFieldImage { name, .. } => format!("Set the image of {name}"), - Edit::ResetForm { .. } => "Clear form".into(), - Edit::AddField { .. } => "Add field".into(), - Edit::SetFieldProps { .. } => "Change field properties".into(), - Edit::DeleteField { .. } => "Delete field".into(), - Edit::DuplicateField { .. } => "Duplicate field".into(), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => "Set tab order".into(), - Edit::SetInitialView(_) => "Change initial view".into(), - Edit::SetAltText { .. } => "Set alternate text".into(), - Edit::MarkDecorative { .. } => "Mark figure as decorative".into(), - Edit::AddOcrText { .. } => "Recognize text".into(), - Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(), - Edit::XfaEvent { .. } => "Run form script".into(), - Edit::ConvertPdfA { level } => format!("Save as {}", level.label()), - Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"), - Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(), - Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(), - Edit::MoveObjects { objects, .. } => plural("Move object", objects.len()), - Edit::EditPageImage { change, .. } => match change { - ImageEdit::Move(_) => "Move image".into(), - ImageEdit::Rotate(_) => "Rotate image".into(), - ImageEdit::Flip { .. } => "Flip image".into(), - ImageEdit::Replace { .. } => "Replace image".into(), - ImageEdit::Delete => "Delete image".into(), + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), }, - Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), - Edit::AddHeaderFooter { .. } => "Update header & footer".into(), - Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), - Edit::AddWatermark { .. } => "Update watermark".into(), - Edit::AddBackground { replace: false, .. } => "Add background".into(), - Edit::AddBackground { .. } => "Update background".into(), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(), - Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(), - Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(), - Edit::AddText { .. } => "Add text".into(), - Edit::AddImage { .. } => "Add image".into(), - Edit::UpdateContent { .. } => "Edit content".into(), - Edit::DeleteContent { .. } => "Delete content".into(), - Edit::ReplaceImage { .. } => "Replace image".into(), - Edit::ApplyRedactions { .. } => "Apply redactions".into(), - Edit::ClearRedactions => "Remove redaction marks".into(), - Edit::RemoveHidden { .. } => "Remove hidden information".into(), - Edit::ImportData { name, .. } => format!("Import {name}"), - Edit::AddLink { .. } => "Add link".into(), - Edit::SetLink { .. } => "Change link properties".into(), - Edit::DeleteLink { .. } => "Delete link".into(), - Edit::RemoveLinks { .. } => "Remove all links".into(), - Edit::AddLinks { links, .. } => plural("Create link", links.len()), - Edit::Sanitize => "Sanitize document".into(), - Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(), - Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), - Edit::Flatten { .. } => "Flatten".into(), - Edit::FlattenFillSign => "Flatten Fill & Sign".into(), - Edit::Protect(_) => "Protect with password".into(), - Edit::RemoveProtection => "Remove security".into(), - Edit::Batch { label, .. } => label.clone(), + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) => "Rotate image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), } } } -/// What the Edit menu calls a new comment ("Undo Add highlight"). -fn annotation_noun(s: &Shape) -> &'static str { +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { match s { - Shape::Note { .. } => "sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline", - Shape::Rectangle { .. } => "rectangle", - Shape::Oval { .. } => "oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow", - Shape::Line { .. } => "line", - Shape::Ink { .. } => "drawing", - Shape::TextBox { .. } => "text box", - Shape::Typewriter { .. } => "text", - Shape::Mark { mark: FillMark::Check, .. } => "checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "cross", - Shape::Mark { mark: FillMark::Dot, .. } => "dot", - Shape::Mark { mark: FillMark::Line, .. } => "line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "signature", - Shape::Redact { .. } => "redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "stamp", - Shape::Polygon { cloud: true, .. } => "cloud", - Shape::Polygon { .. } => "polygon", - Shape::PolyLine { .. } => "connected lines", - Shape::Callout { .. } => "callout", - Shape::Caret { .. } => "inserted text", - Shape::Attachment { .. } => "file attachment", + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", } } @@ -2006,8 +2236,10 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -fn plural(s: &str, n: usize) -> String { - if n == 1 { s.to_string() } else { format!("{s}s") } +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) } #[derive(Debug, thiserror::Error, PartialEq)] @@ -2575,7 +2807,7 @@ impl Session { // (applying redactions, changing security, sanitizing) would invalidate the signatures. let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); if signed && rewrites(&next) && !rewrites(&editor.cos) { - return Err(EditError::SignedRewrite(edit.label())); + return Err(EditError::SignedRewrite(edit.label().to_string())); } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); @@ -2616,7 +2848,7 @@ impl Session { Ok(()) } - pub fn undo(&mut self, id: DocId) -> Result { + pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; @@ -2630,7 +2862,7 @@ impl Session { Ok(label) } - pub fn redo(&mut self, id: DocId) -> Result { + pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7ae4592c..68cdc0c8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -218,12 +218,12 @@ fn undo_and_redo_restore_exact_states() { s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap(); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); - assert_eq!(s.undo(id).unwrap(), "Rotate page"); + assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); - assert_eq!(s.undo(id).unwrap(), "Move page"); + assert_eq!(s.undo(id).unwrap().as_str(), "Move page"); assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes"); assert_eq!(s.undo(id), Err(EditError::NothingToUndo)); - assert_eq!(s.redo(id).unwrap(), "Move page"); + assert_eq!(s.redo(id).unwrap().as_str(), "Move page"); assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]); // A new edit clears the redo stack. s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap(); @@ -396,7 +396,7 @@ fn batch_is_one_undo_step_and_all_or_nothing() { }; s.apply(id, batch).unwrap(); assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A")); - assert_eq!(s.undo(id).unwrap(), "Change properties"); + assert_eq!(s.undo(id).unwrap().as_str(), "Change properties"); assert_eq!(s.get(id).unwrap().info_value("Title"), None); assert_eq!(s.get(id).unwrap().can_undo(), None, "one step"); // A failing member rolls back the whole batch. @@ -432,7 +432,7 @@ fn combine_extract_split_and_insert_from_file() { // Insert pages from a file, undoably, into the open document. s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]); - assert_eq!(s.undo(id).unwrap(), "Insert pages from b.pdf"); + assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf"); assert_eq!(page_texts(&s, id).len(), 3); // Garbage sources fail cleanly. let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 }); @@ -1911,7 +1911,8 @@ fn signing_saving_trusting_and_commenting_afterwards() { assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); let sig = &s.get(id).unwrap().signatures[0]; - assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, sign::Modification::Allowed(vec!["comments".into()]))); + let comments = sign::Modification::Allowed(vec![sign::kind::COMMENTS]); + assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, comments)); // Saving keeps the signature (incremental). let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); @@ -3094,7 +3095,7 @@ fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() { let mut again = Session::new(); let other = again.open("moved.pdf", None, moved.clone(), None).unwrap(); assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after); - assert_eq!(s.undo(id).unwrap(), "Move objects"); + assert_eq!(s.undo(id).unwrap().as_str(), "Move objects"); assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before); assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects"); s.redo(id).unwrap(); diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 7dc6ee24..c2457c47 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -58,17 +58,90 @@ impl Level { } /// A rule a document breaks. +/// +/// [`message`](Issue::message) is the problem in English, ready to read. An interface that reads in +/// another language says it from [`pattern`](Issue::pattern) and [`value`](Issue::value) instead: +/// the pattern is wording of ours, listed in [`WORDINGS`], and the value — when there is one — is +/// the document's own name for something, a PDF keyword that reads the same in every language. +/// [`Issue::said`] does that. #[derive(Clone, Debug, PartialEq)] pub struct Issue { /// The ISO 19005-2 clause. pub clause: &'static str, pub message: String, + /// The problem as it is worded here, with `{}` where the document's own name goes. + pub pattern: &'static str, + /// What goes in the pattern's `{}`: an annotation subtype, an action, a font, a page number. + pub value: Option, /// 0-based page, when the problem is on a page. pub page: Option, /// Whether [`convert`] can fix it. pub fixable: bool, } +impl Issue { + /// The problem as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the document's own name goes in afterwards, in one pass, so a + /// font called `{}` cannot turn into anything else. With [`in_english`] this is + /// [`message`](Issue::message). + pub fn said(&self, words: Words) -> String { + let said = words(self.pattern); + match &self.value { + Some(v) => said.replacen("{}", v, 1), + None => said, + } + } +} + +/// How an interface says one of our wordings in its own language: given the English in [`WORDINGS`], +/// the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// Every problem [`verify`] can report, in English, with `{}` where the document's own name goes. +/// +/// An interface that shows them in another language translates these, which is the whole reason they +/// are gathered here; `wordings_are_complete` fails if a rule says something that is not on the +/// list. What [`convert`] reports as fixed is not here: that is read by scripts, in English. +pub const WORDINGS: &[&str] = &[ + // Identification and metadata. + "The document has no XMP metadata", + "The metadata doesn't identify the file as {}", + "The title in the metadata and the document information differ", + // The document as a whole. + "The document is encrypted", + "Device colour is used, but there is no PDF/A output intent", + "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", + "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", + "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", + "The document has additional actions (/AA)", + "The document has document-level JavaScript", + "The form asks viewers to make appearances (NeedAppearances)", + // Pages, actions, annotations and fields. + "Page {} has additional actions", + "A {} action isn't allowed", + "{} annotations aren't allowed", + "A {} annotation isn't set to print, or is hidden", + "A {} annotation has no appearance stream", + "A {} annotation has additional actions", + "A form field has additional actions", + // Fonts, streams, images and embedded files. + "The font {} isn't embedded", + "A font isn't embedded", + "A stream uses LZW compression", + "A stream refers to an external file", + "An image asks to be interpolated", + "An image has alternates or OPI information", + "PostScript XObjects aren't allowed", + "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", + "An embedded file has no AFRelationship", +]; + /// What a document declares (the Standards panel). #[derive(Clone, Debug, Default, PartialEq)] pub struct Declared { @@ -230,62 +303,70 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { let mut out = Vec::new(); - let mut issue = |clause: &'static str, message: String, page: Option, fixable: bool| out.push(Issue { clause, message, page, fixable }); + // Each problem is worded here and named by the document there: the wording goes on the list in + // `WORDINGS` for an interface to translate, and the name goes in its `{}` as it stands. + let mut issue = |clause: &'static str, pattern: &'static str, value: Option, page: Option, fixable: bool| { + let message = match &value { + Some(v) => pattern.replacen("{}", v, 1), + None => pattern.to_owned(), + }; + out.push(Issue { clause, message, pattern, value, page, fixable }); + }; let cat = catalog(doc); // 6.1.3 / 6.6: identification and metadata. match metadata(doc) { - None => issue("6.6.2.1", "The document has no XMP metadata".into(), None, true), + None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), Some(x) => { let part = xmp::value(&x, "pdfaid:part"); let conf = xmp::value(&x, "pdfaid:conformance"); if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { - issue("6.6.4", format!("The metadata doesn't identify the file as {}", level.label()), None, true); + issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } if let Some(title) = info(doc, "Title") && xmp::value(&x, "dc:title").is_some_and(|t| t != title) { - issue("6.6.3", "The title in the metadata and the document information differ".into(), None, true); + issue("6.6.3", "The title in the metadata and the document information differ", None, None, true); } } } // 6.1.3: encryption. if doc.trailer().get(b"Encrypt").is_some() { - issue("6.1.3", "The document is encrypted".into(), None, true); + issue("6.1.3", "The document is encrypted", None, None, true); } // 6.2.2: output intent when device colour is used. let objs = objects(doc); let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { - issue("6.2.3", "Device colour is used, but there is no PDF/A output intent".into(), None, !used.cmyk); + issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk); if used.cmyk { - issue("6.2.4.3", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)".into(), None, false); + issue("6.2.4.3", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", None, None, false); } } // 6.2.4.3: DeviceRGB needs an RGB output intent and DeviceCMYK a CMYK one (#667). Some(n) => { if used.cmyk && !used.default_cmyk && n != Some(4) { - issue("6.2.4.3", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile".into(), None, false); + issue("6.2.4.3", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", None, None, false); } if used.rgb && !used.default_rgb && n != Some(3) { - issue("6.2.4.3", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile".into(), None, false); + issue("6.2.4.3", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", None, None, false); } } None => {} } // 6.1.3 / 6.6.1: catalog actions. if cat.get(b"AA").is_some() { - issue("6.5.2", "The document has additional actions (/AA)".into(), None, true); + issue("6.5.2", "The document has additional actions (/AA)", None, None, true); } if let Some(names) = cat.get(b"Names").map(|n| doc.resolve(n)) && names.as_dict().is_some_and(|n| n.get(b"JavaScript").is_some()) { - issue("6.5.1", "The document has document-level JavaScript".into(), None, true); + issue("6.5.1", "The document has document-level JavaScript", None, None, true); } if let Some(af) = cat.get(b"AcroForm").map(|a| doc.resolve(a)) && af.as_dict().is_some_and(|a| matches!(a.get(b"NeedAppearances"), Some(Object::Bool(true)))) { - issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)".into(), None, true); + issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true); } // Pages: annotations and page actions. let pages = pdfcraft_model::pages(doc); @@ -302,7 +383,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { }; for (i, p) in pages.iter().enumerate() { if p.dict.get(b"AA").is_some() { - issue("6.5.2", format!("Page {} has additional actions", i + 1), Some(i), true); + issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } for (r, o) in &objs { @@ -314,19 +395,19 @@ pub fn verify(doc: &Document, level: Level) -> Vec { && FORBIDDEN_ACTIONS.contains(&s) && is_action(d) { - issue("6.5.1", format!("A {} action isn't allowed", String::from_utf8_lossy(s)), None, true); + issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true); } // Annotations. if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { let page = page_of(*r); let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", format!("{name} annotations aren't allowed"), page, true); + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); continue; } let f = d.int(b"F").unwrap_or(0); if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", format!("A {name} annotation isn't set to print, or is hidden"), page, true); + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); } let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { @@ -335,16 +416,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec { v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 }); if !zero { - issue("6.3.3", format!("A {name} annotation has no appearance stream"), page, false); + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); } } if d.get(b"AA").is_some() { - issue("6.5.2", format!("A {name} annotation has additional actions"), page, true); + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); } } // Form fields with additional actions (non-widget fields). if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions".into(), None, true); + issue("6.5.2", "A form field has additional actions", None, None, true); } // Fonts. if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { @@ -361,30 +442,30 @@ pub fn verify(doc: &Document, level: Level) -> Vec { None => Vec::new(), }; if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression".into(), None, false); + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); } if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file".into(), None, false); + issue("6.1.7.1", "A stream refers to an external file", None, None, false); } if sub == Some(b"Image") { if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated".into(), None, true); + issue("6.2.8", "An image asks to be interpolated", None, None, true); } if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information".into(), None, true); + issue("6.2.8", "An image has alternates or OPI information", None, None, true); } } if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed".into(), None, false); + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); } } // Embedded files. if ty == Some(b"Filespec") && d.get(b"EF").is_some() { match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)".into(), None, false), + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), Level::A3b => { if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship".into(), None, true); + issue("6.8", "An embedded file has no AFRelationship", None, None, true); } } } @@ -396,12 +477,12 @@ pub fn verify(doc: &Document, level: Level) -> Vec { out } -fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, String, Option, bool)) { +fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { let sub = d.name(b"Subtype"); if sub == Some(b"Type3") { return; } - let name = d.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "a font".into()); + let name = d.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()); let descriptor = if sub == Some(b"Type0") { d.get(b"DescendantFonts") .map(|x| doc.resolve(x)) @@ -416,7 +497,12 @@ fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, Str .and_then(|fd| fd.as_dict().cloned()) .is_some_and(|fd| fd.get(b"FontFile").is_some() || fd.get(b"FontFile2").is_some() || fd.get(b"FontFile3").is_some()); if !embedded { - issue("6.2.11.4", format!("The font {name} isn't embedded"), None, false); + // A font the document doesn't name gets a sentence of its own, rather than the word for + // "a font" put where a name goes, which read badly even in English. + match name { + Some(n) => issue("6.2.11.4", "The font {} isn't embedded", Some(n), None, false), + None => issue("6.2.11.4", "A font isn't embedded", None, None, false), + } } } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 35ed8565..dadbd583 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -60,6 +60,39 @@ fn verify_reports_what_breaks_pdfa() { } assert!(issues.iter().find(|i| i.message.starts_with("The font")).is_some_and(|i| !i.fixable)); assert!(clauses(&issues).contains(&"6.6.2.1")); + // Each problem is also worded separately from what the document calls things, so an interface + // can say it in another language; in English the two come to the same sentence. + for i in &issues { + assert_eq!(i.said(&in_english), i.message); + assert!(WORDINGS.contains(&i.pattern), "{:?} is not in WORDINGS", i.pattern); + } + let font = issues.iter().find(|i| i.pattern == "The font {} isn't embedded").unwrap(); + assert_eq!(font.said(&|w: &str| format!("«{w}»")), "«The font {} isn't embedded»".replace("{}", "Helvetica")); +} + +/// Every problem the rules can report is listed in `WORDINGS`, so an interface showing them in +/// another language has something to translate. The wording is `verify`'s second argument, which is +/// what this reads off the file; the sentences `convert` reports as fixed are not wordings. +#[test] +fn wordings_are_complete() { + let source = include_str!("lib.rs"); + let mut said = Vec::new(); + let mut rest = source; + while let Some(at) = rest.find("issue(\"") { + rest = &rest[at + 7..]; + // Step over the clause, then take the wording that follows it. + let Some(end) = rest.find('"') else { break }; + rest = &rest[end + 1..]; + let Some(text) = rest.strip_prefix(", \"") else { continue }; + let Some(end) = text.find('"') else { break }; + said.push(&text[..end]); + rest = text; + } + assert!(said.len() >= 24, "the wordings are not being read off the file: {said:#?}"); + let missing: Vec<&&str> = said.iter().filter(|word| !WORDINGS.contains(word)).collect(); + assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}"); + let unsaid: Vec<&&str> = WORDINGS.iter().filter(|word| !said.contains(word)).collect(); + assert!(unsaid.is_empty(), "WORDINGS lists problems the rules no longer report: {unsaid:#?}"); } #[test] diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 33c20d7f..1a2868d7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -27,10 +27,22 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; +/// What can go wrong when printing. +/// +/// The three things a *person* can be wrong about — there is nothing to print, a page number past +/// the end, a range that is not a page — each have their own variant with the numbers apart from +/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest +/// are faults rather than mistakes and carry their text as it is. #[derive(Debug, thiserror::Error, PartialEq)] pub enum PrintError { #[error("there are no pages to print")] NoPages, + /// A page number past the end of the document: the number asked for, and the page count. + #[error("page {0} is out of range (1–{1})")] + OutOfRange(usize, usize), + /// A range token that is neither a page number nor a page label. + #[error("{0:?} is not a page number or label")] + NotAPage(String), #[error("{0}")] Invalid(String), #[error("{0}")] @@ -39,6 +51,67 @@ pub enum PrintError { Cos(#[from] pdfcraft_cos::CosError), } +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { #[default] diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index e6efd0a2..07526194 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -19,8 +19,8 @@ fn page_of(tok: &str, count: usize, labels: &[String]) -> Result() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), - Ok(n) => Err(PrintError::Invalid(format!("page {n} is out of range (1–{count})"))), - Err(_) => Err(PrintError::Invalid(format!("{t:?} is not a page number or label"))), + Ok(n) => Err(PrintError::OutOfRange(n, count)), + Err(_) => Err(PrintError::NotAPage(t.to_string())), } } @@ -90,7 +90,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset /// labels. The subset and `reverse` apply as in [`select_pages`]. pub fn select_listed(count: usize, pages: &[usize], subset: Subset, reverse: bool) -> Result, PrintError> { if let Some(p) = pages.iter().find(|p| **p >= count) { - return Err(PrintError::Invalid(format!("page {} is out of range (1–{count})", p.saturating_add(1)))); + return Err(PrintError::OutOfRange(p.saturating_add(1), count)); } narrow(pages.to_vec(), subset, reverse) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 61239b6e..93559229 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -65,18 +65,48 @@ fn page_selection() { assert_eq!(select_pages(5, Some("ii-2, A-1"), &labels, Subset::All, false).unwrap(), [1, 2, 3, 4], "labels, even with a dash"); assert_eq!(select_pages(5, None, &[], Subset::Even, true).unwrap(), [3, 1]); assert_eq!(select_pages(5, None, &[], Subset::Odd, false).unwrap(), [0, 2, 4]); - assert!(matches!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::Invalid(_)))); - assert!(matches!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::Invalid(_)))); + assert_eq!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::OutOfRange(7, 5))); + assert_eq!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::NotAPage("x".into()))); + assert_eq!(select_pages(5, Some("7"), &[], Subset::All, false).unwrap_err().to_string(), "page 7 is out of range (1–5)"); assert_eq!(select_pages(1, None, &[], Subset::Even, false), Err(PrintError::NoPages)); // An explicit list (selected thumbnails): positions, never labels, in the order given. assert_eq!(select_listed(5, &[1, 3], Subset::All, false).unwrap(), [1, 3]); assert_eq!(select_listed(5, &[0, 2, 4], Subset::Even, false).unwrap(), [2]); assert_eq!(select_listed(5, &[0, 2, 4], Subset::Odd, true).unwrap(), [4, 0]); - assert!(matches!(select_listed(5, &[1, 5], Subset::All, false), Err(PrintError::Invalid(_)))); - assert!(matches!(select_listed(0, &[usize::MAX], Subset::All, false), Err(PrintError::Invalid(_)))); + assert_eq!(select_listed(5, &[1, 5], Subset::All, false), Err(PrintError::OutOfRange(6, 5))); + assert!(matches!(select_listed(0, &[usize::MAX], Subset::All, false), Err(PrintError::OutOfRange(_, 0)))); assert_eq!(select_listed(5, &[], Subset::All, false), Err(PrintError::NoPages)); } +/// Every mistake a person can make about what to print says itself out of a wording on the list +/// and the numbers apart from it, and says itself in English exactly as the error prints itself. +#[test] +fn wordings_are_said() { + let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into())]; + for e in &mistakes { + assert_eq!(e.said(&in_english), e.to_string(), "{e:?} says itself in English the way it prints itself"); + } + // Each wording is one of a mistake's, and each mistake's is on the list: the hole names and + // all, so a catalog that translates the list translates every sentence a person can be shown. + let said: Vec = WORDINGS.iter().map(|w| w.to_string()).collect(); + assert_eq!(said.len(), mistakes.len(), "one wording per mistake"); + for w in WORDINGS { + assert!(!w.is_empty() && w.ends_with(|c: char| c.is_alphanumeric() || c == ')'), "a whole sentence, not a fragment: {w:?}"); + } + // A fault is not a mistake: it carries its own text and is not on the list. + let fault = PrintError::Spool("lp: no such printer".into()); + assert_eq!(fault.said(&in_english), "lp: no such printer"); + assert_eq!(fault.said(&|_| "traducido".to_string()), "lp: no such printer", "nothing to translate"); + // Said in another language, the numbers still go in, and in that language's own order. + let other = |w: &str| match w { + "page {n} is out of range (1–{count})" => "el documento tiene {count} páginas, no {n}".to_string(), + w => w.to_string(), + }; + assert_eq!(PrintError::OutOfRange(7, 5).said(&other), "el documento tiene 5 páginas, no 7"); + // What was typed goes in as it stands, braces and all, and is never read as a hole. + assert_eq!(PrintError::NotAPage("{count}".into()).said(&other), "\"{count}\" is not a page number or label"); +} + #[test] fn page_selection_handles_ranges_between_hyphenated_labels() { let labels: Vec = ["A-1", "A-2", "A-3", "B-1", "B-2"].iter().map(|s| s.to_string()).collect(); diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index 0d5825ca..2954de28 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -28,8 +28,8 @@ pub use der::Time; pub use graphic::Graphic; pub use keys::{DigestAlg, PrivateKey, PublicKey}; pub use pdf::{ - Appearance, DigestCache, Modification, SignOptions, SignatureInfo, Status, TrustStore, list as signatures, sign, sign_with_timestamp, - timestamp_document, validate, + Appearance, AppearanceLabels, Detail, DigestCache, Fill, Modification, SignOptions, SignatureInfo, Status, TrustStore, WORDINGS, Words, + in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate, }; pub use pkcs12::DigitalId; pub use timestamp::{TimestampAuthority, TimestampQuery, TimestampToken}; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index e2eb6c61..4ba11e9d 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -212,17 +212,200 @@ pub enum Status { Invalid, } +/// What a change made after signing is called: the words [`Modification`] lists it by, and the +/// vocabulary a signature's details speak of changes in. +/// +/// They are words of ours, not the document's, so an interface reading in another language +/// translates each of them; [`ALL`](kind::ALL) is the whole vocabulary. +pub mod kind { + pub const PAGE_CONTENT: &str = "page content"; + pub const FORM_FILL: &str = "form fill"; + pub const COMMENTS: &str = "comments"; + pub const SIGNATURE: &str = "signature"; + pub const SECURITY_STORE: &str = "document security store"; + pub const METADATA: &str = "metadata"; + pub const LINKS: &str = "links"; + pub const STRUCTURE: &str = "document structure"; + pub const PAGES: &str = "pages added or removed"; + pub const OTHER: &str = "other changes"; + /// Not a change but the reason there is nothing to compare: the signed revision itself could not + /// be read, so what came after it cannot be allowed. + pub const UNREADABLE: &str = "the signed version could not be read"; + + /// Every word above, for an interface to translate. + pub const ALL: &[&str] = &[PAGE_CONTENT, FORM_FILL, COMMENTS, SIGNATURE, SECURITY_STORE, METADATA, LINKS, STRUCTURE, PAGES, OTHER, UNREADABLE]; +} + /// Changes made after signing. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Modification { /// The signature covers the whole file. None, - /// Later revisions only make changes the signature permits (listed). - Allowed(Vec), - /// Later revisions make changes it doesn't permit (listed). - Disallowed(Vec), + /// Later revisions only make changes the signature permits, listed in the words of [`kind`]. + Allowed(Vec<&'static str>), + /// Later revisions make changes it doesn't permit, listed in the words of [`kind`]. + Disallowed(Vec<&'static str>), } +/// One of the sentences that explain a signature's verdict. +/// +/// [`message`](Detail::message) is the sentence in English, ready to read. An interface that reads in +/// another language says it with [`Detail::said`] instead, out of [`pattern`](Detail::pattern) — +/// wording of ours, listed in [`WORDINGS`] — and [`fill`](Detail::fill), whatever goes in it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Detail { + /// The sentence in English. + pub message: String, + /// The sentence as it is worded here, with `{}` where the fill goes. + pub pattern: &'static str, + pub fill: Fill, +} + +/// What goes where a [`Detail`]'s pattern has `{}`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fill { + /// Nothing: the pattern is the whole sentence. + Nothing, + /// The changes made after signing, as [`Modification`] lists them: words of ours, each one in + /// [`kind::ALL`], said one after another. + Changes(Vec<&'static str>), + /// What a reader could not make of the signature, in its own words. It goes in as it stands: it + /// names the structure that is wrong, which is a developer's business and reads the same + /// everywhere. + Reason(String), + /// A value out of the signature itself — a time, the evidence a revocation came from — written + /// as it stands, the way a certificate's name or date is. + Value(String), + /// Two certificates' own names, for the wording that says one was not used to vouch for the + /// other. They go in the order the wording puts its two holes in, which a language may reverse. + Names(String, String), +} + +impl Detail { + /// The sentence said in the language `words` translates into. + /// + /// The wording is translated, and what fills it goes in afterwards — the changes translated too — + /// so nothing put in can be mistaken for a hole of its own. With [`in_english`] this is + /// [`message`](Detail::message). + pub fn said(&self, words: Words) -> String { + let said = words(self.pattern); + match &self.fill { + Fill::Nothing => said, + Fill::Changes(c) => said.replacen("{}", &c.iter().map(|k| words(k)).collect::>().join(", "), 1), + Fill::Reason(r) | Fill::Value(r) => said.replacen("{}", r, 1), + // Both holes are cut out of the wording first, so neither name can be taken for the other's. + Fill::Names(a, b) => { + let mut parts = said.splitn(3, "{}"); + match (parts.next(), parts.next(), parts.next()) { + (Some(p0), Some(p1), Some(p2)) => format!("{p0}{a}{p1}{b}{p2}"), + // A wording with one hole, or none: say it as it is rather than guess. + _ => said, + } + } + } + } + + /// The whole sentence, with no hole in it. + pub(crate) fn plain(pattern: &'static str) -> Detail { + Detail::of(pattern, Fill::Nothing) + } + + /// The sentence and what fills its hole. The English is said once, here, so it and + /// [`Detail::said`] cannot drift apart. + pub(crate) fn of(pattern: &'static str, fill: Fill) -> Detail { + let mut d = Detail { message: String::new(), pattern, fill }; + d.message = d.said(&in_english); + d + } +} + +/// How an interface says one of this crate's wordings in its own language: it is handed the English +/// and gives back what it reads. [`in_english`] is the one that keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// [`Words`] that leaves the English as it is. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// Every sentence this crate says about a signature: what [`SignatureInfo::details`] can hold, and +/// the summaries [`SignatureInfo::summary`] chooses between. +/// +/// An interface reading in another language translates these and the words in [`kind::ALL`], which +/// go into the two sentences that list changes. The rest of what it shows comes from elsewhere and +/// goes through as it stands: the certificate's names and dates, and a reader's own detail in +/// [`Fill::Reason`]. A test in this crate keeps the list in step with the code. +pub const WORDINGS: &[&str] = &[ + // What the signature's own structure says, before anything is verified. + "The signature has no valid byte range.", + "The signature's byte range does not match the file.", + "The signature's byte range does not exclude exactly its contents.", + "The signature contents are not hexadecimal.", + "The signature could not be read ({}).", + "PdfCraft can't check this signature yet: {}.", + "The signature uses the legacy adbe.x509.rsa_sha1 format.", + // What was read leniently on the way, each sentence worded where the reading happens. + "The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently.", + "The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway.", + "The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway.", + "The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway.", + "The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway.", + "The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway.", + "The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's.", + // What a signature that validates with an old hash still does not prove. + "This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on.", + "This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on.", + // Whether the signed bytes and the signature still agree. + "The document has been altered or corrupted since the signature was applied.", + "The signer's certificate is not in the signature.", + "The signature value does not match the signer's certificate: the signature is corrupt.", + "The signature could not be checked ({}).", + "The signature could not be verified ({}).", + // What came after the signature. + "This document has not been modified since this signature was applied.", + "The document has been modified since this signature was applied, but the changes are permitted ({}).", + "The document has been altered since this signature was applied in ways it does not permit ({}).", + // Who signed, and when. + "The signer's certificate was not valid at the time of signing.", + "The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation).", + "The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).", + "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", + // Why a certificate that matched the issuer was not used to build the chain. + "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", + "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", + "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", + "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", + "The signer's identity is valid.", + "The signature includes an embedded timestamp.", + "Signing time is from the clock on the signer's computer.", + // What the signature's own timestamp token proves, if it carries one. + "The signature's timestamp token does not cover the signature value.", + "The signature's timestamp token could not be verified ({}).", + "The embedded timestamp token is valid and its authority is trusted; trusted time is {}.", + "The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead.", + // What the document's own revocation evidence says about the signer. + "The signer's certificate has been revoked ({}).", + "The document's embedded revocation information shows that the signer's certificate has not been revoked.", + // A standalone document timestamp, which is checked on its own. + "The timestamp has no valid byte range.", + "The timestamp's byte range does not match the file.", + "The timestamp's byte range does not exclude exactly its contents.", + "The timestamp contents are not hexadecimal.", + "The timestamp token could not be read ({}).", + "The document has been altered or corrupted since the timestamp was applied.", + "The timestamp authority's certificate was not valid at the time of timestamping.", + "This document has not been modified since this timestamp was applied.", + "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", + "The document has been altered since this timestamp was applied in ways it does not permit ({}).", + "The timestamp token is valid and its authority is trusted.", + "The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates.", + // The one line that stands for all of it. + "Unsigned signature field", + "Signature is valid", + "Signature validity is unknown", + "Signature is invalid", +]; + /// One signature field. #[derive(Clone, Debug)] pub struct SignatureInfo { @@ -263,12 +446,14 @@ pub struct SignatureInfo { pub timestamp_time: Option