M2.6: accessibility marks no tag whose operator reaches past a stream not read whole

Marking a tag as an artifact, or a figure as decorative, refused content after a stream damaged
partway, placing each operator by its first operand. A page may split its content between an
operator's operands and its keyword. When a `BDC`'s operands end the stream before one damaged
from its start, or one that doesn't decode, the keyword the page reads is in what wasn't read;
joined without it, those operands and the next tag's read as one `BDC`, and marking rewrote the
stream after the hidden one, the next tag's `BDC` taken out of it.

An operator now counts after damage by its keyword, and one whose operands and keyword lie on
either side of a stream not read whole is refused: that stream may end them itself.
`a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten` (damaged from its start, and
not decoding). A tag entirely after a stream that doesn't decode is still marked: such a stream
holds all of its content, between tokens. `accessibility_mark_tag_artifact`'s description names
content a stream that doesn't decode may hide or change too, not only a damaged one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 20:18:07 +01:00
1 parent 3639fed12f
commit 1b8f3040ee
3 files changed
+40 -3

No files matched your search

+5 -2
View File
@@ -452,8 +452,11 @@ fn artifact_marked_content(
for op in pieces.parse().ops {
if let Some(id) = marked_id(doc, &props, &op, ids) {
// What follows the damage is unknown: a string or comment the damage left open runs
// on into the next stream, so what reads as an operator after it may not be one.
if damage.is_some_and(|d| pieces.piece_of(op.span.start) > d) {
// on into the next stream, so what reads as an operator after it may not be one. An
// operator whose operands start before a stream not read whole and whose keyword comes
// after it takes them from around what wasn't read: that stream may end them itself.
let (first, last) = pieces.pieces_of(&op);
if damage.is_some_and(|d| last > d) || read.get(first..last).is_some_and(|r| r.iter().any(|r| *r != Read::Whole)) {
return Ok(Marked::Damaged);
}
found.insert(id);
+34
View File
@@ -1220,6 +1220,40 @@ fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
}
}
/// A page may split its content between an operator's operands and its keyword. When the operands
/// of the figure's `BDC` end the stream before one damaged from its start or one that doesn't
/// decode, the `BDC` the page reads is in what wasn't read: joined without it, those operands and
/// the next tag's read as one `BDC`, which reaches into the stream after it. Neither marking
/// changes anything, the structure tree included.
#[test]
fn a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let rest = b" BDC q 40 0 0 20 10 150 cm /Im Do Q EMC";
for damaged in [true, false] {
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >>".to_vec()));
let hidden = if damaged {
damaged_flate(b"", rest)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), rest);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = doc.add(hidden);
let after = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC".to_vec())));
let listed = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(after)]);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed)).unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "damaged: {damaged}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "damaged: {damaged}");
assert_eq!(state(&doc), before, "damaged: {damaged}");
}
}
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
/// to be off the page, so neither marking changes anything, the structure tree included.
#[test]
+1 -1
View File
@@ -1285,7 +1285,7 @@ pub fn tools() -> Vec<ToolDef> {
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that may be in or after a damaged content stream are rejected. Undoable.")
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that a damaged or undecodable content stream may hide or change are rejected. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },