mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 07:27:40 +00:00
Accessibility: export before-and-after review record (#1204)
* Add accessibility before-and-after review record tool * Parity: leave out the review record (the parity list tracks Acrobat features; this is a PdfCraft extension) --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
b65b7adb01
commit
4a1e205f81
7 files changed
+127
No files matched your search
Generated
+1
@@ -3619,6 +3619,7 @@ dependencies = [
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,6 +15,7 @@ pdfcraft-render = { workspace = true }
|
||||
pdfcraft-organize = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
png = "0.18"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
|
||||
|
||||
@@ -85,6 +85,15 @@ moves 1–1000 references atomically in displayed points (right/down), preservin
|
||||
and fonts. A refusal changes nothing; success is one undo step. See
|
||||
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
|
||||
|
||||
## Accessibility review record
|
||||
|
||||
Open the original and edited PDFs as separate documents, then call
|
||||
`accessibility_review_record {before, after, path}`. The tool writes a JSON record atomically within
|
||||
`--root`: SHA-256 hashes of both working PDFs, rule statuses and finding counts before and after,
|
||||
changed rules, and rules still failed or requiring manual review. `all: true` also includes the
|
||||
colour-contrast rule. It does not change either PDF or certify accessibility. Keep the original open
|
||||
while making edits to the second document so the comparison has a stable baseline.
|
||||
|
||||
## Common command tools
|
||||
|
||||
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Accessibility tools: the full check, the accessibility report, and the automatic fixes.
|
||||
|
||||
use pdfcraft_engine::DocId;
|
||||
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
|
||||
|
||||
@@ -104,6 +106,78 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn a11y_review_record(&self, a: &Args) -> Result<Value> {
|
||||
let id = |key| -> Result<DocId> {
|
||||
let n = u64::try_from(a.int(key)?).map_err(|_| ToolError::InvalidArgs(format!("{key} must be positive")))?;
|
||||
if n == 0 {
|
||||
return Err(ToolError::InvalidArgs(format!("{key} must be positive")));
|
||||
}
|
||||
Ok(DocId(n))
|
||||
};
|
||||
let before_id = id("before")?;
|
||||
let after_id = id("after")?;
|
||||
if before_id == after_id {
|
||||
return Err(ToolError::InvalidArgs("before and after must be separate open documents".into()));
|
||||
}
|
||||
let before = self.session.get(before_id).ok_or_else(|| failed(format!("no open document with id {}", before_id.0)))?;
|
||||
let after = self.session.get(after_id).ok_or_else(|| failed(format!("no open document with id {}", after_id.0)))?;
|
||||
let mut options = Options::default();
|
||||
if a.opt_bool("all")?.unwrap_or(false) {
|
||||
options.rules = Rule::ALL.into_iter().collect();
|
||||
}
|
||||
let before_report = before.accessibility_check(&options).ok_or_else(|| failed("the original document can't be read for checking"))?;
|
||||
let after_report = after.accessibility_check(&options).ok_or_else(|| failed("the edited document can't be read for checking"))?;
|
||||
if before_report.results.len() != after_report.results.len() {
|
||||
return Err(failed("the two accessibility checks returned different rule sets"));
|
||||
}
|
||||
let mut rules = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
let mut review_required = Vec::new();
|
||||
for (prior, current) in before_report.results.iter().zip(&after_report.results) {
|
||||
if prior.rule != current.rule {
|
||||
return Err(failed("the two accessibility checks returned different rule sets"));
|
||||
}
|
||||
let rule = prior.rule.id();
|
||||
let before_status = status_id(prior.status);
|
||||
let after_status = status_id(current.status);
|
||||
let before_findings = prior.findings.len();
|
||||
let after_findings = current.findings.len();
|
||||
if before_status != after_status || before_findings != after_findings {
|
||||
changed.push(rule);
|
||||
}
|
||||
if matches!(current.status, Status::Failed | Status::Manual) {
|
||||
review_required.push(rule);
|
||||
}
|
||||
rules.push(json!({
|
||||
"rule": rule,
|
||||
"before": { "status": before_status, "findings": before_findings },
|
||||
"after": { "status": after_status, "findings": after_findings },
|
||||
}));
|
||||
}
|
||||
let (y, m, d) = self.session.today();
|
||||
let hash = |bytes: &[u8]| Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
let record = json!({
|
||||
"schema": 1,
|
||||
"date": format!("{y}-{m:02}-{d:02}"),
|
||||
"before": { "name": before.name, "sha256": hash(before.bytes.as_slice()), "summary": {
|
||||
"passed": before_report.count(Status::Passed), "failed": before_report.count(Status::Failed),
|
||||
"manual": before_report.count(Status::Manual), "skipped": before_report.count(Status::Skipped),
|
||||
} },
|
||||
"after": { "name": after.name, "sha256": hash(after.bytes.as_slice()), "summary": {
|
||||
"passed": after_report.count(Status::Passed), "failed": after_report.count(Status::Failed),
|
||||
"manual": after_report.count(Status::Manual), "skipped": after_report.count(Status::Skipped),
|
||||
} },
|
||||
"rules": rules,
|
||||
"changed": changed,
|
||||
"review_required": review_required,
|
||||
"note": "Automated checks cover only part of accessibility. Review remaining findings manually and validate the final PDF independently.",
|
||||
});
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let bytes = serde_json::to_vec_pretty(&record).map_err(failed)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
|
||||
}
|
||||
|
||||
pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = a.str("rule")?;
|
||||
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
|
||||
|
||||
@@ -640,6 +640,7 @@ impl Automation {
|
||||
"ocr_status" => self.ocr_status()?,
|
||||
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
|
||||
"accessibility_report" => self.a11y_report(&a)?,
|
||||
"accessibility_review_record" => self.a11y_review_record(&a)?,
|
||||
"accessibility_fix" => self.a11y_fix(&a)?,
|
||||
"accessibility_figures" => self.accessibility_figures(&a)?,
|
||||
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
|
||||
|
||||
@@ -866,6 +866,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "path"],
|
||||
)),
|
||||
t("accessibility_review_record", "Accessibility review record", "Compare the same accessibility rules on two open versions of a PDF and write a JSON record to path. Includes document SHA-256 hashes, before/after rule statuses and finding counts, changed rules, and rules still needing review. Does not change either PDF or claim accessibility compliance. Open the original and edited copies as separate documents.")
|
||||
.destructive()
|
||||
.with(schema(
|
||||
json!({
|
||||
"before": doc(),
|
||||
"after": doc(),
|
||||
"path": { "type": "string" },
|
||||
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
|
||||
}),
|
||||
&["before", "after", "path"],
|
||||
)),
|
||||
t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])),
|
||||
t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).")
|
||||
|
||||
@@ -1608,6 +1608,36 @@ trailer << /Root 1 0 R >>
|
||||
assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_review_record_compares_two_versions_without_changing_them() {
|
||||
let dir = workdir("a11y-review-record");
|
||||
let source = std::fs::read(dir.join("a.pdf")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let before = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "accessibility_fix", json!({ "doc": after, "rule": "primary-language", "value": "en-GB" }));
|
||||
|
||||
let output = ok(&mut a, "accessibility_review_record", json!({ "before": before, "after": after, "path": "review.json" }));
|
||||
let file: Value = serde_json::from_slice(&std::fs::read(dir.join("review.json")).unwrap()).unwrap();
|
||||
assert_eq!(output["record"], file);
|
||||
assert_eq!(file["schema"], 1);
|
||||
assert_eq!(file["before"]["sha256"].as_str().unwrap().len(), 64);
|
||||
assert_eq!(file["after"]["sha256"].as_str().unwrap().len(), 64);
|
||||
assert_ne!(file["before"]["sha256"], file["after"]["sha256"]);
|
||||
assert_eq!(file["changed"], json!(["primary-language"]));
|
||||
let language = file["rules"].as_array().unwrap().iter().find(|r| r["rule"] == "primary-language").unwrap();
|
||||
assert_eq!(language["before"]["status"], "failed");
|
||||
assert_eq!(language["after"]["status"], "passed");
|
||||
assert!(file["review_required"].as_array().unwrap().iter().any(|r| r == "tagged-pdf"));
|
||||
assert_eq!(std::fs::read(dir.join("a.pdf")).unwrap(), source);
|
||||
assert!(matches!(
|
||||
a.call("accessibility_review_record", &json!({ "before": before, "after": before, "path": "same.json" })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(!dir.join("same.json").exists());
|
||||
assert!(a.call("accessibility_review_record", &json!({ "before": before, "after": after, "path": "../escape.json" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_text_through_tools() {
|
||||
let dir = workdir("edit-text");
|
||||
|
||||
Reference in new issue
Block a user