fix(a11y): join and splice page content streams with cross-stream rules

Join multiple streams in a page's /Contents array with a space (b' ') rather than a newline (b'\n') in autotag::content_bytes, alt::page_content, and content::page_content so a % comment at the end of one stream continues through the start of the next stream per ISO 32000-2 section 7.8.2.

Update alt::artifact_marked_content to join page content streams with pdfcraft_content::Pieces::join, parse operators across the joined stream, and rewrite only matching BDC spans via Pieces::splice so operators split across stream boundaries and cross-stream comments are preserved.

Fixes #3217
This commit is contained in:
jonathanwxh-cell committed 2026-10-19 06:47:56 +08:00
1 parent dab3149110
commit 060381c745
4 files changed
+193 -18

No files matched your search

+24 -16
View File
@@ -165,7 +165,7 @@ pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b'\n');
v.push(b' ');
v
}
_ => Vec::new(),
@@ -321,25 +321,33 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
let props = page_properties(doc, &page.dict);
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
for entry in &entries {
match &*doc.resolve(entry) {
Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
_ => streams.push(None),
}
}
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
let pieces = pdfcraft_content::Pieces::join(&decoded);
let edits: Vec<_> =
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
if edits.is_empty() {
return Ok(false);
}
let mut spliced = pieces.splice(edits).into_iter();
let mut next = Vec::with_capacity(entries.len());
let mut changed = false;
for entry in entries {
let Some(stream) = (match &*doc.resolve(&entry) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}) else {
for (entry, stream) in entries.into_iter().zip(streams) {
let Some((stream, old)) = stream else {
next.push(entry);
continue;
};
let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops;
let mut ours = false;
for op in &mut ops {
if marked(doc, &props, op, ids) {
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
ours = true;
}
}
if !ours {
let Some(new) = spliced.next() else {
next.push(entry);
continue;
};
if new == old {
next.push(entry);
continue;
}
@@ -348,7 +356,7 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
dict.remove(b"Length");
dict.remove(b"Filter");
dict.remove(b"DecodeParms");
let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops)));
let stream = Object::Stream(Stream::flate(dict, &new));
let stored = match entry {
Object::Ref(r) => {
doc.set(r, stream);
+1 -1
View File
@@ -70,7 +70,7 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
all.extend_from_slice(&decoded);
all.push(b'\n');
all.push(b' ');
}
Ok(all)
}
+1 -1
View File
@@ -47,7 +47,7 @@ fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b'\n');
v.push(b' ');
v
}
_ => Vec::new(),
+167
View File
@@ -0,0 +1,167 @@
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::{Document, ObjRef, Object};
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 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);
}