mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
fix(organize,print): join /Contents array pieces with a space (#3502)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user