Files
pdfcraft/crates/a11y/tests/cross_stream.rs
T

255 lines
13 KiB
Rust

use pdfcraft_a11y::{Options, Rule, Status, autotag_text, check, figures, mark_decorative, mark_tag_artifact, tag_content, tags};
use pdfcraft_content::{Op, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
const HIDDEN_AND_AFTER: &str = "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\nBT /F1 12 Tf 72 500 Td (After) Tj ET";
fn stream(data: &str) -> String {
format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())
}
fn build_pdf(objects: &[(u32, String)]) -> Document {
let mut raw = Vec::from(&b"%PDF-1.7\n"[..]);
let max_num = objects.iter().map(|(n, _)| *n).max().unwrap_or(0);
let mut offsets = std::collections::HashMap::new();
for (num, body) in objects {
offsets.insert(*num, raw.len());
raw.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = raw.len();
let size = max_num + 1;
raw.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for num in 1..size {
if let Some(off) = offsets.get(&num) {
raw.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
} else {
raw.extend_from_slice(b"0000000000 65535 f \n");
}
}
raw.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Document::open(std::sync::Arc::new(raw)).expect("valid synthetic PDF")
}
fn untagged_two_streams(first: &str, second: &str) -> Document {
build_pdf(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into()),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [5 0 R 6 0 R] /Resources << /Font << /F1 4 0 R >> >> >>".into()),
(4, FONT.into()),
(5, stream(first)),
(6, stream(second)),
])
}
fn tagged_figure_two_streams(after_figure: &str, second: &str) -> Document {
let figure = "/Figure << /MCID 0 >> BDC q 50 0 0 50 72 600 cm /Im1 Do Q EMC\n";
let first = format!("{figure}{after_figure}");
build_pdf(&[
(
1,
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>"
.into(),
),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 /Contents [10 0 R 11 0 R] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> >>".into(),
),
(4, FONT.into()),
(
5,
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\x00\x7f\x40\x20\nendstream".into(),
),
(6, "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 9 0 R >>".into()),
(7, "<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R] >>".into()),
(
8,
"<< /Type /StructElem /S /Figure /P 7 0 R /Pg 3 0 R /K 0 /Alt (A grey square) >>"
.into(),
),
(9, "<< /Nums [0 [8 0 R]] >>".into()),
(10, stream(&first)),
(11, stream(second)),
])
}
fn joined_ops(doc: &Document) -> (Vec<Op>, usize) {
let page = &pdfcraft_model::pages(doc)[0];
let contents = page.dict.get(b"Contents").expect("page contents");
let pieces: Vec<Vec<u8>> = match &*doc.resolve(contents) {
Object::Stream(s) => vec![s.decoded().expect("decoded stream")],
Object::Array(items) => items
.iter()
.filter_map(|item| match &*doc.resolve(item) {
Object::Stream(s) => Some(s.decoded().expect("decoded stream")),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let joined = Pieces::join(&pieces);
let parsed = joined.parse();
(parsed.ops, parsed.skipped)
}
fn shown_strings(ops: &[Op]) -> Vec<String> {
ops.iter().filter(|op| op.is("Tj")).filter_map(|op| op.operands.first()?.as_string().map(|s| s.to_text())).collect()
}
#[test]
fn mark_decorative_leaves_the_other_stream_untouched() {
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
let before = doc.get(ObjRef::new(11, 0)).clone();
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
assert!(figures(&doc).is_empty());
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
let page = doc.get(ObjRef::new(3, 0));
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
}
/// A and B supply different operands to R's BDC. Reusing R must not let the first
/// occurrence's splice remove the unselected second occurrence's BDC.
fn split_marked_content(shared: bool) -> Document {
let mut doc = tagged_figure_two_streams("", "");
doc.set(ObjRef::new(10, 0), Object::Stream(Stream::flate(Dict::new(), b"/Figure << /MCID 0 >>")));
let body = Object::Stream(Stream::flate(Dict::new(), b"BDC 0 0 10 10 re f EMC"));
doc.set(ObjRef::new(11, 0), body.clone());
let b = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Span << /MCID 1 >>")));
let second = if shared { ObjRef::new(11, 0) } else { doc.add(body) };
doc.update_dict(ObjRef::new(3, 0), |p| {
p.set(
b"Contents".to_vec(),
Object::Array(vec![Object::Ref(ObjRef::new(10, 0)), Object::Ref(ObjRef::new(11, 0)), Object::Ref(b), Object::Ref(second)]),
);
})
.unwrap();
Document::open(std::sync::Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
}
fn assert_selected_occurrence_only(shared: bool) {
let mut doc = split_marked_content(shared);
let (before, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(before.iter().filter(|op| op.is("BDC")).count(), 2);
let figure = figures(&doc)[0].obj;
mark_decorative(&mut doc, figure).unwrap();
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
let (ops, skipped) = joined_ops(&reopened);
assert_eq!(skipped, 0);
assert_eq!(ops.iter().filter(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")).count(), 1);
let unselected: Vec<_> = ops.iter().filter(|op| op.is("BDC")).collect();
assert_eq!(unselected.len(), 1, "unselected Span must retain its BDC after save/reopen");
assert_eq!(unselected[0].name(0), Some(b"Span".as_slice()));
assert_eq!(unselected[0].operands[1].as_dict().unwrap().get(b"MCID").and_then(Object::as_int), Some(1));
assert_eq!(ops.iter().filter(|op| op.is("re")).count(), 2);
assert_eq!(ops.iter().filter(|op| op.is("EMC")).count(), 2);
assert!(figures(&reopened).is_empty());
}
}
#[test]
fn mark_decorative_isolates_shared_stream_occurrences_after_save() {
assert_selected_occurrence_only(true);
}
#[test]
fn mark_decorative_preserves_unique_stream_occurrences_after_save() {
assert_selected_occurrence_only(false);
}
#[test]
fn mark_decorative_leaves_another_page_using_the_stream_untouched() {
let mut doc = tagged_figure_two_streams("", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let other_page = doc.add(Object::Dict(page));
doc.update_dict(ObjRef::new(2, 0), |p| {
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(other_page)]));
p.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
let before = doc.get(ObjRef::new(10, 0)).clone();
mark_decorative(&mut doc, ObjRef::new(8, 0)).unwrap();
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
let pages = pdfcraft_model::pages(&reopened);
assert_eq!(pages.len(), 2);
let contents = pages[1].dict.get(b"Contents").unwrap().as_array().unwrap();
assert_eq!(*reopened.resolve(&contents[0]), *before, "the other page's shared figure stream must stay intact");
let (ops, skipped) = joined_ops(&reopened);
assert_eq!(skipped, 0);
assert!(ops.iter().any(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")));
}
}
#[test]
fn autotag_keeps_text_commented_out_across_stream_boundary_hidden() {
let mut doc = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream", HIDDEN_AND_AFTER);
let result = autotag_text(&mut doc).expect("autotag succeeds");
assert_eq!(result.pages, 1);
assert_eq!(result.paragraphs, 2, "commented-out text at start of second stream must not be tagged");
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(shown_strings(&ops), ["Visible", "After"]);
let p_tags: Vec<_> = tags(&doc).into_iter().filter(|t| t.kind == "P").collect();
assert_eq!(p_tags.len(), 2);
let texts: Vec<_> = p_tags.iter().flat_map(|t| tag_content(&doc, t.object).expect("tag content")).map(|item| item.text).collect();
assert_eq!(texts, ["Visible", "After"]);
// Control: when the comment ends with a newline inside the first stream, Hidden is visible.
let mut control = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream\n", HIDDEN_AND_AFTER);
let control_res = autotag_text(&mut control).expect("control autotag succeeds");
assert_eq!(control_res.paragraphs, 3);
let (control_ops, _) = joined_ops(&control);
assert_eq!(shown_strings(&control_ops), ["Visible", "Hidden", "After"]);
}
#[test]
fn mark_decorative_preserves_shape_split_across_content_streams() {
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50", "re f\nBT /F1 12 Tf 72 500 Td (After) Tj ET");
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
assert!(figures(&doc).is_empty());
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0, "split rectangle operands must not be dropped as skipped tokens");
let re_op = ops.iter().find(|op| op.is("re")).expect("rectangle operator must survive");
assert_eq!(re_op.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
assert_eq!(shown_strings(&ops), ["After"]);
// Control: rectangle whole in the first stream.
let mut control = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
mark_decorative(&mut control, ObjRef::new(8, 0)).expect("control mark decorative succeeds");
let (control_ops, control_skipped) = joined_ops(&control);
assert_eq!(control_skipped, 0);
let control_re = control_ops.iter().find(|op| op.is("re")).expect("control rectangle");
assert_eq!(control_re.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
assert_eq!(shown_strings(&control_ops), ["After"]);
}
#[test]
fn mark_decorative_and_mark_artifact_keep_cross_stream_comment_hidden() {
let mut doc = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(shown_strings(&ops), ["After"], "commented-out Hidden text must not become visible after mark_decorative");
let mut doc_artifact = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
mark_tag_artifact(&mut doc_artifact, ObjRef::new(8, 0)).expect("mark_tag_artifact succeeds");
let (artifact_ops, artifact_skipped) = joined_ops(&doc_artifact);
assert_eq!(artifact_skipped, 0);
assert_eq!(shown_strings(&artifact_ops), ["After"], "commented-out Hidden text must not become visible after mark_tag_artifact");
// Checker also honours the cross-stream comment when scanning for untagged content.
let mut comment_only = tagged_figure_two_streams("% a note at the end of the first stream", "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\n");
let report = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
assert_eq!(report.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
mark_decorative(&mut comment_only, ObjRef::new(8, 0)).expect("mark decorative");
let report_after = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
assert_eq!(report_after.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
}