mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
* Edit text: a rewritten paragraph keeps its place in the text object it shares A rewritten paragraph was drawn again just before the BT of its text object. When earlier paragraphs shared that BT … ET (as in text-only pages), the paragraph moved ahead of them in the content: reading order changed (screen readers, copy, `pdftotext -raw`) and text_paragraphs renumbered, so after text_edit paragraph 2 a following text_edit paragraph 1 rewrote the already edited paragraph instead of the first one. The text object is now split where the paragraph was, and the second half resumes from the same line matrix and text state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Edit text: read the text before a paragraph without slicing by input positions (never-panic rule) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Edit text: test a recoloured paragraph in a shared text object (split + q/Q together) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: storybored379 <storybored379@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
552 lines
29 KiB
Rust
552 lines
29 KiB
Rust
use std::sync::Arc;
|
||
|
||
use printcraft_cos::{Document, SaveOptions, write_incremental};
|
||
|
||
use super::*;
|
||
|
||
/// Three pages: an upright page with content that leaves the graphics state changed (an
|
||
/// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources.
|
||
fn fixture() -> Document {
|
||
let objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 600 800] /Resources 6 0 R >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents [7 0 R] >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||
"<< /Font << /F1 8 0 R >> >>".into(),
|
||
"<< /Length 37 >>\nstream\n2 0 0 2 0 0 cm BT /F1 9 Tf (Hi) Tj ET\nendstream".into(),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
Document::open(Arc::new(out)).unwrap()
|
||
}
|
||
|
||
fn reopen(doc: &Document) -> Document {
|
||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
|
||
Document::open(Arc::new(bytes)).unwrap()
|
||
}
|
||
|
||
/// The decoded content streams of a page, in order.
|
||
fn streams(doc: &Document, page: usize) -> Vec<String> {
|
||
let p = &printcraft_model::pages(doc)[page];
|
||
let c = p.dict.get(b"Contents").cloned();
|
||
let list = match c.map(|c| doc.resolve(&c)).as_deref() {
|
||
Some(Object::Array(a)) => a.clone(),
|
||
Some(_) => vec![p.dict.get(b"Contents").cloned().unwrap()],
|
||
None => vec![],
|
||
};
|
||
list.iter().map(|o| String::from_utf8_lossy(&stream_bytes(doc, o).unwrap()).into_owned()).collect()
|
||
}
|
||
|
||
fn cx() -> Context {
|
||
Context { date: (2026, 10, 1) }
|
||
}
|
||
|
||
#[test]
|
||
fn tokens_expand() {
|
||
let c = cx();
|
||
assert_eq!(expand("Page <<1>> of <<n>>", 3, 10, 0, &c), "Page 3 of 10");
|
||
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
|
||
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
|
||
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
|
||
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
|
||
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
|
||
}
|
||
|
||
#[test]
|
||
fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() {
|
||
let mut doc = fixture();
|
||
let hf = HeaderFooter {
|
||
text: ["Left".into(), String::new(), "<<Page 1 of n>>".into(), String::new(), "Confidential (draft)".into(), String::new()],
|
||
..HeaderFooter::default()
|
||
};
|
||
add_header_footer(&mut doc, &[0, 1, 2], &hf, false, &cx()).unwrap();
|
||
let doc = reopen(&doc);
|
||
let s0 = streams(&doc, 0);
|
||
// q-wrapper, original, Q-wrapper, header/footer.
|
||
assert_eq!(s0.len(), 4, "{s0:?}");
|
||
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PrintCraft\n", "Q %PrintCraft\n"));
|
||
let mark = &s0[3];
|
||
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
|
||
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
|
||
// The rotated page maps display space onto its rotated crop box.
|
||
let s1 = streams(&doc, 1);
|
||
assert!(s1.last().unwrap().contains("0 1 -1 0 600 0 cm") && s1.last().unwrap().contains("(Page 2 of 3)"), "{s1:?}");
|
||
// A page without content gets just the mark; its inherited resources are copied, not changed.
|
||
assert_eq!(streams(&doc, 2).len(), 1);
|
||
let p2 = &printcraft_model::pages(&doc)[2];
|
||
let res = doc.resolve(p2.dict.get(b"Resources").unwrap());
|
||
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
|
||
assert!(fonts.as_dict().unwrap().contains(b"PCHelv") && fonts.as_dict().unwrap().contains(b"F1"));
|
||
let shared = doc.get(printcraft_cos::ObjRef::new(6, 0));
|
||
let shared_fonts = doc.resolve(shared.as_dict().unwrap().get(b"Font").unwrap());
|
||
assert!(!shared_fonts.as_dict().unwrap().contains(b"PCHelv"), "the shared dictionary is untouched");
|
||
assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]);
|
||
}
|
||
|
||
#[test]
|
||
fn replace_and_remove_restore_the_original_content() {
|
||
let mut doc = fixture();
|
||
let original = streams(&doc, 0);
|
||
let mut hf = HeaderFooter::default();
|
||
hf.text[1] = "First".into();
|
||
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
|
||
hf.text[1] = "Second".into();
|
||
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
|
||
let s = streams(&doc, 0);
|
||
assert_eq!(s.iter().filter(|x| x.contains("/PCMark")).count(), 1, "replaced, not added");
|
||
assert!(s.last().unwrap().contains("(Second)"));
|
||
add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), ..Watermark::default() }, false).unwrap();
|
||
add_background(&mut doc, &[0], &Background { color: [1.0, 1.0, 0.9], opacity: 1.0, ..Background::default() }, false).unwrap();
|
||
assert_eq!(marks_present(&doc).len(), 3);
|
||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
|
||
let s = streams(&doc, 0);
|
||
assert!(s[0].contains("/PCMark /Background"), "the background stays behind: {s:?}");
|
||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
|
||
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
|
||
}
|
||
|
||
#[test]
|
||
fn watermarks_rotate_fade_and_can_go_behind() {
|
||
let mut doc = fixture();
|
||
let wm = Watermark { text: "CONFIDENTIAL\nDo not copy".into(), opacity: 0.3, rotation: 45.0, behind: true, ..Watermark::default() };
|
||
add_watermark(&mut doc, &[0], &wm, false).unwrap();
|
||
let s = streams(&doc, 0);
|
||
assert!(s[0].contains("/PCMark /Watermark"), "behind: first");
|
||
assert!(s[0].contains("/PCGS30 gs") && s[0].contains("0.707 0.707 -0.707 0.707 300 400 cm"), "{}", s[0]);
|
||
assert!(s[0].contains("(CONFIDENTIAL) Tj") && s[0].contains("(Do not copy) Tj"));
|
||
assert_eq!(s.len(), 2, "no wrapper needed for content behind");
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_requests_change_nothing() {
|
||
let mut doc = fixture();
|
||
assert!(matches!(add_header_footer(&mut doc, &[0], &HeaderFooter::default(), false, &cx()), Err(EditError::Invalid(_))));
|
||
assert!(matches!(add_watermark(&mut doc, &[0], &Watermark::default(), false), Err(EditError::Invalid(_))));
|
||
assert_eq!(
|
||
add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false),
|
||
Err(EditError::NoSuchPage(9))
|
||
);
|
||
assert!(!doc.is_modified());
|
||
}
|
||
|
||
#[test]
|
||
fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
|
||
use printcraft_annot::{Meta, NewAnnotation, NoteIcon, Shape, Style, add_annotation, add_reply};
|
||
let mut doc = fixture();
|
||
let meta = Meta { date: None, id: "x".into() };
|
||
let add = |doc: &mut Document, shape: Shape| {
|
||
let style = Style::default_for(&shape);
|
||
add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: "c".into(), author: "a".into() }, &meta).unwrap()
|
||
};
|
||
add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] });
|
||
let note = add(&mut doc, Shape::Note { at: [200.0, 700.0], icon: NoteIcon::Comment });
|
||
add_reply(&mut doc, 0, note, "reply", "b", &meta).unwrap();
|
||
let before = streams(&doc, 0);
|
||
let n = flatten(&mut doc, &[0], true, false).unwrap();
|
||
assert_eq!(n, 2, "the rectangle and the note icon are drawn; the reply has nothing to draw");
|
||
let doc = reopen(&doc);
|
||
let p = &printcraft_model::pages(&doc)[0];
|
||
assert!(!p.dict.contains(b"Annots"), "comments, pop-up and reply are gone");
|
||
let s = streams(&doc, 0);
|
||
assert_eq!(s.len(), before.len() + 3, "wrapped original + flattened content: {s:?}");
|
||
let flat = s.last().unwrap();
|
||
assert!(flat.contains("/PCFl0 Do") && flat.contains("/PCFl1 Do"), "{flat}");
|
||
// The rectangle's appearance is drawn at its rectangle (bbox = rect, identity mapping).
|
||
assert!(flat.contains("q 1 0 0 1 0 0 cm /PCFl0 Do Q"), "{flat}");
|
||
// The note's 20×20 icon box is mapped onto its rect at (200, 680).
|
||
assert!(flat.contains("1 0 0 1 200 680 cm /PCFl1 Do"), "{flat}");
|
||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||
assert!(xo.as_dict().unwrap().contains(b"PCFl1"));
|
||
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
|
||
}
|
||
|
||
#[test]
|
||
fn added_text_and_images_are_page_content_that_stays_editable() {
|
||
let mut doc = fixture();
|
||
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Approved by Ada\nSecond line".into(), size: 14.0, ..AddedText::default() };
|
||
assert_eq!(add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(), 0);
|
||
// An 1×1 gray image, placed on the rotated page.
|
||
let mut d = Dict::new();
|
||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(1));
|
||
d.set(b"Height".to_vec(), Object::Int(1));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128])));
|
||
add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap();
|
||
let doc2 = reopen(&doc);
|
||
let all = list_added(&doc2);
|
||
assert_eq!(all.len(), 2);
|
||
let Content::Text(t) = &all[0].content else { panic!() };
|
||
assert_eq!((t.text.as_str(), t.size), ("Approved by Ada\nSecond line", 14.0));
|
||
assert_eq!(t.rect, [72.0, 700.0 - 2.0 * 14.0 * 1.2, 300.0, 700.0], "the box height follows the two lines");
|
||
let page0 = streams(&doc2, 0).join("\n");
|
||
assert!(page0.contains("(Approved by Ada) Tj") && page0.contains("(Second line) Tj") && page0.contains("/PCFHelvetica 14 Tf"), "{page0}");
|
||
// The rotated page draws in display space: the view matrix comes first.
|
||
let page1 = streams(&doc2, 1).join("\n");
|
||
assert!(page1.contains("q 0 1 -1 0 600 0 cm") && page1.contains(&format!("/PCImg{} Do", img.num)), "{page1}");
|
||
// Edit: move, restyle, retype; then delete.
|
||
let mut doc = doc2;
|
||
let moved = AddedText {
|
||
rect: [100.0, 500.0, 300.0, 520.0],
|
||
text: "Approved".into(),
|
||
bold: true,
|
||
family: Family::Times,
|
||
align: Align::Right,
|
||
color: [1.0, 0.0, 0.0],
|
||
..text
|
||
};
|
||
update_content(&mut doc, 0, 0, &Content::Text(moved)).unwrap();
|
||
let page0 = streams(&doc, 0).join("\n");
|
||
assert!(page0.contains("/PCFTimesBold 14 Tf 1 0 0 rg") && !page0.contains("Second line"), "{page0}");
|
||
assert!(
|
||
update_content(&mut doc, 1, 0, &Content::Text(AddedText { text: "x".into(), rect: [0.0, 0.0, 50.0, 10.0], ..AddedText::default() })).is_err(),
|
||
"kinds don't change"
|
||
);
|
||
delete_content(&mut doc, 0, 0).unwrap();
|
||
assert_eq!(list_added(&doc).len(), 1);
|
||
assert!(!streams(&doc, 0).join("").contains("Approved"));
|
||
assert!(add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 0.0, 100.0, 10.0], ..AddedText::default() })).is_err(), "empty text");
|
||
// Page marks ignore added items.
|
||
assert!(marks_present(&doc).is_empty());
|
||
}
|
||
|
||
/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses.
|
||
fn text_page(content: &str) -> Document {
|
||
let objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
|
||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||
// Subset: glyphs for a (97) and b (98) only.
|
||
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
|
||
.into(),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
Document::open(Arc::new(out)).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn text_lines_are_found_and_replaced_in_place() {
|
||
let mut doc =
|
||
text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 40 0 Td [(wor) -20 (ld)] TJ 0 -20 Td (Second line) Tj ET BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
|
||
assert_eq!(texts, ["Hello world", "Second line", "ab"]);
|
||
assert!((lines[0].rect[0] - 72.0).abs() < 0.01 && lines[0].rect[1] < 700.0 && lines[0].rect[3] > 700.0, "{:?}", lines[0].rect);
|
||
assert!((lines[0].size - 12.0).abs() < 1e-9 && lines[0].base_font == "Helvetica");
|
||
let second = lines[1].rect;
|
||
// Same font: reused; the next line stays where it was.
|
||
let r = text::replace_line(&mut doc, 0, 0, "Goodbye, café").unwrap();
|
||
assert_eq!(r.substituted, None);
|
||
let doc = reopen(&doc);
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
assert_eq!(lines[0].text, "Goodbye, café");
|
||
assert_eq!(lines[1].text, "Second line");
|
||
assert_eq!(lines[1].rect, second);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||
// "c" has no glyph in the subset: Helvetica takes over for this line.
|
||
let r = text::replace_line(&mut doc, 0, 0, "abc").unwrap();
|
||
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
|
||
let doc2 = reopen(&doc);
|
||
let lines = text::text_lines(&doc2, 0).unwrap();
|
||
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), ("abc", "Helvetica"));
|
||
// Neither font can show Greek.
|
||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
|
||
assert!(text::replace_line(&mut doc, 0, 0, "Ωmega").is_err());
|
||
assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line");
|
||
}
|
||
|
||
#[test]
|
||
fn paragraphs_are_found_and_rewrapped() {
|
||
let mut doc = text_page(
|
||
"BT 0 0 1 rg /F1 10 Tf 12 TL 72 700 Td (The quick brown fox) Tj T* (jumps over the) Tj T* (lazy dog.) Tj ET \
|
||
BT /F1 10 Tf 72 600 Td (Next paragraph) Tj ET",
|
||
);
|
||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
|
||
assert_eq!(texts, ["The quick brown fox jumps over the lazy dog.", "Next paragraph"]);
|
||
assert_eq!(blocks[0].lines, [0, 1, 2]);
|
||
let width = blocks[0].rect[2] - blocks[0].rect[0];
|
||
let next = blocks[1].rect;
|
||
let long = "PrintCraft rewraps a paragraph to its own width when its text changes, keeping the font, size, colour and line spacing.";
|
||
assert_eq!(text::replace_block(&mut doc, 0, 0, long).unwrap().substituted, None);
|
||
let doc = reopen(&doc);
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||
assert_eq!(blocks[0].text, long);
|
||
assert!(blocks[0].lines.len() > 3, "more lines: {}", blocks[0].lines.len());
|
||
for i in &blocks[0].lines {
|
||
assert!(lines[*i].rect[2] - lines[*i].rect[0] <= width + 1.0, "line {} fits", lines[*i].text);
|
||
}
|
||
// Same left edge, same spacing (12 pt).
|
||
assert!((lines[0].rect[0] - 72.0).abs() < 0.01);
|
||
let spacing = lines[blocks[0].lines[0]].origin_baseline() - lines[blocks[0].lines[1]].origin_baseline();
|
||
assert!((spacing - 12.0).abs() < 0.01, "{spacing}");
|
||
// The next paragraph is untouched, and the new text is blue like the old.
|
||
assert_eq!(blocks[1].text, "Next paragraph");
|
||
assert_eq!(blocks[1].rect, next);
|
||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||
assert!(content.contains("0 0 1 rg"), "{content}");
|
||
// Shorter text: fewer lines.
|
||
let mut doc = doc;
|
||
text::replace_block(&mut doc, 0, 0, "Short.").unwrap();
|
||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||
assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1));
|
||
}
|
||
|
||
fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
|
||
let p = printcraft_model::pages(doc).swap_remove(page);
|
||
let c = p.dict.get(b"Contents").unwrap();
|
||
match &*doc.resolve(c) {
|
||
printcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
|
||
printcraft_cos::Object::Array(a) => a
|
||
.iter()
|
||
.flat_map(|x| match &*doc.resolve(x) {
|
||
printcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
|
||
_ => Vec::new(),
|
||
})
|
||
.collect(),
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2).
|
||
fn image_page() -> Document {
|
||
let content = "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET";
|
||
let objs: Vec<String> = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(),
|
||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
Document::open(Arc::new(out)).unwrap()
|
||
}
|
||
|
||
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||
}
|
||
|
||
#[test]
|
||
fn page_images_move_turn_replace_and_delete() {
|
||
let mut doc = image_page();
|
||
let imgs = images::page_images(&doc, 0).unwrap();
|
||
assert_eq!(imgs.len(), 1);
|
||
assert!(close(imgs[0].rect, [100.0, 100.0, 300.0, 200.0]), "{:?}", imgs[0].rect);
|
||
assert_eq!((imgs[0].width, imgs[0].height, imgs[0].name.as_str()), (4, 2, "Im0"));
|
||
// Move and resize.
|
||
let t = images::rect_to_rect(imgs[0].rect, [50.0, 400.0, 150.0, 450.0]);
|
||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
|
||
let doc = reopen(&doc);
|
||
let r = images::page_images(&doc, 0).unwrap()[0].rect;
|
||
assert!(close(r, [50.0, 400.0, 150.0, 450.0]), "{r:?}");
|
||
// A quarter turn about the centre: 100 × 50 becomes 50 × 100 around (100, 425).
|
||
let mut doc = doc;
|
||
let t = images::turn_about_centre(r, 1, false, false);
|
||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
|
||
let r = images::page_images(&doc, 0).unwrap()[0].rect;
|
||
assert!(close(r, [75.0, 375.0, 125.0, 475.0]), "{r:?}");
|
||
// The text after it is untouched.
|
||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
|
||
// Replace with another image object, in the same place.
|
||
let mut d = printcraft_cos::Dict::new();
|
||
for (k, v) in [
|
||
(&b"Type"[..], printcraft_cos::Object::name("XObject")),
|
||
(b"Subtype", printcraft_cos::Object::name("Image")),
|
||
(b"Width", printcraft_cos::Object::Int(1)),
|
||
(b"Height", printcraft_cos::Object::Int(1)),
|
||
] {
|
||
d.set(k.to_vec(), v);
|
||
}
|
||
let other = doc.add(printcraft_cos::Object::Stream(printcraft_cos::Stream::from_raw(d, vec![0])));
|
||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(other)).unwrap();
|
||
let imgs = images::page_images(&doc, 0).unwrap();
|
||
assert_eq!((imgs[0].object, imgs[0].width), (Some(other), 1));
|
||
assert!(close(imgs[0].rect, r));
|
||
// Delete.
|
||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
|
||
assert!(images::page_images(&doc, 0).unwrap().is_empty());
|
||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn paragraphs_take_new_formatting() {
|
||
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
|
||
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
|
||
let style = text::BlockStyle {
|
||
family: Some((added::Family::Times, true, false)),
|
||
size: Some(12.0),
|
||
color: Some([1.0, 0.0, 0.0]),
|
||
align: Some(added::Align::Center),
|
||
..Default::default()
|
||
};
|
||
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
|
||
let doc = reopen(&doc);
|
||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
assert_eq!(blocks[0].text, before.text, "same words");
|
||
assert_eq!((blocks[0].base_font.as_str(), blocks[0].size), ("Times-Bold", 12.0));
|
||
// Centred in the paragraph's width.
|
||
let mid = (before.rect[0] + before.rect[2]) / 2.0;
|
||
for i in &blocks[0].lines {
|
||
let r = lines[*i].rect;
|
||
assert!(((r[0] + r[2]) / 2.0 - mid).abs() < 2.0, "line {:?} centred on {mid}", lines[*i].text);
|
||
}
|
||
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("1 0 0 rg"));
|
||
// Right alignment keeps lines flush with the right edge.
|
||
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
|
||
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
|
||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }).unwrap();
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
for l in &lines {
|
||
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() {
|
||
let mut doc = text_page("BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj ET");
|
||
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
|
||
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
|
||
let doc = reopen(&doc);
|
||
// The fill colour in force where the second paragraph is shown (q/Q nest it).
|
||
let ops = printcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||
let (mut fill, mut stack) = (String::from("0 g"), Vec::new());
|
||
for op in &ops {
|
||
match op.op.as_slice() {
|
||
b"q" => stack.push(fill.clone()),
|
||
b"Q" => fill = stack.pop().unwrap_or_default(),
|
||
b"g" | b"rg" | b"k" => fill = String::from_utf8_lossy(&printcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string(),
|
||
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Second paragraph") => break,
|
||
_ => {}
|
||
}
|
||
}
|
||
assert_eq!(fill, "0 g", "{ops:?}");
|
||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].text, "Second paragraph");
|
||
}
|
||
|
||
#[test]
|
||
fn a_rewritten_paragraph_keeps_its_place_in_a_shared_text_object() {
|
||
// Three paragraphs in one BT … ET: rewriting the second keeps the order (paragraph numbers
|
||
// and reading order) and every paragraph's position.
|
||
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
|
||
let mut doc = text_page(src);
|
||
let before = text::text_blocks(&doc, 0).unwrap();
|
||
text::replace_block(&mut doc, 0, 1, "Edited second").unwrap();
|
||
let doc = reopen(&doc);
|
||
let after = text::text_blocks(&doc, 0).unwrap();
|
||
let texts: Vec<&str> = after.iter().map(|b| b.text.as_str()).collect();
|
||
assert_eq!(texts, ["First paragraph", "Edited second", "Third paragraph"]);
|
||
assert_eq!(after[0].rect, before[0].rect);
|
||
assert_eq!(after[2].rect, before[2].rect);
|
||
assert!((after[1].rect[0] - before[1].rect[0]).abs() < 0.01 && (after[1].rect[1] - before[1].rect[1]).abs() < 0.01, "{:?}", after[1].rect);
|
||
}
|
||
|
||
#[test]
|
||
fn recolouring_a_paragraph_in_a_shared_text_object_keeps_order_and_nesting() {
|
||
// Splitting the text object (to keep the order) and q … Q (to keep the colour in) together:
|
||
// q/Q stay outside text objects, and the paragraph after it keeps the original colour.
|
||
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
|
||
let mut doc = text_page(src);
|
||
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
|
||
text::rewrite_block(&mut doc, 0, 1, None, &style).unwrap();
|
||
let doc = reopen(&doc);
|
||
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||
assert_eq!(texts, ["First paragraph", "Second paragraph", "Third paragraph"]);
|
||
let ops = printcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||
let (mut in_text, mut depth, mut red) = (false, 0usize, Vec::new());
|
||
for op in &ops {
|
||
match op.op.as_slice() {
|
||
b"BT" => in_text = true,
|
||
b"ET" => in_text = false,
|
||
b"q" | b"Q" => {
|
||
assert!(!in_text, "q/Q inside a text object: {ops:?}");
|
||
depth = if op.is("q") { depth + 1 } else { depth.saturating_sub(1) };
|
||
}
|
||
b"rg" => red.push(depth),
|
||
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Third paragraph") => {
|
||
assert!(red.iter().all(|d| *d > depth), "red still in force for the third paragraph: {ops:?}");
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(!red.is_empty(), "{ops:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn justify_underline_and_spacing() {
|
||
let src = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET";
|
||
// Justified: every line but the last reaches the right edge.
|
||
let mut doc = text_page(src);
|
||
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
|
||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap();
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
for l in &lines[..lines.len() - 1] {
|
||
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
|
||
}
|
||
// Double line spacing, underlined.
|
||
let mut doc = text_page(src);
|
||
let style = text::BlockStyle { line_spacing: Some(2.0), underline: Some(true), ..Default::default() };
|
||
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
|
||
let lines = text::text_lines(&doc, 0).unwrap();
|
||
assert!((lines[0].origin_baseline() - lines[1].origin_baseline() - 20.0).abs() < 0.01);
|
||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||
assert!(content.contains(" l\nS\n") || content.contains(" l S"), "{content}");
|
||
// Character spacing widens, horizontal scale narrows.
|
||
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
|
||
let w0 = text::text_lines(&doc, 0).unwrap()[0].rect;
|
||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { char_spacing: Some(2.0), ..Default::default() }).unwrap();
|
||
let w1 = text::text_lines(&doc, 0).unwrap()[0].rect;
|
||
assert!((w1[2] - w1[0]) - (w0[2] - w0[0]) > 15.0, "{w0:?} → {w1:?}");
|
||
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
|
||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { scale: Some(50.0), ..Default::default() }).unwrap();
|
||
let w2 = text::text_lines(&doc, 0).unwrap()[0].rect;
|
||
assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}");
|
||
}
|