i18n: let the layers below hand their sentences over, so they can be translated (#478)
* i18n: let the layers below hand their sentences over, so they can be translated The accessibility findings, the PDF/A problems, the signature details, the page-range mistakes and the edit labels were English in all ten languages, because matching their wording from the UI with a tl! literal would break silently whenever the engine changed it. So each layer now hands the wording over instead, apart from the document's own words: a WORDINGS list of every sentence it can say, with holes in it, and said(&words), which looks the wording up through the closure it is given and fills the holes in a single pass. in_english changes nothing, for the CLI and the tests; the UI passes &i18n::words, a catalog lookup. A wording with no entry comes back as it stands, which is the English, so the nine catalogs this does not touch read exactly as before. a11y::report_html takes a language tag and a words closure, so a saved report reads the way the panel read, down to its lang attribute. print::PrintError gained a variant per thing a person can be wrong about, with the numbers apart from the words; faults rather than mistakes keep their own text. Spanish translates all of it (107 entries). spanish_covers_what_the_engine_says requires an entry per wording in the eight lists and spanish_keeps_the_holes_in_what_the_engine_says requires the holes to survive the translation, so the catalog cannot fall behind the engine unnoticed. No new tl! literal, so the Chinese, French and Russian UI-literal gates are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Sign tests from #1099: compare against the &'static str kinds * Camera tests: undo returns a Label now --------- Co-authored-by: Fernando Areán <soporte@codebit.es> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
116fa51262
commit
513a1ecc3a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
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<String>) -> Vec<Finding> {
|
||||
vec![Finding { page: None, message: message.into() }]
|
||||
fn doc_finding(sentence: &'static str) -> Vec<Finding> {
|
||||
vec![Finding::plain(None, sentence)]
|
||||
}
|
||||
|
||||
pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec<Finding> {
|
||||
|
|
@ -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())],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
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<usize>, 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<usize>, 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<usize>, 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 });
|
||||
|
|
|
|||
|
|
@ -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("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility Report</title>\n<style>\n");
|
||||
h.push_str(&format!("<!doctype html>\n<html lang=\"{}\"><head><meta charset=\"utf-8\"><title>{title}</title>\n<style>\n", esc(lang)));
|
||||
h.push_str(
|
||||
"body{font:14px/1.45 system-ui,sans-serif;margin:24px;color:#1d1d1f}h1{font-size:22px}h2{font-size:17px;margin-top:28px}\
|
||||
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:6px 8px;text-align:left;vertical-align:top}\
|
||||
th{background:#f2f2f2}.Failed{color:#b3261e;font-weight:600}.Passed{color:#1e7b34}ul{margin:4px 0 0;padding-left:18px}\n",
|
||||
);
|
||||
h.push_str("</style></head><body>\n<h1>Accessibility Report</h1>\n");
|
||||
h.push_str(&format!("<p>Filename: <b>{}</b><br>Report created: {}</p>\n", esc(file_name), esc(date)));
|
||||
h.push_str("<h2>Summary</h2>\n<p>");
|
||||
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!("</style></head><body>\n<h1>{title}</h1>\n"));
|
||||
h.push_str(&format!("<p>{}: <b>{}</b><br>{}: {}</p>\n", esc(&words("Filename")), esc(file_name), esc(&words("Report created")), esc(date)));
|
||||
h.push_str(&format!("<h2>{}</h2>\n<p>", 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("</p>\n<ul>\n");
|
||||
for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] {
|
||||
h.push_str(&format!("<li>{}: {}</li>\n", s.label(), report.count(s)));
|
||||
h.push_str(&format!("<li>{}: {}</li>\n", esc(&words(s.label())), report.count(s)));
|
||||
}
|
||||
h.push_str("</ul>\n<h2>Detailed Report</h2>\n");
|
||||
h.push_str(&format!("</ul>\n<h2>{}</h2>\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!("<h3>{}</h3>\n<table><tr><th>Rule Name</th><th>Status</th><th>Description</th></tr>\n", c.label()));
|
||||
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>{name}</th><th>{status}</th><th>{description}</th></tr>\n", esc(&words(c.label()))));
|
||||
for r in report.in_category(c) {
|
||||
h.push_str(&format!(
|
||||
"<tr><td>{}</td><td class=\"{:?}\">{}</td><td>{}",
|
||||
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("<ul>");
|
||||
for f in &r.findings {
|
||||
h.push_str(&format!("<li>{}</li>", esc(&f.message)));
|
||||
h.push_str(&format!("<li>{}</li>", esc(&f.said(words))));
|
||||
}
|
||||
h.push_str("</ul>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
|||
}
|
||||
}
|
||||
|
||||
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())])
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,9 +347,15 @@ fn rules_have_ids_and_the_report_lists_them() {
|
|||
let per: Vec<usize> = 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<b>.pdf", "2026-10-02");
|
||||
let report = check(&doc, &Options::default());
|
||||
let html = report_html(&report, "a<b>.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("<h3>Headings</h3>"));
|
||||
// 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<b>.pdf", "2026-10-02", "es", &|w| format!("«{w}»"));
|
||||
assert!(other.contains("<html lang=\"es\">") && other.contains("<h3>«Headings»</h3>") && 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<String> {
|
||||
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»»");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -393,13 +393,22 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<DocId>) -> 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<DocId>) -> 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<String>) -> 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<Vec<u8>>, 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<String, EditError> {
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
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<String, EditError> {
|
||||
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 0-based page, when the problem is on a page.
|
||||
pub page: Option<usize>,
|
||||
/// 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<Object>)])
|
|||
/// Check `doc` against `level`.
|
||||
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let mut issue = |clause: &'static str, message: String, page: Option<usize>, 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<String>, page: Option<usize>, 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<Issue> {
|
|||
};
|
||||
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<Issue> {
|
|||
&& 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<Issue> {
|
|||
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<Issue> {
|
|||
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<Issue> {
|
|||
out
|
||||
}
|
||||
|
||||
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, String, Option<usize>, bool)) {
|
||||
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ fn page_of(tok: &str, count: usize, labels: &[String]) -> Result<usize, PrintErr
|
|||
}
|
||||
match t.parse::<usize>() {
|
||||
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<Vec<usize>, 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<String> = ["A-1", "A-2", "A-3", "B-1", "B-2"].iter().map(|s| s.to_string()).collect();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>),
|
||||
/// Later revisions make changes it doesn't permit (listed).
|
||||
Disallowed(Vec<String>),
|
||||
/// 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::<Vec<_>>().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<Time>,
|
||||
pub status: Status,
|
||||
pub modification: Modification,
|
||||
/// Acrobat-style sentences explaining the verdict.
|
||||
pub details: Vec<String>,
|
||||
/// Acrobat-style sentences explaining the verdict, each one ready to read in English and ready to
|
||||
/// be said in another language (see [`Detail`]).
|
||||
pub details: Vec<Detail>,
|
||||
}
|
||||
|
||||
impl SignatureInfo {
|
||||
/// The one-line summary under the signature in the Signatures panel.
|
||||
/// The one-line summary under the signature in the Signatures panel, in English and listed in
|
||||
/// [`WORDINGS`] for an interface to say in its own language.
|
||||
pub fn summary(&self) -> &'static str {
|
||||
if !self.signed {
|
||||
return "Unsigned signature field";
|
||||
|
|
@ -507,23 +692,23 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
.map(|r| {
|
||||
r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8
|
||||
});
|
||||
let invalid = |info: &mut SignatureInfo, why: &str| {
|
||||
let invalid = |info: &mut SignatureInfo, why: Detail| {
|
||||
info.status = Status::Invalid;
|
||||
info.details.push(why.to_string());
|
||||
info.details.push(why);
|
||||
};
|
||||
// Byte ranges: [0 a b c] with the gap holding exactly the hex /Contents.
|
||||
let Some(br) = nums(doc, v, b"ByteRange").filter(|b| b.len() == 4 && b.iter().all(|x| *x >= 0.0)) else {
|
||||
return invalid(info, "The signature has no valid byte range.");
|
||||
return invalid(info, Detail::plain("The signature has no valid byte range."));
|
||||
};
|
||||
let [o0, l0, o1, l1] = [br[0] as usize, br[1] as usize, br[2] as usize, br[3] as usize];
|
||||
if o0 != 0 || o1 < l0 || o1.checked_add(l1).is_none_or(|end| end > bytes.len()) || o1 - l0 < 2 {
|
||||
return invalid(info, "The signature's byte range does not match the file.");
|
||||
return invalid(info, Detail::plain("The signature's byte range does not match the file."));
|
||||
}
|
||||
let gap = &bytes[l0..o1];
|
||||
if gap.first() != Some(&b'<') || gap.last() != Some(&b'>') {
|
||||
return invalid(info, "The signature's byte range does not exclude exactly its contents.");
|
||||
return invalid(info, Detail::plain("The signature's byte range does not exclude exactly its contents."));
|
||||
}
|
||||
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, "The signature contents are not hexadecimal.") };
|
||||
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, Detail::plain("The signature contents are not hexadecimal.")) };
|
||||
let covered = o1 + l1;
|
||||
info.signed_len = covered;
|
||||
// The signed revision's number: its cross-reference sections (1 for a reconstructed file).
|
||||
|
|
@ -532,7 +717,8 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
// is "invalid".
|
||||
let unsupported = |info: &mut SignatureInfo, why: &str| {
|
||||
info.status = Status::Unknown;
|
||||
info.details.push(format!("PdfCraft can't check this signature yet: {why}."));
|
||||
// What is not supported yet is named by the reader, in its own words.
|
||||
info.details.push(Detail::of("PdfCraft can't check this signature yet: {}.", Fill::Reason(why.to_owned())));
|
||||
};
|
||||
if info.sub_filter.as_deref() == Some("adbe.x509.rsa_sha1") {
|
||||
return validate_x509_rsa_sha1(doc, bytes, trust, v, info, cache, (l0, o1, covered), &contents);
|
||||
|
|
@ -540,10 +726,12 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
let sd = match SignedData::parse(&contents) {
|
||||
Ok(sd) => sd,
|
||||
Err(SignError::Unsupported(e)) => return unsupported(info, &e),
|
||||
Err(e) => return invalid(info, &format!("The signature could not be read ({e}).")),
|
||||
// What the reader made of it goes in as it stands: it is a developer's detail, not a sentence.
|
||||
Err(e) => return invalid(info, Detail::of("The signature could not be read ({}).", Fill::Reason(e.to_string()))),
|
||||
};
|
||||
let s = &sd.signer;
|
||||
info.details.extend(sd.quirks.iter().map(|q| q.to_string()));
|
||||
// A quirk is a whole sentence the reader words for itself, so it goes on the list in `WORDINGS`.
|
||||
info.details.extend(sd.quirks.iter().copied().map(Detail::plain));
|
||||
info.digest = Some(s.digest);
|
||||
info.timestamp = s.timestamp;
|
||||
let mut unverified_time = None;
|
||||
|
|
@ -552,7 +740,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
Ok(t) => {
|
||||
let imprint = t.digest.digest(&[&s.signature]);
|
||||
if imprint != t.imprint {
|
||||
info.details.push("The signature's timestamp token does not cover the signature value.".into());
|
||||
info.details.push(Detail::plain("The signature's timestamp token does not cover the signature value."));
|
||||
} else {
|
||||
// Only a token from a trusted authority (valid when it stamped) is trusted
|
||||
// time; otherwise its time is reported but validation uses the signer's
|
||||
|
|
@ -569,7 +757,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(e) => info.details.push(format!("The signature's timestamp token could not be verified ({e}).")),
|
||||
Err(e) => info.details.push(Detail::of("The signature's timestamp token could not be verified ({}).", Fill::Reason(e.to_string()))),
|
||||
}
|
||||
}
|
||||
// The digest the signature commits to.
|
||||
|
|
@ -577,7 +765,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
// adbe.pkcs7.sha1: the document's SHA-1 digest is the signed content.
|
||||
Some(c) => {
|
||||
if *c != cache.digest(DigestAlg::Sha1, bytes, l0, o1, covered) {
|
||||
return invalid(info, "The document has been altered or corrupted since the signature was applied.");
|
||||
return invalid(info, Detail::plain("The document has been altered or corrupted since the signature was applied."));
|
||||
}
|
||||
s.digest.digest(&[c])
|
||||
}
|
||||
|
|
@ -586,19 +774,20 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
if let Some(md) = &s.message_digest
|
||||
&& *md != content_digest
|
||||
{
|
||||
return invalid(info, "The document has been altered or corrupted since the signature was applied.");
|
||||
return invalid(info, Detail::plain("The document has been altered or corrupted since the signature was applied."));
|
||||
}
|
||||
let Some(cert) = sd.signer_certificate().cloned() else {
|
||||
return invalid(info, "The signer's certificate is not in the signature.");
|
||||
return invalid(info, Detail::plain("The signer's certificate is not in the signature."));
|
||||
};
|
||||
info.algorithm = Some(format!("{} with {}", cert.public_key.describe(), s.scheme_digest.unwrap_or(s.digest).name()));
|
||||
info.signer = Some(cert.display_name());
|
||||
let mut notes = Vec::new();
|
||||
match sd.verify_signature_noting(&cert, &content_digest, &mut notes) {
|
||||
Ok(true) => info.details.extend(notes.into_iter().map(String::from)),
|
||||
// A note is a whole sentence the reader words for itself, like a quirk.
|
||||
Ok(true) => info.details.extend(notes.into_iter().map(Detail::plain)),
|
||||
Ok(false) => {
|
||||
info.certificate = Some(cert);
|
||||
return invalid(info, "The signature value does not match the signer's certificate: the signature is corrupt.");
|
||||
return invalid(info, Detail::plain("The signature value does not match the signer's certificate: the signature is corrupt."));
|
||||
}
|
||||
Err(SignError::Unsupported(e)) => {
|
||||
info.certificate = Some(cert);
|
||||
|
|
@ -606,7 +795,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
}
|
||||
Err(e) => {
|
||||
info.certificate = Some(cert);
|
||||
return invalid(info, &format!("The signature could not be checked ({e})."));
|
||||
return invalid(info, Detail::of("The signature could not be checked ({}).", Fill::Reason(e.to_string())));
|
||||
}
|
||||
}
|
||||
finish_validation(doc, bytes, trust, info, cache, covered, cert, &sd.certificates, s.signing_time, unverified_time);
|
||||
|
|
@ -653,22 +842,28 @@ fn finish_validation(
|
|||
classify_changes(doc, cache.revision(bytes, covered), info.certify)
|
||||
};
|
||||
let mut problems = false;
|
||||
// The changes are handed over as the words they are, not stitched into the sentence, so an
|
||||
// interface can say both in its own language.
|
||||
match &info.modification {
|
||||
Modification::None => info.details.push("This document has not been modified since this signature was applied.".into()),
|
||||
Modification::Allowed(kinds) => info
|
||||
.details
|
||||
.push(format!("The document has been modified since this signature was applied, but the changes are permitted ({}).", kinds.join(", "))),
|
||||
Modification::None => info.details.push(Detail::plain("This document has not been modified since this signature was applied.")),
|
||||
Modification::Allowed(kinds) => info.details.push(Detail::of(
|
||||
"The document has been modified since this signature was applied, but the changes are permitted ({}).",
|
||||
Fill::Changes(kinds.clone()),
|
||||
)),
|
||||
Modification::Disallowed(kinds) => {
|
||||
let said = Detail::of(
|
||||
"The document has been altered since this signature was applied in ways it does not permit ({}).",
|
||||
Fill::Changes(kinds.clone()),
|
||||
);
|
||||
info.status = Status::Invalid;
|
||||
info.details
|
||||
.push(format!("The document has been altered since this signature was applied in ways it does not permit ({}).", kinds.join(", ")));
|
||||
info.details.push(said);
|
||||
problems = true;
|
||||
}
|
||||
}
|
||||
if let Some(t) = info.timestamp_time.or(info.signing_time)
|
||||
&& !cert.valid_at(t)
|
||||
{
|
||||
info.details.push("The signer's certificate was not valid at the time of signing.".into());
|
||||
info.details.push(Detail::plain("The signer's certificate was not valid at the time of signing."));
|
||||
// A verified revocation already made the signature invalid; keep it that way.
|
||||
if !problems && !revoked {
|
||||
info.status = Status::Unknown;
|
||||
|
|
@ -683,29 +878,30 @@ fn finish_validation(
|
|||
problems = true;
|
||||
}
|
||||
if !trusted {
|
||||
info.details.push(
|
||||
"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."
|
||||
.into(),
|
||||
);
|
||||
info.details.push(Detail::plain(
|
||||
"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.",
|
||||
));
|
||||
if !problems && !revoked {
|
||||
info.status = Status::Unknown;
|
||||
}
|
||||
} else {
|
||||
info.details.push("The signer's identity is valid.".into());
|
||||
info.details.push(Detail::plain("The signer's identity is valid."));
|
||||
if !problems && !revoked {
|
||||
info.status = Status::Valid;
|
||||
}
|
||||
}
|
||||
if info.timestamp {
|
||||
info.details.push("The signature includes an embedded timestamp.".into());
|
||||
info.details.push(Detail::plain("The signature includes an embedded timestamp."));
|
||||
} else {
|
||||
info.details.push("Signing time is from the clock on the signer's computer.".into());
|
||||
info.details.push(Detail::plain("Signing time is from the clock on the signer's computer."));
|
||||
}
|
||||
if let Some(t) = info.timestamp_time {
|
||||
info.details.push(format!("The embedded timestamp token is valid and its authority is trusted; trusted time is {t}."));
|
||||
info.details
|
||||
.push(Detail::of("The embedded timestamp token is valid and its authority is trusted; trusted time is {}.", Fill::Value(t.to_string())));
|
||||
} else if let Some(t) = unverified_time {
|
||||
info.details.push(format!(
|
||||
"The signature carries an unverified timestamp ({t}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead."
|
||||
info.details.push(Detail::of(
|
||||
"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.",
|
||||
Fill::Value(t.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -767,11 +963,14 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
|||
match revoked {
|
||||
Some(at) => {
|
||||
info.status = Status::Invalid;
|
||||
info.details.push(format!("The signer's certificate has been revoked ({}, {}).", sources.join(", "), at));
|
||||
// The evidence and the date go in together: one hole, so a reader can put the
|
||||
// sentence's parts in whatever order its language wants.
|
||||
info.details.push(Detail::of("The signer's certificate has been revoked ({}).", Fill::Value(format!("{}, {at}", sources.join(", ")))));
|
||||
true
|
||||
}
|
||||
None if covered => {
|
||||
info.details.push("The document's embedded revocation information shows that the signer's certificate has not been revoked.".into());
|
||||
info.details
|
||||
.push(Detail::plain("The document's embedded revocation information shows that the signer's certificate has not been revoked."));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
|
|
@ -782,42 +981,44 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
|||
/// cryptographically valid and its message imprint must cover the file's signed bytes.
|
||||
fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut SignatureInfo, cache: &DigestCache, trust: &TrustStore) {
|
||||
info.date = text(doc, v, b"M");
|
||||
let invalid = |info: &mut SignatureInfo, why: &str| {
|
||||
let invalid = |info: &mut SignatureInfo, why: Detail| {
|
||||
info.status = Status::Invalid;
|
||||
info.details.push(why.to_string());
|
||||
info.details.push(why);
|
||||
};
|
||||
let Some(br) = nums(doc, v, b"ByteRange").filter(|b| b.len() == 4 && b.iter().all(|x| *x >= 0.0)) else {
|
||||
return invalid(info, "The timestamp has no valid byte range.");
|
||||
return invalid(info, Detail::plain("The timestamp has no valid byte range."));
|
||||
};
|
||||
let [o0, l0, o1, l1] = [br[0] as usize, br[1] as usize, br[2] as usize, br[3] as usize];
|
||||
if o0 != 0 || o1 < l0 || o1.checked_add(l1).is_none_or(|end| end > bytes.len()) || o1 - l0 < 2 {
|
||||
return invalid(info, "The timestamp's byte range does not match the file.");
|
||||
return invalid(info, Detail::plain("The timestamp's byte range does not match the file."));
|
||||
}
|
||||
let gap = &bytes[l0..o1];
|
||||
if gap.first() != Some(&b'<') || gap.last() != Some(&b'>') {
|
||||
return invalid(info, "The timestamp's byte range does not exclude exactly its contents.");
|
||||
return invalid(info, Detail::plain("The timestamp's byte range does not exclude exactly its contents."));
|
||||
}
|
||||
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, "The timestamp contents are not hexadecimal.") };
|
||||
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else {
|
||||
return invalid(info, Detail::plain("The timestamp contents are not hexadecimal."));
|
||||
};
|
||||
let covered = o1 + l1;
|
||||
info.signed_len = covered;
|
||||
info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1));
|
||||
let token = match crate::timestamp::parse_token(&contents) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return invalid(info, &format!("The timestamp token could not be read ({e}).")),
|
||||
Err(e) => return invalid(info, Detail::of("The timestamp token could not be read ({}).", Fill::Reason(e.to_string()))),
|
||||
};
|
||||
info.digest = Some(token.digest);
|
||||
info.timestamp_time = Some(token.gen_time);
|
||||
info.algorithm = Some(format!("RFC 3161 timestamp ({})", token.digest.name()));
|
||||
let doc_digest = token.digest.digest(&[&bytes[..l0], &bytes[o1..covered]]);
|
||||
if doc_digest != token.imprint {
|
||||
return invalid(info, "The document has been altered or corrupted since the timestamp was applied.");
|
||||
return invalid(info, Detail::plain("The document has been altered or corrupted since the timestamp was applied."));
|
||||
}
|
||||
if let Some(cert) = token.signer_certificate() {
|
||||
info.signer = Some(cert.display_name());
|
||||
info.certificate = Some(cert.clone());
|
||||
if !cert.valid_at(token.gen_time) {
|
||||
info.status = Status::Unknown;
|
||||
info.details.push("The timestamp authority's certificate was not valid at the time of timestamping.".into());
|
||||
info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping."));
|
||||
}
|
||||
}
|
||||
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
|
||||
|
|
@ -826,14 +1027,17 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
|
|||
classify_changes(doc, cache.revision(bytes, covered), None)
|
||||
};
|
||||
match &info.modification {
|
||||
Modification::None => info.details.push("This document has not been modified since this timestamp was applied.".into()),
|
||||
Modification::Allowed(kinds) => info
|
||||
.details
|
||||
.push(format!("The document has been modified since this timestamp was applied, but the changes are permitted ({}).", kinds.join(", "))),
|
||||
Modification::None => info.details.push(Detail::plain("This document has not been modified since this timestamp was applied.")),
|
||||
Modification::Allowed(kinds) => info.details.push(Detail::of(
|
||||
"The document has been modified since this timestamp was applied, but the changes are permitted ({}).",
|
||||
Fill::Changes(kinds.clone()),
|
||||
)),
|
||||
Modification::Disallowed(kinds) => {
|
||||
info.status = Status::Invalid;
|
||||
info.details
|
||||
.push(format!("The document has been altered since this timestamp was applied in ways it does not permit ({}).", kinds.join(", ")));
|
||||
info.details.push(Detail::of(
|
||||
"The document has been altered since this timestamp was applied in ways it does not permit ({}).",
|
||||
Fill::Changes(kinds.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if info.status != Status::Invalid {
|
||||
|
|
@ -847,12 +1051,13 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
|
|||
let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x)));
|
||||
if trusted_tsa && in_validity {
|
||||
info.status = Status::Valid;
|
||||
info.details.push("The timestamp token is valid and its authority is trusted.".into());
|
||||
info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted."));
|
||||
} else if trusted_tsa {
|
||||
info.status = Status::Unknown;
|
||||
} else {
|
||||
info.status = Status::Unknown;
|
||||
info.details.push("The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates.".into());
|
||||
info.details
|
||||
.push(Detail::plain("The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -907,9 +1112,9 @@ fn validate_x509_rsa_sha1(
|
|||
(l0, o1, covered): (usize, usize, usize),
|
||||
contents: &[u8],
|
||||
) {
|
||||
let fail = |info: &mut SignatureInfo, why: &str| {
|
||||
let fail = |info: &mut SignatureInfo, why: Detail| {
|
||||
info.status = Status::Invalid;
|
||||
info.details.push(why.to_string());
|
||||
info.details.push(why);
|
||||
};
|
||||
let certs: Vec<Certificate> = match v.get(b"Cert").map(|c| doc.resolve(c)).as_deref() {
|
||||
Some(Object::Array(a)) => a.iter().filter_map(|c| doc.resolve(c).as_string().map(|s| s.bytes.clone())).collect::<Vec<_>>(),
|
||||
|
|
@ -919,10 +1124,11 @@ fn validate_x509_rsa_sha1(
|
|||
.iter()
|
||||
.filter_map(|raw| Certificate::parse(raw).ok())
|
||||
.collect();
|
||||
let Some(cert) = certs.first().cloned() else { return fail(info, "The signer's certificate is not in the signature.") };
|
||||
let Some(cert) = certs.first().cloned() else { return fail(info, Detail::plain("The signer's certificate is not in the signature.")) };
|
||||
let signature = match crate::der::Tlv::parse_ber(contents).and_then(|(t, _)| t.octets("signature").map(|o| o.into_owned())) {
|
||||
Ok(sig) => sig,
|
||||
Err(e) => return fail(info, &format!("The signature could not be read ({e}).")),
|
||||
// What the reader made of it goes in as it stands: it is a developer's detail, not a sentence.
|
||||
Err(e) => return fail(info, Detail::of("The signature could not be read ({}).", Fill::Reason(e.to_string()))),
|
||||
};
|
||||
info.digest = Some(DigestAlg::Sha1);
|
||||
info.algorithm = Some(format!("{} with SHA-1", cert.public_key.describe()));
|
||||
|
|
@ -930,26 +1136,28 @@ fn validate_x509_rsa_sha1(
|
|||
let digest = cache.digest(DigestAlg::Sha1, bytes, l0, o1, covered);
|
||||
let mut notes = Vec::new();
|
||||
match cert.public_key.verify_noting(crate::keys::Scheme::RsaPkcs1, DigestAlg::Sha1, &digest, &signature, &mut notes) {
|
||||
Ok(true) => info.details.extend(notes.into_iter().map(String::from)),
|
||||
// A note is a whole sentence the reader words for itself, so it goes on the list in `WORDINGS`.
|
||||
Ok(true) => info.details.extend(notes.into_iter().map(Detail::plain)),
|
||||
Ok(false) => {
|
||||
info.certificate = Some(cert);
|
||||
return fail(info, "The document has been altered or corrupted since the signature was applied.");
|
||||
return fail(info, Detail::plain("The document has been altered or corrupted since the signature was applied."));
|
||||
}
|
||||
// Only what PdfCraft doesn't support yet is "can't check"; a key or value that is
|
||||
// malformed is a signature that doesn't verify (as on the CMS path).
|
||||
Err(SignError::Unsupported(e)) => {
|
||||
info.certificate = Some(cert);
|
||||
info.status = Status::Unknown;
|
||||
info.details.push(format!("PdfCraft can't check this signature yet: {e}."));
|
||||
// What is not supported yet is named by the reader, in its own words.
|
||||
info.details.push(Detail::of("PdfCraft can't check this signature yet: {}.", Fill::Reason(e.to_string())));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
info.certificate = Some(cert);
|
||||
return fail(info, &format!("The signature could not be verified ({e})."));
|
||||
return fail(info, Detail::of("The signature could not be verified ({}).", Fill::Reason(e.to_string())));
|
||||
}
|
||||
}
|
||||
info.details.push("The signature uses the legacy adbe.x509.rsa_sha1 format.".into());
|
||||
info.details.extend(DigestAlg::Sha1.weakness().map(String::from));
|
||||
info.details.push(Detail::plain("The signature uses the legacy adbe.x509.rsa_sha1 format."));
|
||||
info.details.extend(DigestAlg::Sha1.weakness().map(Detail::plain));
|
||||
finish_validation(doc, bytes, trust, info, cache, covered, cert, &certs, None, None);
|
||||
}
|
||||
|
||||
|
|
@ -957,7 +1165,7 @@ fn validate_x509_rsa_sha1(
|
|||
/// an approval signature permits form fill, comments and further signatures).
|
||||
fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Modification {
|
||||
let Some(old) = old else {
|
||||
return Modification::Disallowed(vec!["the signed version could not be read".into()]);
|
||||
return Modification::Disallowed(vec![kind::UNREADABLE]);
|
||||
};
|
||||
let old_content: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old)
|
||||
.unwrap_or_default()
|
||||
|
|
@ -982,7 +1190,7 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
|||
// /Type and adding /ModDate, must not reclassify it away from page content).
|
||||
let signed_pages: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect();
|
||||
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref);
|
||||
let (mut allowed, mut disallowed): (Vec<&str>, Vec<&str>) = (Vec::new(), Vec::new());
|
||||
let (mut allowed, mut disallowed): (Vec<&'static str>, Vec<&'static str>) = (Vec::new(), Vec::new());
|
||||
let dss = dss_parts(doc, &old);
|
||||
// Stored at the same place in both (and not edited since): the same bytes, unchanged.
|
||||
let same_place = |num: u32| -> bool {
|
||||
|
|
@ -1008,9 +1216,9 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
|||
if before.as_deref() == Some(&*new) {
|
||||
continue;
|
||||
}
|
||||
let kind = match &*new {
|
||||
let what = match &*new {
|
||||
// The Document Security Store's own arrays and dictionaries, when they only grew.
|
||||
_ if dss.contains(&num) => "document security store",
|
||||
_ if dss.contains(&num) => kind::SECURITY_STORE,
|
||||
Object::Dict(d) => {
|
||||
let was = before.as_deref().and_then(Object::as_dict);
|
||||
if signed_pages.contains(&r) {
|
||||
|
|
@ -1019,7 +1227,7 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
|||
page_change_kind(doc, &old, d, was)
|
||||
} else if was.is_some() && Some(r) == old_info {
|
||||
// Only the signed revision's own /Info dictionary is a metadata change.
|
||||
"metadata"
|
||||
kind::METADATA
|
||||
} else {
|
||||
change_kind(doc, &old, d, was)
|
||||
}
|
||||
|
|
@ -1030,36 +1238,35 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
|||
// XObject relabelled /ObjStm still draws its new content).
|
||||
let was_plumbing = matches!(before.as_deref(), Some(Object::Stream(b)) if matches!(b.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")));
|
||||
if old_content.contains(&r) {
|
||||
"page content"
|
||||
kind::PAGE_CONTENT
|
||||
} else if before.is_none() || was_plumbing {
|
||||
continue;
|
||||
} else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata {
|
||||
// Only the signed catalog's XMP stream. A /Type /Metadata label on anything
|
||||
// else, even one it already had when signed (a page's Form XObject: the
|
||||
// renderer ignores /Type), does not make rewriting it a metadata change.
|
||||
"metadata"
|
||||
kind::METADATA
|
||||
} else {
|
||||
"other changes"
|
||||
kind::OTHER
|
||||
}
|
||||
}
|
||||
_ if before.is_none() => continue,
|
||||
_ => "other changes",
|
||||
_ => kind::OTHER,
|
||||
};
|
||||
let ok = match (kind, p) {
|
||||
("signature" | "document security store", _) => true,
|
||||
("form fill", Some(2 | 3) | None) => true,
|
||||
("comments", Some(3) | None) => true,
|
||||
let ok = match (what, p) {
|
||||
(kind::SIGNATURE | kind::SECURITY_STORE, _) => true,
|
||||
(kind::FORM_FILL, Some(2 | 3) | None) => true,
|
||||
(kind::COMMENTS, Some(3) | None) => true,
|
||||
// Every save updates /Info (ModDate), signing included.
|
||||
("metadata", _) => true,
|
||||
(kind::METADATA, _) => true,
|
||||
_ => false,
|
||||
};
|
||||
let list = if ok { &mut allowed } else { &mut disallowed };
|
||||
if !list.contains(&kind) {
|
||||
list.push(kind);
|
||||
if !list.contains(&what) {
|
||||
list.push(what);
|
||||
}
|
||||
}
|
||||
let own = |v: Vec<&str>| v.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||
if disallowed.is_empty() { Modification::Allowed(own(allowed)) } else { Modification::Disallowed(own(disallowed)) }
|
||||
if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) }
|
||||
}
|
||||
|
||||
/// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3).
|
||||
|
|
@ -1195,11 +1402,11 @@ fn added_annots(doc: &Document, added: &[ObjRef]) -> &'static str {
|
|||
})
|
||||
.collect();
|
||||
if !kinds.is_empty() && kinds.iter().all(|(w, s)| *w && *s) {
|
||||
"signature"
|
||||
kind::SIGNATURE
|
||||
} else if !kinds.is_empty() && kinds.iter().all(|(w, _)| *w) {
|
||||
"form fill"
|
||||
kind::FORM_FILL
|
||||
} else {
|
||||
"comments"
|
||||
kind::COMMENTS
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1225,11 +1432,11 @@ fn acroform_kind(doc: &Document, old: &Document, now: &Dict, before: Option<&Dic
|
|||
v
|
||||
};
|
||||
if strip(now) != strip(before) {
|
||||
return "form fill";
|
||||
return kind::FORM_FILL;
|
||||
}
|
||||
let old_fields = refs(old, before.get(b"Fields"));
|
||||
let added: Vec<ObjRef> = refs(doc, now.get(b"Fields")).into_iter().filter(|r| !old_fields.contains(r)).collect();
|
||||
if added.is_empty() || added_annots(doc, &added) == "signature" { "signature" } else { "form fill" }
|
||||
if added.is_empty() || added_annots(doc, &added) == kind::SIGNATURE { kind::SIGNATURE } else { kind::FORM_FILL }
|
||||
}
|
||||
|
||||
/// Dictionary entries in a stable order, without `keys`.
|
||||
|
|
@ -1251,13 +1458,13 @@ fn is_signature_dict(d: &Dict) -> bool {
|
|||
/// difference (content, resources, a relabelled or dropped `/Type`) is a page content change.
|
||||
fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
|
||||
match before {
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => "page content",
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT,
|
||||
Some(b) => {
|
||||
let was = refs(old, b.get(b"Annots"));
|
||||
let added: Vec<ObjRef> = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect();
|
||||
added_annots(doc, &added)
|
||||
}
|
||||
None => "page content",
|
||||
None => kind::PAGE_CONTENT,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1267,59 +1474,59 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
|
|||
// change is only a brand-new object, or one that was already a signature object when signed.
|
||||
let signature_ok = before.is_none_or(is_signature_dict);
|
||||
match ty {
|
||||
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if signature_ok => return "signature",
|
||||
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if signature_ok => return kind::SIGNATURE,
|
||||
Some(b"Catalog") => {
|
||||
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"];
|
||||
return match before {
|
||||
Some(b) if without(d, keys) != without(b, keys) => "document structure",
|
||||
Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE,
|
||||
Some(b) => {
|
||||
if d.get(b"AcroForm") != b.get(b"AcroForm") {
|
||||
// Compare the forms themselves, inline or not.
|
||||
let now = acroform(doc).unwrap_or_default();
|
||||
acroform_kind(doc, old, &now, None)
|
||||
} else if b.get(b"Perms") != d.get(b"Perms") {
|
||||
"signature"
|
||||
kind::SIGNATURE
|
||||
} else {
|
||||
"metadata"
|
||||
kind::METADATA
|
||||
}
|
||||
}
|
||||
None => "form fill",
|
||||
None => kind::FORM_FILL,
|
||||
};
|
||||
}
|
||||
Some(b"Page") => {
|
||||
return match before {
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => "page content",
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT,
|
||||
Some(b) => {
|
||||
let was = refs(old, b.get(b"Annots"));
|
||||
let added: Vec<ObjRef> = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect();
|
||||
added_annots(doc, &added)
|
||||
}
|
||||
None => "comments",
|
||||
None => kind::COMMENTS,
|
||||
};
|
||||
}
|
||||
Some(b"Pages") => return if before.is_some() { "pages added or removed" } else { "document structure" },
|
||||
Some(b"Pages") => return if before.is_some() { kind::PAGES } else { kind::STRUCTURE },
|
||||
_ => {}
|
||||
}
|
||||
match d.name(b"Subtype") {
|
||||
Some(b"Widget") => return if d.name(b"FT") == Some(b"Sig") && signature_ok { "signature" } else { "form fill" },
|
||||
Some(b"Link") => return "links",
|
||||
Some(_) if ty == Some(b"Annot") || d.contains(b"Rect") => return "comments",
|
||||
Some(b"Widget") => return if d.name(b"FT") == Some(b"Sig") && signature_ok { kind::SIGNATURE } else { kind::FORM_FILL },
|
||||
Some(b"Link") => return kind::LINKS,
|
||||
Some(_) if ty == Some(b"Annot") || d.contains(b"Rect") => return kind::COMMENTS,
|
||||
_ => {}
|
||||
}
|
||||
if d.name(b"FT") == Some(b"Sig") && signature_ok {
|
||||
return "signature";
|
||||
return kind::SIGNATURE;
|
||||
}
|
||||
if d.contains(b"Fields") {
|
||||
return acroform_kind(doc, old, d, before);
|
||||
}
|
||||
if d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")) || d.contains(b"DR") {
|
||||
return "form fill";
|
||||
return kind::FORM_FILL;
|
||||
}
|
||||
if before.is_none() {
|
||||
// New objects referenced by changed ones (fonts, appearance resources).
|
||||
return "form fill";
|
||||
return kind::FORM_FILL;
|
||||
}
|
||||
"other changes"
|
||||
kind::OTHER
|
||||
}
|
||||
|
||||
// ── signing ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1341,6 +1548,31 @@ impl Default for Appearance {
|
|||
}
|
||||
}
|
||||
|
||||
/// The words of the labels of a visible signature.
|
||||
///
|
||||
/// They are stamped into the document, so the caller chooses them: an interface that speaks
|
||||
/// another language passes them in that language. The defaults are the English ones.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AppearanceLabels {
|
||||
pub signed_by: String,
|
||||
pub distinguished_name: String,
|
||||
pub reason: String,
|
||||
pub location: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
impl Default for AppearanceLabels {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
signed_by: "Digitally signed by".into(),
|
||||
distinguished_name: "DN: ".into(),
|
||||
reason: "Reason: ".into(),
|
||||
location: "Location: ".into(),
|
||||
date: "Date: ".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SignOptions {
|
||||
/// Sign this existing, unsigned signature field.
|
||||
|
|
@ -1358,6 +1590,8 @@ pub struct SignOptions {
|
|||
/// Certify with these DocMDP permissions (1 no changes, 2 form fill and signing, 3 also comments).
|
||||
pub certify: Option<u8>,
|
||||
pub appearance: Appearance,
|
||||
/// The words of the labels the visible signature shows.
|
||||
pub appearance_labels: AppearanceLabels,
|
||||
/// A drawing, typed signature or image shown where the large name would be.
|
||||
pub graphic: Option<crate::graphic::Graphic>,
|
||||
}
|
||||
|
|
@ -1682,25 +1916,27 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
|
|||
let a = &opts.appearance;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
|
||||
// The labels are the caller's words, so a signature signed in another language reads in it.
|
||||
let wording = &opts.appearance_labels;
|
||||
if a.name {
|
||||
lines.push(if a.labels { "Digitally signed by".into() } else { String::new() });
|
||||
lines.push(if a.labels { wording.signed_by.clone() } else { String::new() });
|
||||
lines.push(name.to_string());
|
||||
}
|
||||
if a.distinguished_name {
|
||||
lines.push(label("DN: ", &cert.subject.display()));
|
||||
lines.push(label(&wording.distinguished_name, &cert.subject.display()));
|
||||
}
|
||||
if a.reason
|
||||
&& let Some(r) = opts.reason.as_deref().filter(|r| !r.is_empty())
|
||||
{
|
||||
lines.push(label("Reason: ", r));
|
||||
lines.push(label(&wording.reason, r));
|
||||
}
|
||||
if a.location
|
||||
&& let Some(l) = opts.location.as_deref().filter(|l| !l.is_empty())
|
||||
{
|
||||
lines.push(label("Location: ", l));
|
||||
lines.push(label(&wording.location, l));
|
||||
}
|
||||
if a.date {
|
||||
lines.push(label("Date: ", &display_date(&opts.date)));
|
||||
lines.push(label(&wording.date, &display_date(&opts.date)));
|
||||
}
|
||||
lines.retain(|l| !l.is_empty());
|
||||
let mut out = Vec::new();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use crate::SignError;
|
||||
use crate::der::{self, Time, Tlv, tag};
|
||||
use crate::keys::{self, DigestAlg, PrivateKey, PublicKey};
|
||||
use crate::pdf::{Detail, Fill};
|
||||
|
||||
const CN: &str = "2.5.4.3";
|
||||
const C: &str = "2.5.4.6";
|
||||
|
|
@ -355,18 +356,25 @@ impl Certificate {
|
|||
/// Why this certificate may not sign documents, if it may not (RFC 5280 §4.2.1.3,
|
||||
/// §4.2.1.12, §4.2): a key usage without digitalSignature or nonRepudiation, an extended key
|
||||
/// usage without a document-signing purpose, or a critical extension PdfCraft doesn't process.
|
||||
pub fn signing_problem(&self) -> Option<String> {
|
||||
/// The sentences are wordings of this crate's, listed in [`WORDINGS`](crate::pdf::WORDINGS), so an
|
||||
/// interface reading in another language says them in it; only the extensions' own OIDs go in as
|
||||
/// they stand.
|
||||
pub fn signing_problem(&self) -> Option<Detail> {
|
||||
/// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation).
|
||||
const SIGNING_BITS: u16 = 0b11;
|
||||
if self.key_usage.is_some_and(|u| u & SIGNING_BITS == 0) {
|
||||
Some(
|
||||
"The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)."
|
||||
.into(),
|
||||
)
|
||||
Some(Detail::plain(
|
||||
"The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation).",
|
||||
))
|
||||
} else if self.extended_key_usage.as_ref().is_some_and(|e| !e.iter().any(|o| DOCUMENT_SIGNING_EKUS.contains(&o.as_str()))) {
|
||||
Some("The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).".into())
|
||||
Some(Detail::plain(
|
||||
"The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).",
|
||||
))
|
||||
} else if !self.unknown_critical.is_empty() {
|
||||
Some(format!("The signer's certificate has critical extensions PdfCraft does not recognize ({}).", self.unknown_critical.join(", ")))
|
||||
Some(Detail::of(
|
||||
"The signer's certificate has critical extensions PdfCraft does not recognize ({}).",
|
||||
Fill::Value(self.unknown_critical.join(", ")),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
@ -481,7 +489,11 @@ pub fn build_chain<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: Optio
|
|||
|
||||
/// [`build_chain`], and why it stopped where it did if a certificate that matched the issuer by
|
||||
/// name and signature was refused.
|
||||
pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: Option<Time>) -> (Vec<&'a Certificate>, Option<String>) {
|
||||
///
|
||||
/// The reason is a whole sentence of ours, listed in [`WORDINGS`](crate::pdf::WORDINGS), with the two
|
||||
/// certificates' own names put in afterwards, so an interface reading in another language says it in
|
||||
/// it.
|
||||
pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: Option<Time>) -> (Vec<&'a Certificate>, Option<Detail>) {
|
||||
let mut chain = vec![leaf];
|
||||
let mut refused = None;
|
||||
while chain.len() < 10 {
|
||||
|
|
@ -493,12 +505,14 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at:
|
|||
let below = chain.len() - 1;
|
||||
let mut found = None;
|
||||
for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) {
|
||||
// Each reason is a whole sentence, not a phrase stitched into one: a language puts the
|
||||
// two names where it needs them.
|
||||
let why = if !c.may_issue() {
|
||||
Some("is not a CA certificate that may issue certificates")
|
||||
Some("The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.")
|
||||
} else if c.path_len.is_some_and(|n| below > n as usize) {
|
||||
Some("does not allow this many CA certificates below it (path length)")
|
||||
Some("The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.")
|
||||
} else if at.is_some_and(|t| !c.valid_at(t)) {
|
||||
Some("was not valid at the time of signing")
|
||||
Some("The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -507,9 +521,7 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at:
|
|||
found = Some(c);
|
||||
break;
|
||||
}
|
||||
Some(why) => {
|
||||
refused = Some(format!("The certificate of {} {why}, so it is not used to vouch for {}.", c.display_name(), last.display_name()))
|
||||
}
|
||||
Some(why) => refused = Some(Detail::of(why, Fill::Names(c.display_name(), last.display_name()))),
|
||||
}
|
||||
}
|
||||
let Some(issuer) = found else { break };
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ fn an_end_entity_certificate_cannot_issue() {
|
|||
let pool = [e.cert.clone(), r.cert.clone()];
|
||||
let (chain, why) = build_chain_noted(&forged.cert, &pool, None);
|
||||
assert_eq!(names(&chain), ["Bank AG"], "the subscriber certificate does not vouch for what it signed");
|
||||
assert!(why.is_some_and(|w| w.contains("not a CA")), "the reason is reported");
|
||||
assert!(why.is_some_and(|w| w.message.contains("not a CA")), "the reason is reported");
|
||||
// Neither does one without basicConstraints (not a v1 root: it has an issuer).
|
||||
let plain = issue("No Constraints", Some(&r), FOREVER, &[]);
|
||||
let under_plain = end_entity("Under Plain", &plain);
|
||||
|
|
@ -159,7 +159,7 @@ fn issuers_must_have_been_valid_when_it_matters() {
|
|||
assert_eq!(names(&build_chain(&l.cert, &pool, Some(time(2021)))), ["Leaf", "Short Lived CA", "Test Root"]);
|
||||
let (chain, why) = build_chain_noted(&l.cert, &pool, Some(time(2023)));
|
||||
assert_eq!(names(&chain), ["Leaf"], "expired by then");
|
||||
assert!(why.is_some_and(|w| w.contains("not valid")));
|
||||
assert!(why.is_some_and(|w| w.message.contains("not valid")));
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, Some(time(2019)))), ["Leaf"], "not yet valid");
|
||||
// With no time to judge by, nothing is refused on validity.
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)).len(), 3);
|
||||
|
|
@ -191,7 +191,9 @@ fn verdict(leaf: Party, embedded: Vec<Certificate>, anchor: &Certificate) -> (St
|
|||
let signed = pdfcraft_sign::sign(&doc, &id, &opts).unwrap();
|
||||
let trust = TrustStore { certs: vec![anchor.clone()] };
|
||||
let s = signatures(&Document::open(Arc::new(signed.clone())).unwrap(), &signed, &trust).into_iter().rfind(|s| s.signed).unwrap();
|
||||
(s.status, s.details)
|
||||
// The details as they read in English; what an interface says in another language is checked in
|
||||
// `tests/pdf.rs`.
|
||||
(s.status, s.details.iter().map(|d| d.message.clone()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -242,19 +244,19 @@ fn the_signer_certificate_must_allow_document_signing() {
|
|||
let r = root();
|
||||
let problem = |exts: &[Vec<u8>]| signer_with("Signer", &r, exts).cert.signing_problem();
|
||||
// Key usage without digitalSignature or nonRepudiation.
|
||||
assert!(problem(&[key_usage(KEY_CERT_SIGN)]).is_some_and(|w| w.contains("key usage")));
|
||||
assert!(problem(&[key_usage(KEY_CERT_SIGN)]).is_some_and(|w| w.message.contains("key usage")));
|
||||
assert!(problem(&[key_usage(KEY_ENCIPHERMENT)]).is_some());
|
||||
assert!(problem(&[key_usage(DIGITAL_SIGNATURE)]).is_none());
|
||||
// nonRepudiation alone is enough.
|
||||
assert!(problem(&[key_usage(0x40)]).is_none());
|
||||
// Extended key usage without a document-signing purpose.
|
||||
assert!(problem(&[extended_key_usage(&[SERVER_AUTH])]).is_some_and(|w| w.contains("extended key usage")));
|
||||
assert!(problem(&[extended_key_usage(&[SERVER_AUTH])]).is_some_and(|w| w.message.contains("extended key usage")));
|
||||
for ok in ["2.5.29.37.0", "1.3.6.1.5.5.7.3.4", "1.3.6.1.5.5.7.3.36", "1.2.840.113583.1.1.5", "1.3.6.1.4.1.311.10.3.12"] {
|
||||
assert!(problem(&[extended_key_usage(&[SERVER_AUTH, ok])]).is_none(), "{ok}");
|
||||
}
|
||||
// An unknown critical extension refuses; the same extension non-critical does not.
|
||||
let unknown = "1.2.3.4.5.6";
|
||||
assert!(problem(&[ext(unknown, &der::octets(&[0]))]).is_some_and(|w| w.contains(unknown)));
|
||||
assert!(problem(&[ext(unknown, &der::octets(&[0]))]).is_some_and(|w| w.message.contains(unknown)));
|
||||
assert!(problem(&[der::seq(&[&der::oid(unknown), &der::octets(&der::octets(&[0]))])]).is_none());
|
||||
// Critical extensions PdfCraft knows, or may accept as they are, don't refuse.
|
||||
assert!(problem(&[ext("2.5.29.32", &der::seq(&[]))]).is_none());
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental};
|
||||
use pdfcraft_sign::{Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, der, pkcs12, signatures};
|
||||
use pdfcraft_sign::{
|
||||
Detail, Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures,
|
||||
};
|
||||
|
||||
fn data(name: &str) -> Vec<u8> {
|
||||
std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
|
||||
|
|
@ -89,7 +91,7 @@ fn signing_then_validating_with_and_without_trust() {
|
|||
assert_eq!(s.signer.as_deref(), id.certificate.subject.common_name());
|
||||
assert_eq!((s.reason.as_deref(), s.location.as_deref(), s.page), (Some("I approve this document"), Some("London"), Some(0)));
|
||||
assert!(s.visible && s.signed_len == signed.len() && s.revision == 2);
|
||||
assert!(s.details.iter().any(|d| d.contains("identity is unknown")));
|
||||
assert!(s.details.iter().any(|d| d.message.contains("identity is unknown")));
|
||||
// The unsigned field is listed too.
|
||||
assert!(sigs.iter().any(|s| s.field == "Approval" && !s.signed));
|
||||
// Trusting the signer (or its root) makes it valid.
|
||||
|
|
@ -104,7 +106,7 @@ fn signing_then_validating_with_and_without_trust() {
|
|||
tampered[i] = b'K';
|
||||
let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid);
|
||||
assert!(s.details[0].contains("altered or corrupted"), "{:?}", s.details);
|
||||
assert!(s.details[0].message.contains("altered or corrupted"), "{:?}", s.details);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +122,7 @@ fn signing_an_existing_field_and_counter_signing() {
|
|||
assert!(a.signed);
|
||||
assert_eq!(a.rect, Some([150.0, 20.0, 280.0, 70.0]));
|
||||
assert_eq!(a.revision, 2);
|
||||
assert_eq!(a.modification, Modification::Allowed(vec!["signature".into()]), "{:?}", a.details);
|
||||
assert_eq!(a.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", a.details);
|
||||
assert_eq!(a.status, Status::Unknown);
|
||||
let b = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||||
assert_eq!((b.revision, b.modification.clone()), (3, Modification::None));
|
||||
|
|
@ -169,10 +171,10 @@ fn later_changes_are_classified_under_the_signature_permissions() {
|
|||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let s = check(&edit_after(&signed, add_comment));
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert_eq!(s.modification, Modification::Allowed(vec!["comments".into()]));
|
||||
assert_eq!(s.modification, Modification::Allowed(vec![kind::COMMENTS]));
|
||||
let s = check(&edit_after(&signed, change_text));
|
||||
assert_eq!(s.status, Status::Invalid);
|
||||
assert_eq!(s.modification, Modification::Disallowed(vec!["page content".into()]));
|
||||
assert_eq!(s.modification, Modification::Disallowed(vec![kind::PAGE_CONTENT]));
|
||||
// Certified with "no changes allowed": even a comment invalidates it.
|
||||
let certified = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap();
|
||||
let s = check(&certified);
|
||||
|
|
@ -335,13 +337,13 @@ fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() {
|
|||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert!(s.timestamp, "{:?}", s.details);
|
||||
assert_eq!(s.timestamp_time, Some(tsa.time), "the token's generation time, verified over the signature value");
|
||||
assert!(s.details.iter().any(|d| d.contains("trusted time")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("trusted time")), "{:?}", s.details);
|
||||
// A trusted signer with an untrusted TSA: still valid, but the token's time is unverified.
|
||||
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert!(s.timestamp);
|
||||
assert_eq!(s.timestamp_time, None);
|
||||
assert!(s.details.iter().any(|d| d.contains("unverified timestamp")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("unverified timestamp")), "{:?}", s.details);
|
||||
// Untrusted, the signature stays intact-but-unknown; the timestamp is still noted.
|
||||
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Unknown);
|
||||
|
|
@ -392,7 +394,7 @@ fn a_document_timestamp_covers_the_file_and_validates() {
|
|||
Modification::Allowed(k) => k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(allowed.contains(&"signature".to_string()), "{allowed:?}");
|
||||
assert!(allowed.contains(&kind::SIGNATURE), "{allowed:?}");
|
||||
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
|
||||
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
|
||||
}
|
||||
|
|
@ -464,7 +466,7 @@ fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() {
|
|||
Modification::Allowed(k) => k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(allowed.contains(&"document security store".to_string()), "{allowed:?}");
|
||||
assert!(allowed.contains(&kind::SECURITY_STORE), "{allowed:?}");
|
||||
// Embedding twice keeps one certificate (byte-identical dedup) and stays valid.
|
||||
let twice = dss::embed(&doc, &evidence).unwrap();
|
||||
let doc2 = open(&twice);
|
||||
|
|
@ -493,8 +495,8 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() {
|
|||
Modification::Allowed(k) => k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(allowed.contains(&"document security store".to_string()) && allowed.contains(&"signature".to_string()), "{allowed:?}");
|
||||
assert!(!allowed.contains(&"page content".to_string()), "{allowed:?}");
|
||||
assert!(allowed.contains(&kind::SECURITY_STORE) && allowed.contains(&kind::SIGNATURE), "{allowed:?}");
|
||||
assert!(!allowed.contains(&kind::PAGE_CONTENT), "{allowed:?}");
|
||||
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
|
||||
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
|
||||
}
|
||||
|
|
@ -525,7 +527,7 @@ fn an_embedded_verified_revocation_invalidates_the_signature() {
|
|||
let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap();
|
||||
let s = signatures(&open(<v), <v, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("revoked") && d.contains("CRL")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("revoked") && d.message.contains("CRL")), "{:?}", s.details);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -544,7 +546,7 @@ fn validates_a_signature_made_by_openssl() {
|
|||
// A later comment is allowed for this approval signature.
|
||||
let edited = edit_after(&bytes, add_comment);
|
||||
let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().next().unwrap();
|
||||
assert_eq!(s.modification, Modification::Allowed(vec!["comments".into()]));
|
||||
assert_eq!(s.modification, Modification::Allowed(vec![kind::COMMENTS]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -689,7 +691,7 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() {
|
|||
assert_eq!(check(&signed).status, Status::Valid);
|
||||
let s = check(&edit_after(&signed, xfa_page_swap));
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"document structure".to_string())), "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::STRUCTURE)), "{:?}", s.modification);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +734,7 @@ fn validates_signatures_written_with_ber_indefinite_lengths() {
|
|||
let anchor = id.certificate.clone();
|
||||
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{file}: {:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("BER")), "the tolerance is reported: {:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("BER")), "the tolerance is reported: {:?}", s.details);
|
||||
// Tampering is still caught: the digest check is not relaxed.
|
||||
let mut tampered = signed.clone();
|
||||
let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
|
||||
|
|
@ -850,7 +852,7 @@ fn assert_refused_at_every_level(base: &[u8], kind: &str, attack: impl Fn(&mut D
|
|||
assert_eq!(check(&signed).status, Status::Valid, "{certify:?}: untouched");
|
||||
let s = check(&edit_after(&signed, &attack));
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?} {:?}", s.modification, s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.iter().any(|k| k == kind)), "{certify:?}: {:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind)), "{certify:?}: {:?}", s.modification);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1016,7 +1018,7 @@ fn the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing() {
|
|||
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"<x:xmpmeta />".to_vec())));
|
||||
});
|
||||
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.iter().any(|k| k == "metadata")), "{certify:?}: {:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::METADATA)), "{certify:?}: {:?}", s.modification);
|
||||
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?}", s.details);
|
||||
}
|
||||
}
|
||||
|
|
@ -1060,14 +1062,14 @@ fn a_signed_store_may_grow_but_not_lose_or_swap_entries() {
|
|||
});
|
||||
let s = check(&grown);
|
||||
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?} {:?}", s.modification, s.details);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.iter().any(|k| k == "document security store")), "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SECURITY_STORE)), "{:?}", s.modification);
|
||||
// Dropping what was signed is a change, not an addition.
|
||||
let dropped = edit_after(&signed, |doc| {
|
||||
doc.set(pdfcraft_cos::ObjRef { num: certs, generation: 0 }, Object::Array(vec![]));
|
||||
});
|
||||
let s = check(&dropped);
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.iter().any(|k| k == "other changes")), "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::OTHER)), "{:?}", s.modification);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1088,7 +1090,7 @@ fn a_form_xobject_relabelled_metadata_is_not_a_metadata_change() {
|
|||
let edited = edit_after(&signed, relabel);
|
||||
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&"metadata".to_string())), "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&kind::METADATA)), "{:?}", s.modification);
|
||||
// The catalog's own XMP stream may still be updated.
|
||||
let with_xmp = edit_after(&fixture_with_xobject(), |doc| {
|
||||
let xmp = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>".to_vec();
|
||||
|
|
@ -1113,7 +1115,7 @@ fn a_form_xobject_relabelled_metadata_is_not_a_metadata_change() {
|
|||
});
|
||||
let s = signatures(&open(&rewrite_xmp), &rewrite_xmp, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert_eq!(s.modification, Modification::Allowed(vec!["metadata".into()]));
|
||||
assert_eq!(s.modification, Modification::Allowed(vec!["metadata"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1137,7 +1139,7 @@ fn a_form_xobject_relabelled_as_file_plumbing_is_still_judged() {
|
|||
});
|
||||
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?} /{label}: {:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.iter().any(|k| k == "other changes")), "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1210,7 +1212,7 @@ fn a_signed_page_relabelled_or_stripped_of_type_is_still_page_content() {
|
|||
});
|
||||
let s = signatures(&open(&bumped), &bumped, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.iter().any(|k| k == "metadata")), "{certify:?}: {:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&"metadata")), "{certify:?}: {:?}", s.modification);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1242,9 +1244,9 @@ fn a_revoked_certificate_stays_invalid_when_it_was_also_expired_at_signing() {
|
|||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
for trust in [TrustStore::default(), TrustStore { certs: vec![anchor] }] {
|
||||
let s = signatures(&open(<v), <v, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||||
assert!(s.details.iter().any(|d| d.contains("not valid at the time of signing")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("not valid at the time of signing")), "{:?}", s.details);
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("revoked")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("revoked")), "{:?}", s.details);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1263,9 +1265,9 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() {
|
|||
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor.clone()] }).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_ne!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert_eq!(s.timestamp_time, None, "an untrusted token's time is not a trusted time");
|
||||
assert!(s.details.iter().any(|d| d.contains("not valid at the time of signing")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.contains("trusted time")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("unverified timestamp")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("not valid at the time of signing")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.message.contains("trusted time")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("unverified timestamp")), "{:?}", s.details);
|
||||
// Trusting the TSA makes its time authoritative: the certificate was valid then.
|
||||
let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] };
|
||||
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
|
|
@ -1493,14 +1495,14 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() {
|
|||
// Without trusting the TSA the stamp is intact but unknown, never "altered".
|
||||
let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.contains("altered or corrupted")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.message.contains("altered or corrupted")), "{:?}", s.details);
|
||||
// A changed byte in what it covers is still caught, and reported as the timestamp's.
|
||||
let mut tampered = stamped.clone();
|
||||
let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
|
||||
tampered[i] = b'K';
|
||||
let s = signatures(&open(&tampered), &tampered, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid);
|
||||
assert!(s.details.iter().any(|d| d.contains("since the timestamp was applied")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("since the timestamp was applied")), "{:?}", s.details);
|
||||
}
|
||||
|
||||
/// A trusted timestamp authority whose certificate was not yet valid when it stamped proves
|
||||
|
|
@ -1517,8 +1519,8 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() {
|
|||
let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert!(s.doc_timestamp);
|
||||
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("was not valid at the time of timestamping")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.contains("its authority is trusted")), "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("was not valid at the time of timestamping")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.message.contains("its authority is trusted")), "{:?}", s.details);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1550,7 +1552,7 @@ fn a_legacy_x509_signature_with_a_malformed_rsa_key_is_invalid() {
|
|||
let s = signatures(&open(&bad), &bad, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.sub_filter.as_deref(), Some("adbe.x509.rsa_sha1"));
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.contains("can't check this signature yet")), "{:?}", s.details);
|
||||
assert!(!s.details.iter().any(|d| d.message.contains("can't check this signature yet")), "{:?}", s.details);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1566,8 +1568,8 @@ fn validates_the_legacy_adbe_x509_rsa_sha1_format() {
|
|||
assert_eq!(s.modification, Modification::None);
|
||||
// SHA-1 still validates, but the panel says it is weak.
|
||||
let weak = "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.";
|
||||
assert_eq!(s.details.iter().filter(|d| *d == weak).count(), 1, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.contains("legacy adbe.x509.rsa_sha1 format")), "{:?}", s.details);
|
||||
assert_eq!(s.details.iter().filter(|d| d.message == weak).count(), 1, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("legacy adbe.x509.rsa_sha1 format")), "{:?}", s.details);
|
||||
let trusted = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let s = signatures(&open(&pdf), &pdf, &trusted).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
|
|
@ -1641,7 +1643,7 @@ fn encrypted_documents_are_signed_with_an_unencrypted_contents() {
|
|||
let sigs = signatures(&open_pw(&both, None), &both, &trust);
|
||||
let first = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||||
assert_eq!(first.status, Status::Valid, "{alg:?}: {:?}", first.details);
|
||||
assert_eq!(first.modification, Modification::Allowed(vec!["signature".into()]), "{alg:?}: {:?}", first.details);
|
||||
assert_eq!(first.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{alg:?}: {:?}", first.details);
|
||||
let second = sigs.iter().find(|s| s.field == "Approval").unwrap();
|
||||
assert_eq!((second.status, second.revision), (Status::Valid, 3), "{alg:?}: {:?}", second.details);
|
||||
// The page text is still encrypted, and tampering with its ciphertext breaks both.
|
||||
|
|
@ -1735,3 +1737,89 @@ fn encrypted_documents_take_a_document_timestamp() {
|
|||
let print_only = open_pw(&protected(pdfcraft_cos::Algorithm::Aes128, -3900), None);
|
||||
assert!(matches!(pdfcraft_sign::timestamp_document(&print_only, &tsa, "D:20261006120000Z"), Err(SignError::Pdf(_))));
|
||||
}
|
||||
|
||||
/// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on
|
||||
/// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the
|
||||
/// sentences the modules that read a signature word for themselves are pushed as notes and quirks,
|
||||
/// and the summaries are the only words `summary` chooses between, so all of them can be read off
|
||||
/// the files. An interface then has these to translate and only these.
|
||||
#[test]
|
||||
fn wordings_are_complete() {
|
||||
let source = include_str!("../src/pdf.rs");
|
||||
// The modules below `pdf` word whole sentences too: how a signature value had to be read, and
|
||||
// what a certificate does not allow.
|
||||
let elsewhere =
|
||||
[include_str!("../src/x509.rs"), include_str!("../src/cms.rs"), include_str!("../src/keys.rs"), include_str!("../src/rsa_pad.rs")];
|
||||
/// A sentence said to the user, as opposed to an OID, a name or a tag: it reads as English.
|
||||
fn is_sentence(s: &str) -> bool {
|
||||
s.len() > 24 && s.ends_with('.') && s.contains(' ') && s.starts_with(|c: char| c.is_ascii_uppercase())
|
||||
}
|
||||
// The literals of a stretch of the file, each as it is written there.
|
||||
fn literals(text: &str) -> Vec<&str> {
|
||||
let (mut out, mut rest) = (Vec::new(), text);
|
||||
while let Some(at) = rest.find('"') {
|
||||
rest = &rest[at + 1..];
|
||||
let Some(end) = rest.find('"') else { break };
|
||||
out.push(&rest[..end]);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The literals written right after each of `shapes`, wherever they appear in `text`.
|
||||
fn after<'a>(text: &'a str, shapes: &[&str]) -> Vec<&'a str> {
|
||||
let mut out = Vec::new();
|
||||
for shape in shapes {
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find(shape) {
|
||||
rest = &rest[at + shape.len()..];
|
||||
// A long sentence is written on the line below, and `plain` passes its own on.
|
||||
let Some(text) = rest.trim_start().strip_prefix('"') else { continue };
|
||||
let Some(end) = text.find('"') else { break };
|
||||
out.push(&text[..end]);
|
||||
rest = text;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let mut said = after(source, &["Detail::plain(", "Detail::of("]);
|
||||
let at_hand = ["Detail::plain(", "Detail::of(", ".push(", "Some("];
|
||||
said.extend(elsewhere.iter().flat_map(|s| after(s, &at_hand)).filter(|s| is_sentence(s)));
|
||||
let summary = source.split("pub fn summary").nth(1).and_then(|s| s.split("\n }").next()).unwrap_or_default();
|
||||
said.extend(literals(summary));
|
||||
assert!(said.len() >= 40, "the sentences are not being read off the files: {said:#?}");
|
||||
let missing: Vec<&&str> = said.iter().filter(|s| !WORDINGS.contains(s)).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(|s| !said.contains(s)).collect();
|
||||
assert!(unsaid.is_empty(), "WORDINGS lists sentences this crate no longer says: {unsaid:#?}");
|
||||
// And every word it calls a change by is in `kind::ALL`, which is what an interface translates.
|
||||
let vocabulary = source.split("pub mod kind {").nth(1).and_then(|s| s.split("\n}").next()).unwrap_or_default();
|
||||
let words = literals(vocabulary);
|
||||
assert!(words.len() >= 11, "the vocabulary is not being read off the file: {words:#?}");
|
||||
let missing: Vec<&&str> = words.iter().filter(|w| !kind::ALL.contains(w)).collect();
|
||||
assert!(missing.is_empty(), "add these to kind::ALL, and a translation of each to every interface: {missing:#?}");
|
||||
let gone: Vec<&&str> = kind::ALL.iter().filter(|w| !words.contains(w)).collect();
|
||||
assert!(gone.is_empty(), "kind::ALL lists words that are no longer there: {gone:#?}");
|
||||
}
|
||||
|
||||
/// Each detail says itself: in English it comes to the sentence it carries, and its wording is one an
|
||||
/// interface can look up. This is what the Signatures panel relies on to read in another language.
|
||||
#[test]
|
||||
fn details_say_themselves() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let sigs = signatures(&open(&signed), &signed, &TrustStore::default());
|
||||
let details: Vec<&Detail> = sigs.iter().flat_map(|s| s.details.iter()).collect();
|
||||
assert!(!details.is_empty());
|
||||
for d in details {
|
||||
assert_eq!(d.said(&in_english), d.message);
|
||||
assert!(WORDINGS.contains(&d.pattern), "{:?} is not in WORDINGS", d.pattern);
|
||||
// A wording has exactly as many holes as its fill has things to put in them.
|
||||
let holes = d.pattern.matches("{}").count();
|
||||
let wanted = match &d.fill {
|
||||
pdfcraft_sign::Fill::Nothing => 0,
|
||||
pdfcraft_sign::Fill::Names(..) => 2,
|
||||
_ => 1,
|
||||
};
|
||||
assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,7 +287,10 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D
|
|||
egui::Frame::NONE.inner_margin(egui::Margin { left: 46, right: 4, top: 0, bottom: 6 }).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
for f in &r.findings {
|
||||
let l = ui.add(egui::Label::new(egui::RichText::new(&f.message).small().color(t.text_muted)).sense(egui::Sense::click()));
|
||||
// The checker hands over the sentence and the document's own words apart, so
|
||||
// the sentence is said in the panel's language with those words still in it.
|
||||
let said = f.said(&crate::i18n::words);
|
||||
let l = ui.add(egui::Label::new(egui::RichText::new(said).small().color(t.text_muted)).sense(egui::Sense::click()));
|
||||
if let Some(p) = f.page
|
||||
&& l.on_hover_text(tl!("Go to the page")).clicked()
|
||||
{
|
||||
|
|
@ -356,7 +359,9 @@ impl PdfCraftApp {
|
|||
let Some((_, report)) = self.a11y.report.as_ref().filter(|(d, _)| *d == id) else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let (y, m, d) = self.session.today();
|
||||
let html = pdfcraft_engine::a11y::report_html(report, &doc.name, &format!("{y}-{m:02}-{d:02}"));
|
||||
// The saved report reads the way the interface reads, down to its `lang` attribute.
|
||||
let lang = crate::i18n::current();
|
||||
let html = pdfcraft_engine::a11y::report_html(report, &doc.name, &format!("{y}-{m:02}-{d:02}"), lang.code(), &crate::i18n::words);
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let title = tl!("Save the accessibility report").to_string();
|
||||
self.write_files(&[(format!("{stem} Accessibility Report.html"), std::sync::Arc::new(html.into_bytes()))], &title);
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ impl PdfCraftApp {
|
|||
}
|
||||
Err(e) => {
|
||||
let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() };
|
||||
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &message)]);
|
||||
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ impl PdfCraftApp {
|
|||
self.views[i].document_changed(&doc.info);
|
||||
}
|
||||
self.views[i].comments.selected = None;
|
||||
let label = crate::i18n::action_label(&label);
|
||||
let label = crate::i18n::edit_label(&label);
|
||||
if undo {
|
||||
self.notify_fmt("Undid {label}", &[("label", &label)]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1834,6 +1834,8 @@
|
|||
The document is encrypted El documento está cifrado
|
||||
Device colour is used, but there is no PDF/A output intent Se usa color de dispositivo, pero no hay propósito de salida PDF/A
|
||||
DeviceCMYK is used: it needs a CMYK output intent (not added automatically) Se usa DeviceCMYK: necesita un propósito de salida CMYK (no se añade automáticamente)
|
||||
DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile Se usa DeviceCMYK, pero el propósito de salida PDF/A no es un perfil CMYK
|
||||
DeviceRGB is used, but the PDF/A output intent isn't an RGB profile Se usa DeviceRGB, pero el propósito de salida PDF/A no es un perfil RGB
|
||||
The document has additional actions (/AA) El documento tiene acciones adicionales (/AA)
|
||||
The document has document-level JavaScript El documento tiene JavaScript de nivel de documento
|
||||
The form asks viewers to make appearances (NeedAppearances) El formulario pide a los visores que generen las apariencias (NeedAppearances)
|
||||
|
|
@ -1911,6 +1913,7 @@
|
|||
Delete link Eliminar enlace
|
||||
Create link Crear enlace
|
||||
Flatten Acoplar
|
||||
Flatten Fill & Sign Acoplar Rellenar y firmar
|
||||
Add sticky note Añadir nota adhesiva
|
||||
Add highlight Añadir resaltado
|
||||
Add underline Añadir subrayado
|
||||
|
|
@ -2040,8 +2043,8 @@
|
|||
No changes to save No hay cambios que guardar
|
||||
Created a PDF from {n} files; left out: {names} Se creó un PDF a partir de {n} archivos; omitidos: {names}
|
||||
Created a PDF from the first {n} files Se creó un PDF a partir de los primeros {n} archivos
|
||||
None of those files can be made into a PDF; use PDFs, images or .txt files Ninguno de esos archivos se puede convertir en PDF; use archivos PDF, imágenes o .txt
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
|
||||
None of those files can be made into a PDF; use PDFs, images or .txt files Ninguno de esos archivos se puede convertir en PDF; usa archivos PDF, imágenes o .txt
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elige una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
|
||||
Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
|
||||
Go to page… Ir a la página…
|
||||
For page sizes and margins. Para tamaños de página y márgenes.
|
||||
|
|
@ -2092,6 +2095,148 @@
|
|||
A selected field has no widget on any page, so the fields can't be edited together. Un campo seleccionado no tiene ningún widget en ninguna página, así que los campos no se pueden editar juntos.
|
||||
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} ppp, el mayor tamaño al que se pueden dibujar)
|
||||
|
||||
# What the layers below word for themselves, now that the engine hands the wording over apart from
|
||||
# the document's own words: `pdfcraft_a11y::{WORDINGS, REPORT_WORDS}`, `pdfcraft_preflight::WORDINGS`,
|
||||
# `pdfcraft_sign::pdf::{WORDINGS, kind::ALL}`, `pdfcraft_print::WORDINGS` and the engine's
|
||||
# `EDIT_LABELS`. `spanish_covers_what_the_engine_says` keeps this section complete.
|
||||
#
|
||||
# What the checker finds (a11y::WORDINGS).
|
||||
The security settings don't allow screen readers to read the text La configuración de seguridad no permite que los lectores de pantalla lean el texto
|
||||
No page has text: the document appears to be images of pages Ninguna página tiene texto: el documento parece ser imágenes de páginas
|
||||
The document has no tags (structure tree) El documento no tiene etiquetas (árbol de estructura)
|
||||
The document isn't marked as tagged (MarkInfo Marked) El documento no está marcado como etiquetado (MarkInfo Marked)
|
||||
No document language is set No se ha indicado el idioma del documento
|
||||
The document has no title El documento no tiene título
|
||||
The window shows the file name, not the title (Initial View > Show) La ventana muestra el nombre del archivo, no el título (Vista inicial > Mostrar)
|
||||
{n} pages and no bookmarks {n} páginas y ningún marcador
|
||||
The document isn't tagged, so none of its content is El documento no está etiquetado, así que su contenido tampoco lo está
|
||||
Page {p}: {n} untagged content item Página {p}: {n} elemento de contenido sin etiquetar
|
||||
Page {p}: {n} untagged content items Página {p}: {n} elementos de contenido sin etiquetar
|
||||
Form field on page {p} isn't tagged Un campo de formulario de la página {p} no está etiquetado
|
||||
{} annotation on page {p} isn't tagged Una anotación {} de la página {p} no está etiquetada
|
||||
Page {p} doesn't tab in structure order La página {p} no se tabula en el orden de la estructura
|
||||
Font {} on page {p} doesn't map its characters to Unicode La fuente {} de la página {p} no asigna sus caracteres a Unicode
|
||||
Font (unnamed) on page {p} doesn't map its characters to Unicode Una fuente sin nombre de la página {p} no asigna sus caracteres a Unicode
|
||||
Field "{}" on page {p} has no description (tooltip) El campo «{}» de la página {p} no tiene descripción (información sobre herramientas)
|
||||
{ty} element: {what} Elemento {ty}: {what}
|
||||
{ty} element on page {p}: {what} Elemento {ty} de la página {p}: {what}
|
||||
no alternate text sin texto alternativo
|
||||
its alternate text is inside another element's and will never be read su texto alternativo está dentro del de otro elemento y nunca se leerá
|
||||
alternate text on an element with no page content texto alternativo en un elemento sin contenido de página
|
||||
alternate text hides an annotation inside it el texto alternativo oculta una anotación que hay dentro
|
||||
not in a Table, THead, TBody or TFoot no está dentro de un Table, THead, TBody ni TFoot
|
||||
not in a TR no está dentro de un TR
|
||||
no header cells (TH) sin celdas de encabezado (TH)
|
||||
rows have between {min} and {max} columns las filas tienen entre {min} y {max} columnas
|
||||
no summary sin resumen
|
||||
not in an L (list) no está dentro de una L (lista)
|
||||
not in an LI (list item) no está dentro de un LI (elemento de lista)
|
||||
the first heading is an H{n}; headings should not skip levels el primer encabezado es un H{n}; los encabezados no deben saltarse niveles
|
||||
H{n} follows an H{d}; headings should not skip levels un H{n} va después de un H{d}; los encabezados no deben saltarse niveles
|
||||
… and {n} more … y {n} más
|
||||
# The saved accessibility report (a11y::REPORT_WORDS).
|
||||
Accessibility Report Informe de accesibilidad
|
||||
Filename Nombre del archivo
|
||||
Report created Informe creado
|
||||
The checker found problems which may prevent the document from being fully accessible. El comprobador ha encontrado problemas que pueden impedir que el documento sea del todo accesible.
|
||||
Detailed Report Informe detallado
|
||||
Rule Name Nombre de la regla
|
||||
# What the PDF/A verifier reports (preflight::WORDINGS).
|
||||
The metadata doesn't identify the file as {} Los metadatos no identifican el archivo como {}
|
||||
Page {} has additional actions La página {} tiene acciones adicionales
|
||||
A {} action isn't allowed No se permite una acción {}
|
||||
{} annotations aren't allowed No se permiten las anotaciones {}
|
||||
A {} annotation isn't set to print, or is hidden Una anotación {} no está marcada para imprimirse, o está oculta
|
||||
A {} annotation has no appearance stream Una anotación {} no tiene flujo de apariencia
|
||||
A {} annotation has additional actions Una anotación {} tiene acciones adicionales
|
||||
The font {} isn't embedded La fuente {} no está incrustada
|
||||
A font isn't embedded Hay una fuente que no está incrustada
|
||||
# What a signature's details say (sign::pdf::WORDINGS).
|
||||
The signature has no valid byte range. La firma no tiene un intervalo de bytes válido.
|
||||
The signature's byte range does not match the file. El intervalo de bytes de la firma no se corresponde con el archivo.
|
||||
The signature's byte range does not exclude exactly its contents. El intervalo de bytes de la firma no excluye exactamente su contenido.
|
||||
The signature contents are not hexadecimal. El contenido de la firma no es hexadecimal.
|
||||
The signature could not be read ({}). No se ha podido leer la firma ({}).
|
||||
PdfCraft can't check this signature yet: {}. PdfCraft todavía no puede comprobar esta firma: {}.
|
||||
The signature uses the legacy adbe.x509.rsa_sha1 format. La firma usa el formato antiguo adbe.x509.rsa_sha1.
|
||||
# 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. La firma está codificada en BER (longitudes indefinidas) en vez de en DER; se ha leído con indulgencia.
|
||||
The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. El contenido firmado es un OCTET STRING construido (segmentado), como permite BER; se ha leído igualmente.
|
||||
The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. El valor de la firma RSA no tiene la longitud del módulo de la clave (se han quitado o añadido ceros iniciales); se ha leído igualmente.
|
||||
The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. El DigestInfo de la firma RSA no lleva el parámetro NULL (los firmadores antiguos lo escriben así); se ha leído igualmente.
|
||||
The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. La firma ECDSA no está en DER canónico (bytes de sobra, relleno o falta el byte de signo); se ha leído igualmente.
|
||||
The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. La firma ECDSA son los valores r y s en bruto en vez de una secuencia DER; se ha leído igualmente.
|
||||
The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. El algoritmo de la firma y el algoritmo de resumen del SignerInfo nombran hashes distintos; la firma se hizo con el del SignerInfo.
|
||||
# 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. Esta firma usa SHA-1, un algoritmo de hash débil: se han demostrado colisiones en él. Sigue siendo válida porque muchos documentos firmados hace años lo usan, pero no conviene fiarse de ella.
|
||||
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. Esta firma usa RIPEMD-160, un algoritmo de hash heredado de 160 bits que ya no se recomienda para firmas. Sigue siendo válida, pero no conviene fiarse de ella.
|
||||
The document has been altered or corrupted since the signature was applied. El documento se ha alterado o se ha dañado desde que se aplicó la firma.
|
||||
The signer's certificate is not in the signature. El certificado del firmante no está en la firma.
|
||||
The signature value does not match the signer's certificate: the signature is corrupt. El valor de la firma no se corresponde con el certificado del firmante: la firma está dañada.
|
||||
The signature could not be checked ({}). No se ha podido comprobar la firma ({}).
|
||||
The signature could not be verified ({}). No se ha podido verificar la firma ({}).
|
||||
This document has not been modified since this signature was applied. Este documento no se ha modificado desde que se aplicó esta firma.
|
||||
The document has been modified since this signature was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó esta firma, pero son cambios que permite ({}).
|
||||
The document has been altered since this signature was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó esta firma de maneras que no permite ({}).
|
||||
The signer's certificate was not valid at the time of signing. El certificado del firmante no era válido en el momento de la firma.
|
||||
The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). El certificado del firmante no permite firmas digitales (su uso de clave no incluye ni firma digital ni no repudio).
|
||||
The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). El certificado del firmante no está emitido para firmar documentos (su uso extendido de clave no incluye ningún propósito de firma de documentos).
|
||||
The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificado del firmante tiene extensiones críticas que PdfCraft no reconoce ({}).
|
||||
# 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 {}. El certificado de {} no es un certificado de CA que pueda emitir certificados, así que no se usa para avalar a {}.
|
||||
The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificado de {} no admite tantos certificados de CA por debajo (longitud de ruta), así que no se usa para avalar a {}.
|
||||
The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificado de {} no era válido en el momento de la firma, así que no se usa para avalar a {}.
|
||||
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. La identidad del firmante es desconocida porque no está en tu lista de certificados de confianza y ninguno de los certificados superiores es de confianza.
|
||||
The signer's identity is valid. La identidad del firmante es válida.
|
||||
The signature includes an embedded timestamp. La firma incluye un sello de tiempo incrustado.
|
||||
Signing time is from the clock on the signer's computer. La hora de la firma es la del reloj del ordenador del firmante.
|
||||
The signature's timestamp token does not cover the signature value. El sello de tiempo de la firma no cubre el valor de la firma.
|
||||
The signature's timestamp token could not be verified ({}). No se ha podido verificar el sello de tiempo de la firma ({}).
|
||||
The embedded timestamp token is valid and its authority is trusted; trusted time is {}. El sello de tiempo incrustado es válido y su autoridad es de confianza; la hora de confianza es {}.
|
||||
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. La firma lleva un sello de tiempo sin verificar ({}): la autoridad de sellado de tiempo no está en tu lista de certificados de confianza, así que se usa la hora de firma que indica el firmante.
|
||||
The signer's certificate has been revoked ({}). El certificado del firmante está revocado ({}).
|
||||
The document's embedded revocation information shows that the signer's certificate has not been revoked. La información de revocación incrustada en el documento indica que el certificado del firmante no está revocado.
|
||||
The timestamp has no valid byte range. El sello de tiempo no tiene un intervalo de bytes válido.
|
||||
The timestamp's byte range does not match the file. El intervalo de bytes del sello de tiempo no se corresponde con el archivo.
|
||||
The timestamp's byte range does not exclude exactly its contents. El intervalo de bytes del sello de tiempo no excluye exactamente su contenido.
|
||||
The timestamp contents are not hexadecimal. El contenido del sello de tiempo no es hexadecimal.
|
||||
The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}).
|
||||
The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo.
|
||||
The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado.
|
||||
This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo.
|
||||
The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}).
|
||||
The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}).
|
||||
The timestamp token is valid and its authority is trusted. El sello de tiempo es válido y su autoridad es de confianza.
|
||||
The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. El sello de tiempo es válido, pero la autoridad de sellado de tiempo no está en tu lista de certificados de confianza.
|
||||
Unsigned signature field Campo de firma sin firmar
|
||||
Signature is valid La firma es válida
|
||||
Signature validity is unknown La validez de la firma es desconocida
|
||||
Signature is invalid La firma no es válida
|
||||
# The changes a signature allows or does not allow, as they are listed in those sentences
|
||||
# (sign::pdf::kind::ALL): words in an enumeration, so no article and no capital.
|
||||
page content contenido de las páginas
|
||||
form fill relleno de formularios
|
||||
comments comentarios
|
||||
document security store almacén de seguridad del documento
|
||||
metadata metadatos
|
||||
links enlaces
|
||||
document structure estructura del documento
|
||||
pages added or removed páginas añadidas o quitadas
|
||||
other changes otros cambios
|
||||
the signed version could not be read no se ha podido leer la versión firmada
|
||||
# What can be wrong with the pages to print (print::WORDINGS).
|
||||
page {n} is out of range (1–{count}) la página {n} está fuera del intervalo (1–{count})
|
||||
{page} is not a page number or label {page} no es un número ni una etiqueta de página
|
||||
# What an edit is called in the Edit menu and the history (EDIT_LABELS, BATCH_LABELS).
|
||||
Change Title Cambiar el título
|
||||
Change Author Cambiar el autor
|
||||
Change Subject Cambiar el asunto
|
||||
Change Keywords Cambiar las palabras clave
|
||||
Run form script Ejecutar el script del formulario
|
||||
Save as {level} Guardar como {level}
|
||||
Create links Crear enlaces
|
||||
Mark differences Marcar diferencias
|
||||
|
||||
# Multi-object selection and movement (#844)
|
||||
@plural {n} object selected|{n} objects selected {n} objeto seleccionado|{n} objetos seleccionados
|
||||
Drag the selection to move it. Esc clears the selection. Arrastra la selección para moverla. Esc borra la selección.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
|
|
@ -406,6 +406,17 @@ pub fn tr(lang: Lang, s: &str) -> &str {
|
|||
lang.catalog().plain(s).unwrap_or(s)
|
||||
}
|
||||
|
||||
/// How the layers below say their own sentences in the current language.
|
||||
///
|
||||
/// The checker, the preflight profiles and the signature panel hand over a whole English wording
|
||||
/// — holes and all — and the document's own words apart from it, so this is all they need of us:
|
||||
/// `f.said(&i18n::words)` says the sentence here, and `in_english` says it in English. An unknown
|
||||
/// wording comes back as it stands, which is the English, so a catalog that is missing one is a
|
||||
/// sentence in English rather than a gap.
|
||||
pub fn words(wording: &str) -> String {
|
||||
t(wording).to_owned()
|
||||
}
|
||||
|
||||
/// Like [`tr`], for an English string that needs a disambiguating `context`.
|
||||
pub fn tr_ctx<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
|
||||
lang.catalog().contextual(context, s).unwrap_or_else(|| tr(lang, s))
|
||||
|
|
@ -464,6 +475,19 @@ pub fn action_label(text: &str) -> String {
|
|||
t(text).to_owned()
|
||||
}
|
||||
|
||||
/// A history label in the current language, out of the engine's own two parts: the wording is
|
||||
/// looked up whole and the document's own word goes into its `{}` afterwards, so a file or field
|
||||
/// name is never read as part of the wording. This is exact where [`action_label`] has to guess
|
||||
/// from the English text, and is what the engine's own labels go through.
|
||||
pub fn edit_label(label: &pdfcraft_engine::Label) -> String {
|
||||
// A batch's wording is only the hole: it carries its name as text, which from below L7 is one
|
||||
// of `BATCH_LABELS` and so is looked up too.
|
||||
if let Some(name) = label.value.as_deref().filter(|_| label.wording == "{batch}") {
|
||||
return t(name).to_owned();
|
||||
}
|
||||
label.said(t(label.wording))
|
||||
}
|
||||
|
||||
/// A command label such as "Undo Insert pages from a.pdf" in the current language. A catalog
|
||||
/// whose word order doesn't put the verb first translates "Undo {action}" and "Redo {action}"
|
||||
/// as whole phrases; otherwise the translated verb goes before the action.
|
||||
|
|
@ -1095,6 +1119,57 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Spanish says everything the layers below word for themselves: what the accessibility
|
||||
/// checker finds and what its saved report calls things, what the PDF/A verifier reports, what
|
||||
/// a signature's details say and the changes they name, what can be wrong with the pages to
|
||||
/// print, and what an edit is called in the history.
|
||||
///
|
||||
/// These lists are the whole point of the engine handing the wording over apart from the
|
||||
/// document's own words: a catalog that covers them says those sentences in its own language.
|
||||
/// A sentence missing here is a sentence in English on a Spanish screen.
|
||||
#[test]
|
||||
fn spanish_covers_what_the_engine_says() {
|
||||
let es = Lang::from_code("es").expect("es registered");
|
||||
let lists: [(&str, &[&str]); 8] = [
|
||||
("accessibility finding", pdfcraft_engine::a11y::WORDINGS),
|
||||
("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS),
|
||||
("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS),
|
||||
("signature detail", pdfcraft_engine::sign::pdf::WORDINGS),
|
||||
("change after signing", pdfcraft_engine::sign::pdf::kind::ALL),
|
||||
("pages to print", pdfcraft_engine::print::WORDINGS),
|
||||
("edit label", pdfcraft_engine::EDIT_LABELS),
|
||||
("batch label", pdfcraft_engine::BATCH_LABELS),
|
||||
];
|
||||
for (what, list) in lists {
|
||||
assert!(!list.is_empty(), "no {what} wordings to cover");
|
||||
for wording in list {
|
||||
assert!(has(es, wording), "missing {what}: {wording}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each of those sentences keeps its holes when it is said in Spanish, so the document's own
|
||||
/// words still have somewhere to go: a translation that drops `{p}` or `{}` would read as a
|
||||
/// sentence about nothing in particular.
|
||||
#[test]
|
||||
fn spanish_keeps_the_holes_in_what_the_engine_says() {
|
||||
let es = Lang::from_code("es").expect("es registered");
|
||||
let lists: [&[&str]; 4] = [
|
||||
pdfcraft_engine::a11y::WORDINGS,
|
||||
pdfcraft_engine::pdfa::WORDINGS,
|
||||
pdfcraft_engine::sign::pdf::WORDINGS,
|
||||
pdfcraft_engine::print::WORDINGS,
|
||||
];
|
||||
for wording in lists.into_iter().flatten() {
|
||||
let said = tr(es, wording);
|
||||
// `{}` is one hole; `{name}` holes are named, and each name must survive by itself.
|
||||
assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Spanish of {wording:?} loses a hole: {said:?}");
|
||||
for hole in wording.split('{').skip(1).filter_map(|rest| rest.split_once('}')).map(|(name, _)| name).filter(|n| !n.is_empty()) {
|
||||
assert!(said.contains(&format!("{{{hole}}}")), "the Spanish of {wording:?} loses {{{hole}}}: {said:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telugu_is_registered() {
|
||||
let te = Lang::from_code("te").expect("te registered");
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ impl PrintDraft {
|
|||
} else {
|
||||
print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse)
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
// What the person got wrong about what to print is said in their language: the engine hands
|
||||
// the wording over whole and the page numbers apart from it.
|
||||
.map_err(|e| e.said(&crate::i18n::words))?;
|
||||
let layout = match self.handling {
|
||||
Handling::Size => Layout::Size(match self.size {
|
||||
SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale),
|
||||
|
|
|
|||
|
|
@ -1061,7 +1061,10 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan
|
|||
})
|
||||
.font(theme::semibold(12.0)),
|
||||
);
|
||||
for line in &s.details {
|
||||
for detail in &s.details {
|
||||
// Each detail is a whole sentence of ours plus what the signature itself says; said
|
||||
// here in the panel's language, with the signature's own words left as they are.
|
||||
let line = detail.said(&crate::i18n::words);
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap());
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool
|
|||
for i in v {
|
||||
let page = i.page.map(|p| crate::i18n::fmt(tl!(" (page {p})"), &[("p", &(p + 1).to_string())])).unwrap_or_default();
|
||||
let fix = if i.fixable { String::new() } else { tl!(" · not fixable here").to_string() };
|
||||
ui.label(format!("{}{page}", i.message));
|
||||
// The verifier hands the wording over whole, with the document's own name
|
||||
// apart from it, so the problem reads in this language and the name as it is.
|
||||
ui.label(format!("{}{page}", i.said(&crate::i18n::words)));
|
||||
ui.label(egui::RichText::new(format!("ISO 19005 {}{fix}", i.clause)).small().color(t.text_muted));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,8 +113,8 @@ fn the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn() {
|
|||
// The history holds one enhancement and one turn, not a pile of them.
|
||||
let s = h.state_mut();
|
||||
let id = s.views[0].id;
|
||||
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap(), "Enhance camera image");
|
||||
assert_eq!(s.session.undo(id).unwrap().as_str(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap().as_str(), "Enhance camera image");
|
||||
assert_eq!(s.session.get(id).unwrap().can_undo(), None);
|
||||
let p = &s.session.get(id).unwrap().info.pages[0];
|
||||
assert_eq!((p.width, p.height), (300.0, 400.0), "back to the photo");
|
||||
|
|
@ -209,8 +209,8 @@ fn the_level_leaves_the_page_alone_when_another_edit_came_in_between() {
|
|||
assert_eq!((done.level, done.applied), (applied.1, applied), "the slider goes back");
|
||||
let s = h.state_mut();
|
||||
let id = s.views[0].id;
|
||||
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap(), "Enhance camera image");
|
||||
assert_eq!(s.session.undo(id).unwrap().as_str(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap().as_str(), "Rotate page");
|
||||
assert_eq!(s.session.undo(id).unwrap().as_str(), "Enhance camera image");
|
||||
assert_eq!(s.session.get(id).unwrap().can_undo(), None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`,
|
|||
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
|
||||
|
||||
Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words).
|
||||
|
||||
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
|
||||
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
|
||||
|
|
@ -56,6 +58,18 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`):
|
|||
- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`.
|
||||
- Translations are clean-room: write them from the meaning of the English text, never from another product's string tables.
|
||||
|
||||
## Sentences the engine words
|
||||
|
||||
Some sentences are not the interface's own. The accessibility checker, the PDF/A verifier, the signature reader, the page-range parser and the edit history all word their own findings, and matching that wording in the UI with a `tl!` literal would break silently whenever the engine changed it. So those layers hand the wording over instead, apart from the document's own words:
|
||||
|
||||
- Each of them has a `WORDINGS` constant listing every sentence it can say, as `&'static str` with holes in it (`{n}`, `{p}`, `{}`). The sentence is written once, in the layer that means it.
|
||||
- `said(&words)` says one of them: the layer looks its own wording up through the closure it is given, then fills the holes with the document's values — a page number, a font name, what the person typed — in one pass, so a value containing `{n}` is inserted as written.
|
||||
- The UI passes `&crate::i18n::words`, which is a catalog lookup, so the sentence reads in the interface language. `in_english` is the same thing in English, and a wording with no entry comes back as it stands, which is the English — a catalog missing one shows an English sentence rather than a gap.
|
||||
- The lists are `a11y::{WORDINGS, REPORT_WORDS}`, `preflight::WORDINGS`, `sign::pdf::{WORDINGS, kind::ALL}`, `print::WORDINGS` and the engine's `{EDIT_LABELS, BATCH_LABELS}`. `a11y::report_html` takes a language tag and a `words` closure, so a saved report reads the way the panel read, down to its `lang` attribute.
|
||||
- `spanish_covers_what_the_engine_says` requires an entry for every wording in those lists, and `spanish_keeps_the_holes_in_what_the_engine_says` requires each translation to keep the same holes, so the catalog cannot fall behind the engine without a test saying so. Other languages are free to add the entries when they are translated; until then those sentences show in English as before.
|
||||
|
||||
Error details that come from the operating system, and faults rather than mistakes (a file that cannot be read, a lock that was poisoned), still carry their own text and are shown as they are.
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries.
|
||||
|
|
|
|||
|
|
@ -9878,8 +9878,8 @@ milestone = "M14"
|
|||
status = "partial"
|
||||
commands = ["app.preferences"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values"]
|
||||
notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. Engine/OS error details stay untranslated. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), a full localization audit."
|
||||
evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"]
|
||||
notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish translates them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit."
|
||||
|
||||
[[feature]]
|
||||
id = "misc.prefs-reading"
|
||||
|
|
|
|||
Loading…
Reference in New Issue