fix(organize,print): join /Contents array pieces with a space (#3502)

This commit is contained in:
jonathanwxh-cell
2026-10-25 12:03:40 +08:00
parent 418ac6a2af
commit 522653d6b1
4 changed files with 137 additions and 7 deletions
+5 -4
View File
@@ -231,14 +231,15 @@ impl Pruner<'_> {
}
}
/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded
/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget
/// runs out: the page may draw names that can't be seen.
/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An
/// error when a stream can't be decoded or the budget runs out: the page may draw names that
/// can't be seen.
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
let mut out = Vec::new();
for data in DecodeBudget::page().decode_contents(doc, contents) {
out.extend(data?);
out.push(b'\n');
out.push(b' ');
}
Ok(out)
}
+46
View File
@@ -3688,3 +3688,49 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() {
let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect();
assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]);
}
/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a
/// `%` comment at the end of a piece without a line break continues through the first line of the
/// next piece in both resource pruning (`extract_pages`) and `page_as_form`.
#[test]
fn contents_pieces_join_with_a_space_across_a_trailing_comment() {
let build_doc = |first_piece: &str| {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources 6 0 R >>".into(), // 3
raw(first_piece), // 4
raw("/ImHidden Do\n/ImShown Do"), // 5
"<< /XObject << /ImHidden 7 0 R /ImShown 8 0 R >> >>".into(), // 6
image(1, 1, 1), // 7
image(1, 1, 1), // 8
];
open(build(&b, "/Root 1 0 R"))
};
// Without a trailing newline in piece 1, `% comment` swallows `/ImHidden Do` on the first
// line of piece 2; only `/ImShown` is drawn.
let commented = build_doc("q Q % comment without newline");
let extracted = full_roundtrip(&extract_pages(&commented, &[0]).unwrap());
assert_eq!(xobject_names(&extracted, 0), [b"ImShown".to_vec()], "commented-out Do across piece boundary is pruned");
let mut form_dst = Document::new_empty();
let (form_ref, _) = crate::page_as_form(&mut form_dst, &commented, 0).unwrap();
let Object::Stream(form_stream) = &*form_dst.get(form_ref) else { panic!("form stream") };
let form_bytes = form_stream.decoded().unwrap();
let drawn: Vec<Vec<u8>> = pdfcraft_content::parse(&form_bytes)
.ops
.into_iter()
.filter(|op| op.op == b"Do")
.filter_map(|op| op.operands.first().and_then(Object::as_name).map(|n| n.to_vec()))
.collect();
assert_eq!(drawn, [b"ImShown".to_vec()], "page_as_form keeps the first line of piece 2 inside the comment");
// Control: when piece 1 ends the comment with a newline, `/ImHidden Do` on piece 2's first
// line is a real operator and stays in both the extracted page and the form XObject.
let newline_ended = build_doc("q Q % comment with newline\n");
let extracted_ctrl = full_roundtrip(&extract_pages(&newline_ended, &[0]).unwrap());
let mut ctrl_names = xobject_names(&extracted_ctrl, 0);
ctrl_names.sort();
assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]);
}
+4 -3
View File
@@ -720,8 +720,9 @@ fn n(v: f64) -> String {
if s == "-0" { "0".into() } else { s.into() }
}
/// The page's content streams decoded against `budget` and joined; streams that don't decode
/// are left out. `None` when the budget ran out, so part of the content is missing.
/// The page's content streams decoded against `budget` and joined with a space as the renderer
/// joins them; streams that don't decode are left out. `None` when the budget ran out, so part of
/// the content is missing.
fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) };
let mut out = Vec::new();
@@ -729,7 +730,7 @@ fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> O
match data {
Ok(data) => {
out.extend_from_slice(&data);
out.push(b'\n');
out.push(b' ');
}
Err(_) if budget.remaining() == 0 => return None,
Err(_) => {}
+82
View File
@@ -1059,3 +1059,85 @@ fn a_booklet_prints_both_sides_on_the_short_edge() {
assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off);
assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off);
}
/// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%`
/// comment at the end of a piece without a line break continues through the first line of the
/// next piece in both `impose` and `text_reads_up`.
#[test]
fn contents_pieces_join_with_a_space_across_a_trailing_comment() {
let build_doc = |first_piece: &[u8]| {
let mut doc = Document::new_empty();
let font = doc.add(
[
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type1")),
(b"BaseFont".to_vec(), Object::name("Helvetica")),
]
.into_iter()
.collect::<Dict>(),
);
let s1 = doc.add(Stream::from_raw(Dict::new(), first_piece.to_vec()));
// Line 1 of s2 draws sideways ("HIDDEN") and 4 characters reading up; line 2 draws upright ("SHOWN").
let s2 = doc.add(Stream::from_raw(
Dict::new(),
b"BT /F1 18 Tf 0 1 -1 0 50 50 Tm (HIDDEN) Tj ET\nBT /F1 18 Tf 1 0 0 1 20 100 Tm (SHOWN) Tj ET".to_vec(),
));
let fonts: Dict = [(b"F1".to_vec(), Object::Ref(font))].into_iter().collect();
let res: Dict = [(b"Font".to_vec(), Object::Dict(fonts))].into_iter().collect();
let pages_root = doc.add(Object::Null);
let page = doc.add(
[
(b"Type".to_vec(), Object::name("Page")),
(b"Parent".to_vec(), Object::Ref(pages_root)),
(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 300.0].into_iter().map(Object::Real).collect())),
(b"Resources".to_vec(), Object::Dict(res)),
(b"Contents".to_vec(), Object::Array(vec![Object::Ref(s1), Object::Ref(s2)])),
]
.into_iter()
.collect::<Dict>(),
);
doc.set(
pages_root,
Object::Dict(
[
(b"Type".to_vec(), Object::name("Pages")),
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
(b"Count".to_vec(), Object::Int(1)),
]
.into_iter()
.collect(),
),
);
let cat = doc.add([(b"Type".to_vec(), Object::name("Catalog")), (b"Pages".to_vec(), Object::Ref(pages_root))].into_iter().collect::<Dict>());
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat));
doc
};
let form_strings = |imposed: Vec<u8>| -> Vec<Vec<u8>> {
let out = Document::open(Arc::new(imposed)).unwrap();
let page = &pdfcraft_model::pages(&out)[0];
let res = out.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
let form_obj = out.resolve(xo.get(b"P0").unwrap());
let Object::Stream(form) = &*form_obj else { panic!("form stream") };
let body = form.decoded().unwrap();
pdfcraft_content::parse(&body)
.ops
.into_iter()
.filter(|op| op.op == b"Tj")
.filter_map(|op| op.operands.first().and_then(Object::as_string).map(|s| s.bytes.to_vec()))
.collect()
};
// Without a newline at the end of s1, `% comment` swallows line 1 of s2 (`(HIDDEN) Tj`).
let commented = build_doc(b"q Q % trailing comment");
assert!(!text_reads_up(&commented, 0), "commented-out vertical text on piece 2's first line is not counted");
let printed = impose(&commented, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap();
assert_eq!(form_strings(printed), [b"SHOWN".to_vec()], "impose does not un-comment piece 2's first line");
// Control: when s1 terminates the comment with a newline, line 1 of s2 is drawn and counted.
let with_newline = build_doc(b"q Q % trailing comment\n");
assert!(text_reads_up(&with_newline, 0), "6 vertical vs 5 horizontal chars when line 1 is not commented out");
let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap();
assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]);
}