Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,277 @@
|
||||
//! Text and object edits against the real worker (PDF-03, PDF-04).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Worker, Writing, Wrote,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// A Letter page: two lines, a stamp, a picture.
|
||||
fn opened(dir: &Path) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn page_text(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
/// Rectangles equal to a hundredth of a point.
|
||||
fn near(got: [f32; 4], want: [f32; 4]) {
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{got:?} is not {want:?}");
|
||||
}
|
||||
|
||||
/// A rewrite of block `block` as `text` in `font`.
|
||||
fn rewrite(worker: &mut Worker, doc: Doc, block: usize, text: &str, font: &str) -> Writing {
|
||||
let read = worker.text_blocks(doc, 0).expect("blocks")[block].clone();
|
||||
Writing {
|
||||
block: Some(block),
|
||||
expected: read.text,
|
||||
text: text.into(),
|
||||
font: font.into(),
|
||||
size: read.size,
|
||||
rgb: read.rgb,
|
||||
bx: [read.rect[0], read.rect[1], read.rect[2] - read.rect[0]],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_say_what_they_hold_and_whether_they_edit() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(dir.path());
|
||||
let blocks = worker.text_blocks(doc, 0).expect("blocks");
|
||||
let hello = blocks.iter().find(|b| b.text.starts_with("Hello")).expect("the page text");
|
||||
assert!(hello.editable && hello.size == 18.0, "{hello:?}");
|
||||
let stamp = blocks.iter().find(|b| b.text == "STAMP").expect("the stamp text");
|
||||
assert!(!stamp.editable, "text inside a form is not the page's own");
|
||||
let drawn = worker.objects(doc, 0).expect("objects");
|
||||
assert_eq!(drawn.iter().map(|d| d.form).collect::<Vec<_>>(), [true, false]);
|
||||
assert_eq!(drawn[1].rect, [72.0, 242.0, 172.0, 292.0], "the picture, in page space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_text_replaces_the_old_glyphs_in_one_step() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = page_text(&mut worker, doc);
|
||||
assert!(now.contains("Hi world") && !now.contains("Hello"), "{now}");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_without_the_glyph_changes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "你好", "helvetica");
|
||||
assert_eq!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Missing('你'));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"), "the old text stays");
|
||||
let chinese = Writing { font: "cjk-sc".into(), ..writing };
|
||||
assert!(matches!(worker.put_text(doc, 0, &chinese, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(page_text(&mut worker, doc).contains("你好"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_draws_the_change_and_keeps_none_of_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let mut writing = rewrite(&mut worker, doc, index, "A much longer line that will not fit the old box at all", "times");
|
||||
writing.bx[2] = 150.0;
|
||||
let Wrote::Preview(pixels, (w, h), grew) = worker.put_text(doc, 0, &writing, 2.0).expect("previews") else {
|
||||
panic!("a preview");
|
||||
};
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(grew, "more lines than before");
|
||||
assert_eq!(pixels.len(), (w * h * 4) as usize);
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[0] < 128), "it draws text");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_goes_where_asked() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let writing = Writing {
|
||||
block: None,
|
||||
expected: String::new(),
|
||||
text: "Added".into(),
|
||||
font: "courier".into(),
|
||||
size: 12.0,
|
||||
rgb: 0xff0000,
|
||||
bx: [100.0, 600.0, 300.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let added = worker.text_blocks(doc, 0).expect("blocks").into_iter().find(|b| b.text == "Added").expect("added");
|
||||
assert!((added.rect[0] - 100.0).abs() < 1.0 && (added.rect[1] - 600.0).abs() < 3.0, "{added:?}");
|
||||
assert_eq!(added.rgb, 0xff0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_move_scale_swap_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
worker.place(doc, 0, 1, [1.0, 0.0, 0.0, 1.0, 50.0, 20.0]).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 222.0, 312.0]);
|
||||
// Twice the size about its top left.
|
||||
worker.place(doc, 0, 1, [2.0, 0.0, 0.0, 2.0, -122.0, -262.0]).expect("scales");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 322.0, 362.0]);
|
||||
let square = dir.path().join("square.pnm");
|
||||
std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes");
|
||||
worker.replace_image(doc, 0, 1, &square).expect("replaces");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let swapped = worker.objects(doc, 0).expect("objects")[1].rect;
|
||||
near(swapped, [172.0, 262.0, 272.0, 362.0]);
|
||||
worker.delete_object(doc, 0, 0).expect("deletes the stamp");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!page_text(&mut worker, doc).contains("STAMP"));
|
||||
assert_eq!(worker.objects(doc, 0).expect("objects").len(), 1);
|
||||
worker.add_image(doc, 0, &square, [400.0, 100.0, 450.0, 150.0]).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects").last().expect("added").rect, [400.0, 100.0, 450.0, 150.0]);
|
||||
for _ in 0..5 {
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [72.0, 242.0, 172.0, 292.0]);
|
||||
assert!(page_text(&mut worker, doc).contains("STAMP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documents_own_font_writes_and_edits_edit_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let hello = |worker: &mut Worker, doc: Doc, start: &str| {
|
||||
worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with(start)).expect("the block")
|
||||
};
|
||||
let index = hello(&mut worker, doc, "Hello");
|
||||
let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "original");
|
||||
assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let again = hello(&mut worker, doc, "Hi");
|
||||
assert!(worker.text_blocks(doc, 0).expect("blocks")[again].editable, "new text is the page's own");
|
||||
let second = rewrite(&mut worker, doc, again, "Yo world\nSecond line", "original");
|
||||
assert!(matches!(worker.put_text(doc, 0, &second, 0.0).expect("writes again"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = page_text(&mut worker, doc);
|
||||
assert!(now.contains("Yo world") && !now.contains("Hi world"), "{now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_added_in_a_preview_or_undone_is_added_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "你好", "cjk-sc");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 2.0).expect("previews"), Wrote::Preview(..)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes again"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = worker.open(&out).expect("reopens").doc;
|
||||
assert!(page_text(&mut worker, saved).contains("你好"), "the font is in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_and_refusals_keep_the_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let preview = rewrite(&mut worker, doc, index, "Yo world\nSecond line", "times");
|
||||
assert!(matches!(worker.put_text(doc, 0, &preview, 2.0).expect("previews"), Wrote::Preview(..)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let refused = Writing { text: "你好".into(), font: "helvetica".into(), ..preview };
|
||||
assert!(matches!(worker.put_text(doc, 0, &refused, 0.0).expect("answers"), Wrote::Missing(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, true).expect("the edit still redoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(page_text(&mut worker, doc).contains("Hi world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigger_glyphs_count_as_a_new_layout() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let same = rewrite(&mut worker, doc, index, "Hello world\nSecond line", "original");
|
||||
let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &same, 2.0).expect("previews") else { panic!("a preview") };
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!grew, "the same text in the same place");
|
||||
let big = Writing { text: "Hi".into(), size: 72.0, ..same };
|
||||
let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &big, 2.0).expect("previews") else { panic!("a preview") };
|
||||
assert!(grew, "fewer lines, much bigger glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_on_one_baseline_still_wrap_onto_new_lines() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "BT /F1 18 Tf 1 0 0 1 72 720 Tm (Hello) Tj 1 0 0 1 160 720 Tm (world) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (n, obj) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", n + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let path = dir.path().join("runs.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let block = worker.text_blocks(doc, 0).expect("blocks")[0].clone();
|
||||
let mut writing = rewrite(&mut worker, doc, 0, "One two three four five", "helvetica");
|
||||
writing.expected = block.text;
|
||||
writing.bx[2] = 50.0;
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mut tops: Vec<i32> = worker.text(doc, 0).expect("reads").iter().map(|c| c.rect[1].round() as i32).collect();
|
||||
tops.dedup();
|
||||
assert!(tops.len() >= 3, "each wrapped line has its own baseline: {tops:?}");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Page edits, undo and saving against the real worker (PDF-02).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Worker,
|
||||
sample::{Extras, pdf, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn open(worker: &mut Worker, dir: &Path, name: &str, bytes: &[u8]) -> Doc {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
worker.open(&path).expect("opens").doc
|
||||
}
|
||||
|
||||
/// Each page's text.
|
||||
fn texts(worker: &mut Worker, doc: Doc) -> Vec<String> {
|
||||
let count = worker.pages(doc).expect("counts");
|
||||
(0..count)
|
||||
.map(|page| worker.text(doc, page).expect("reads").iter().map(|c| c.c).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bookmark titles with their pages.
|
||||
fn marks(worker: &mut Worker, doc: Doc) -> Vec<(String, Option<usize>)> {
|
||||
worker.outline(doc).expect("the outline").into_iter().map(|m| (m.title, m.page)).collect()
|
||||
}
|
||||
|
||||
const LETTER: (f32, f32) = (612.0, 792.0);
|
||||
|
||||
fn three() -> Vec<u8> {
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])];
|
||||
let extras = Extras { links: &[(0, 1), (2, 1), (2, 0)], fields: &[1, 2], labels: &[(0, "r"), (1, "D")], ..Extras::default() };
|
||||
pdf_with(pages, &[("One", 0, &[]), ("Two", 1, &[]), ("Three", 2, &[])], &extras)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_drops_what_pointed_there_and_undoes() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let after = worker.delete_pages(doc, &[1]).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "three"]);
|
||||
let pointing = marks(&mut worker, doc);
|
||||
assert_eq!(pointing, [("One".into(), Some(0)), ("Two".into(), None), ("Three".into(), Some(1))]);
|
||||
let out = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(three)") && !saved.contains("(two)"), "the deleted page is gone from the file");
|
||||
assert_eq!(saved.matches("/Link").count(), 1, "only the link to page one stays");
|
||||
assert!(saved.contains("field1") && !saved.contains("field0"), "the field on the deleted page goes");
|
||||
let labels = saved.split("/Nums").nth(1).expect("labels").replace(char::is_whitespace, "");
|
||||
assert!(labels.starts_with("[0<</S/r>>1<</S/D>>]"), "{labels}");
|
||||
assert!(!dir.path().join("saved.pdf.raw").exists(), "the scratch copy goes");
|
||||
assert_eq!(worker.undo(doc, false).expect("undoes"), before);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(marks(&mut worker, doc)[1], ("Two".into(), Some(1)));
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), after);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_turns_and_crops_are_steps() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.move_pages(doc, &[2], 0).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["three", "one", "two"]);
|
||||
assert_eq!(marks(&mut worker, doc)[2], ("Three".into(), Some(0)), "bookmarks follow their page");
|
||||
worker.move_pages(doc, &[0, 1], 3).expect("moves to the end");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["two", "three", "one"]);
|
||||
worker.rotate_pages(doc, &[1], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]);
|
||||
// Turned a quarter, the text's left edge is on top.
|
||||
let top = |worker: &mut Worker, doc: Doc| worker.text(doc, 1).expect("reads")[0].rect[1];
|
||||
let was = top(&mut worker, doc);
|
||||
worker.crop_pages(doc, &[1], [50.0, 0.0, 0.0, 0.0]).expect("crops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 562.0]]);
|
||||
assert!((top(&mut worker, doc) - (was - 50.0)).abs() < 0.5, "the seen top was cut");
|
||||
worker.crop_pages(doc, &[1], [0.0; 4]).expect("uncrops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]);
|
||||
for _ in 0..5 {
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(worker.bounds(doc, 0, 3).expect("bounds"), [[0.0, 0.0, LETTER.0, LETTER.1]; 3]);
|
||||
assert!(worker.undo(doc, false).is_err(), "nothing left to undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_come_in_blank_or_from_a_file() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.insert_blank(doc, 1, (300.0, 400.0)).expect("inserts a blank page");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 300.0, 400.0]]);
|
||||
let other = dir.path().join("other.pdf");
|
||||
std::fs::write(&other, pdf(&[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])], &[])).expect("writes");
|
||||
let (count, _) = worker.insert_file(doc, 4, &other).expect("inserts a file");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "", "two", "three", "x", "y"]);
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_writes_some_pages_and_leaves_the_document() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.rotate_pages(doc, &[0], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
let out = dir.path().join("part.pdf");
|
||||
worker.extract(doc, &[0, 2], &out).expect("extracts");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert!(worker.undo(doc, true).is_err(), "the extract is not a redo");
|
||||
let part = worker.open(&out).expect("opens the part").doc;
|
||||
assert_eq!(texts(&mut worker, part), ["one", "three"]);
|
||||
assert_eq!(worker.bounds(part, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "with its edits");
|
||||
worker.undo(doc, false).expect("the turn undoes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_edits_are_refused_and_change_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["big"]), (100.0, 100.0, &["small"])];
|
||||
let doc = open(&mut worker, dir.path(), "two.pdf", &pdf(pages, &[]));
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
assert!(worker.delete_pages(doc, &[0, 1]).is_err(), "a PDF keeps a page");
|
||||
assert!(worker.delete_pages(doc, &[1, 0]).is_err(), "pages in order");
|
||||
assert!(worker.rotate_pages(doc, &[0], 45).is_err(), "quarter turns only");
|
||||
// Fine for the big page, too much for the small.
|
||||
assert!(worker.crop_pages(doc, &[0, 1], [200.0, 0.0, 0.0, 0.0]).is_err());
|
||||
assert_eq!(worker.bounds(doc, 0, 2).expect("bounds"), [[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 100.0, 100.0]], "rolled back");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark);
|
||||
assert!(worker.undo(doc, false).is_err(), "no step was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_after_an_undo_keeps_the_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let turned = worker.rotate_pages(doc, &[0], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("saved.pdf")).expect("saves");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.extract(doc, &[0], &dir.path().join("part.pdf")).expect("extracts");
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), turned);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "the turn is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_pages_keep_their_links_and_fields() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let other = dir.path().join("other.pdf");
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])];
|
||||
let extras = Extras { links: &[(0, 1)], fields: &[0], ..Extras::default() };
|
||||
std::fs::write(&other, pdf_with(pages, &[], &extras)).expect("writes");
|
||||
worker.insert_file(doc, 3, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = |worker: &mut Worker, doc: Doc, name: &str| {
|
||||
let out = dir.path().join(name);
|
||||
worker.save(doc, &out).expect("saves");
|
||||
String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned()
|
||||
};
|
||||
let merged = saved(&mut worker, doc, "merged.pdf");
|
||||
assert_eq!(merged.matches("/Link").count(), 4, "three links of its own, one inserted");
|
||||
assert!(merged.contains("(field0 2)") && merged.contains("(value0)"), "a clashing field is renamed, its value kept");
|
||||
// The inserted link points at y: deleting y drops it.
|
||||
worker.delete_pages(doc, &[4]).expect("deletes y");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(saved(&mut worker, doc, "less.pdf").matches("/Link").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_without_its_page_named_still_goes_with_the_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"])];
|
||||
let extras = Extras { fields: &[1], loose: true, ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "loose.pdf", &pdf_with(pages, &[], &extras));
|
||||
worker.delete_pages(doc, &[1]).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(!saved.contains("value0"), "the field and its value are gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_margins_are_points_whatever_the_user_unit() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let extras = Extras { unit: Some(2.0), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "big.pdf", &pdf_with(&[(612.0, 792.0, &["x"])], &[], &extras));
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1584.0]]);
|
||||
worker.crop_pages(doc, &[0], [72.0, 0.0, 0.0, 0.0]).expect("crops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1512.0]]);
|
||||
}
|
||||
|
||||
/// Runs `step` on its own thread, ten seconds at most.
|
||||
fn bounded<T: Send + 'static>(step: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (done, answer) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || done.send(step()).expect("the test waits"));
|
||||
answer.recv_timeout(std::time::Duration::from_secs(10)).expect("done in time")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_clashing_field_names_still_get_a_number() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let long = "a".repeat(255);
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let extras = Extras { fields: &[0], prefix: Some(&long), ..Extras::default() };
|
||||
let (mine, other) = (dir.path().join("mine.pdf"), dir.path().join("other.pdf"));
|
||||
std::fs::write(&mine, pdf_with(page, &[], &extras)).expect("writes");
|
||||
std::fs::write(&other, pdf_with(page, &[], &extras)).expect("writes");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let saved = out.clone();
|
||||
bounded(move || {
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&mine).expect("opens").doc;
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
});
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains(&format!("({long}0 2)")), "the second is numbered");
|
||||
}
|
||||
|
||||
/// Red pixels of a page at one pixel a point.
|
||||
fn red(worker: &mut Worker, doc: Doc, page: usize) -> usize {
|
||||
let pixels = worker.render(doc, page, (1.0, 0), [0, 0, 612, 792], false).expect("renders").bgra;
|
||||
pixels.chunks_exact(4).filter(|p| p[2] > 180 && p[1] < 80 && p[0] < 80).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_fields_keep_the_look_of_their_form() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let other = dir.path().join("other.pdf");
|
||||
std::fs::write(&other, pdf_with(page, &[], &Extras { fields: &[0], look: Some(("Custom", "Custom")), ..Extras::default() })).expect("writes");
|
||||
let source = worker.open(&other).expect("opens").doc;
|
||||
let shown = red(&mut worker, source, 0);
|
||||
assert!(shown > 100, "the source draws its value red: {shown}");
|
||||
// Its own form gives the font name another font.
|
||||
let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine));
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let merged = worker.open(&out).expect("opens").doc;
|
||||
assert_eq!(red(&mut worker, merged, 1), shown, "the same red, the same size");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the clashing font is renamed where it is used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_font_name_and_the_form_alignment_come_along() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let other = dir.path().join("other.pdf");
|
||||
let theirs = Extras { fields: &[0], look: Some(("Custom", "C#75stom")), align: Some(2), ..Extras::default() };
|
||||
std::fs::write(&other, pdf_with(page, &[], &theirs)).expect("writes");
|
||||
let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), align: Some(0), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine));
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the escaped name is renamed too");
|
||||
assert!(saved.contains("/Q 2"), "right-aligned as in its own form");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Ely's edits read back by another reader, PDFKit (QA-13).
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{FieldKind, NewField, Worker, Writing, Wrote, sample::{Extras, pdf_with}};
|
||||
use objc2::AllocAnyThread as _;
|
||||
use objc2_foundation::{NSString, NSURL};
|
||||
use objc2_pdf_kit::PDFDocument;
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Each page's text and widget values, as PDFKit reads them.
|
||||
fn read_back(path: &Path) -> Vec<(String, Vec<String>)> {
|
||||
let url = NSURL::fileURLWithPath(&NSString::from_str(path.to_str().expect("UTF-8")));
|
||||
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }.expect("PDFKit opens it");
|
||||
(0..unsafe { document.pageCount() })
|
||||
.map(|at| {
|
||||
let page = unsafe { document.pageAtIndex(at) }.expect("a page");
|
||||
let text = unsafe { page.string() }.map(|s| s.to_string()).unwrap_or_default();
|
||||
let values = unsafe { page.annotations() }.to_vec().into_iter().filter_map(|a| unsafe { a.widgetStringValue() }.map(|s| s.to_string())).collect();
|
||||
(text, values)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_a_field_and_page_order_read_back_elsewhere() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let block = worker.text_blocks(doc, 0).expect("blocks").into_iter().position(|b| b.text.contains("First page")).expect("the block");
|
||||
let writing = Writing {
|
||||
block: Some(block),
|
||||
expected: "First page".into(),
|
||||
text: "Changed words".into(),
|
||||
font: "helvetica".into(),
|
||||
size: 12.0,
|
||||
rgb: 0,
|
||||
bx: [72.0, 60.0, 400.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let field = NewField { kind: FieldKind::Text, name: "name".into(), rect: [72.0, 200.0, 272.0, 220.0], options: Vec::new() };
|
||||
worker.add_field(doc, 0, &field).expect("adds a field");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[0].id;
|
||||
worker.fill(doc, 0, id, "Ada").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.move_pages(doc, &[1], 0).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("edited.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let pages = read_back(&out);
|
||||
assert_eq!(pages.len(), 2);
|
||||
assert!(pages[0].0.contains("Second page"), "{pages:?}");
|
||||
assert!(pages[1].0.contains("Changed words") && !pages[1].0.contains("First page"), "{pages:?}");
|
||||
assert_eq!(pages[1].1, vec!["Ada".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Annotations and form fields against the real worker (PDF-05, PDF-06).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, FieldKind, NewAnnot, NewField, Shape, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// A Letter page of two lines and a text field.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let extras = Extras { fields: &[0], ..Extras::default() };
|
||||
std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn note(page: usize, shape: Shape) -> NewAnnot {
|
||||
NewAnnot { page, shape, rgb: None, contents: String::new(), author: "Ann".into() }
|
||||
}
|
||||
|
||||
/// Whether page one's pixels `at` hold colour `bgra`.
|
||||
fn has_color(worker: &mut Worker, doc: Doc, at: [i32; 4], bgra: [u8; 3]) -> bool {
|
||||
let pixels = worker.render(doc, 0, (1.0, 0), at, false).expect("draws").bgra;
|
||||
pixels.chunks_exact(4).any(|p| p[..3].iter().zip(bgra).all(|(a, b)| a.abs_diff(b) < 40))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_lands_in_one_step_and_reads_back() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello: Vec<_> = worker.text(doc, 0).expect("text").into_iter().take(5).collect();
|
||||
let line = [hello[0].rect[0], hello[0].rect[1], hello[4].rect[2], hello[4].rect[3]];
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let mut noted = note(0, Shape::Note((300.0, 300.0)));
|
||||
noted.contents = "Check this".into();
|
||||
let mut marked = note(0, Shape::Highlight(vec![line]));
|
||||
marked.rgb = Some(0x00ff00);
|
||||
let all = [
|
||||
marked,
|
||||
note(0, Shape::Underline(vec![line])),
|
||||
note(0, Shape::StrikeOut(vec![line])),
|
||||
noted,
|
||||
note(0, Shape::Square([100.0, 400.0, 200.0, 450.0])),
|
||||
note(0, Shape::Circle([250.0, 400.0, 350.0, 450.0])),
|
||||
note(0, Shape::Line([100.0, 500.0, 300.0, 520.0])),
|
||||
note(0, Shape::Ink(vec![vec![(100.0, 600.0), (150.0, 620.0), (200.0, 600.0)]])),
|
||||
note(0, Shape::Stamp([350.0, 550.0, 540.0, 600.0], "Approved".into())),
|
||||
];
|
||||
let after = worker.annotate(doc, &all).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").annots;
|
||||
let kinds: Vec<&str> = read.iter().map(|a| a.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["Highlight", "Underline", "StrikeOut", "Text", "Square", "Circle", "Line", "Ink", "Stamp"]);
|
||||
assert_eq!((read[0].rgb, read[3].contents.as_str(), read[3].author.as_str()), (Some(0x00ff00), "Check this", "Ann"));
|
||||
assert_eq!(read[8].icon, "Approved");
|
||||
assert!(has_color(&mut worker, doc, [line[0] as i32, line[1] as i32, 20, 10], [0, 255, 0]), "the highlight is drawn");
|
||||
assert_eq!(worker.undo(doc, false).expect("undoes"), before, "one step");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty());
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), after);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.marks(reopened, 0).expect("marks").annots.len(), 9, "kept in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_annotation_changes_nothing_and_keeps_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.annotate(doc, &[note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))]).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let good = note(0, Shape::Circle([100.0, 100.0, 200.0, 200.0]));
|
||||
for bad in [
|
||||
note(0, Shape::Stamp([100.0, 100.0, 200.0, 200.0], "Hacked".into())),
|
||||
note(0, Shape::Highlight(Vec::new())),
|
||||
note(0, Shape::Ink(vec![vec![(1.0, 1.0)]])),
|
||||
note(3, Shape::Note((10.0, 10.0))),
|
||||
] {
|
||||
assert!(worker.annotate(doc, &[good.clone(), bad.clone()]).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "the good one did not land either");
|
||||
worker.undo(doc, true).expect("redo survives");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").annots[0].kind, "Square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_change_and_go_by_their_number() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let two = [note(0, Shape::Note((50.0, 50.0))), note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))];
|
||||
worker.annotate(doc, &two).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").annots;
|
||||
worker.change_annot(doc, 0, read[0].id, Some(0x0000ff), Some("Done")).expect("changes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.delete_annot(doc, 0, read[1].id).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = worker.marks(doc, 0).expect("marks").annots;
|
||||
assert_eq!(now.len(), 1);
|
||||
assert_eq!((now[0].id, now[0].rgb, now[0].contents.as_str()), (read[0].id, Some(0x0000ff), "Done"));
|
||||
assert!(worker.delete_annot(doc, 0, read[1].id).is_err(), "gone already");
|
||||
worker.undo(doc, false).expect("undoes the delete");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").annots[1].id, read[1].id, "the same object back");
|
||||
}
|
||||
|
||||
fn field(kind: FieldKind, name: &str, rect: [f32; 4]) -> NewField {
|
||||
let options = match kind {
|
||||
FieldKind::Choice => vec!["Red".into(), "Blue".into()],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
NewField { kind, name: name.into(), rect, options }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_fields_fill_and_keep_their_values() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
for new in [
|
||||
field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0]),
|
||||
field(FieldKind::Checkbox, "Agree", [72.0, 340.0, 86.0, 354.0]),
|
||||
field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0]),
|
||||
field(FieldKind::Radio, "Size", [100.0, 370.0, 114.0, 384.0]),
|
||||
field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0]),
|
||||
] {
|
||||
worker.add_field(doc, 0, &new).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "an empty box");
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
let names: Vec<(&str, FieldKind)> = fields.iter().map(|f| (f.name.as_str(), f.kind)).collect();
|
||||
assert_eq!(names, [
|
||||
("field0", FieldKind::Text),
|
||||
("Name", FieldKind::Text),
|
||||
("Agree", FieldKind::Checkbox),
|
||||
("Size", FieldKind::Radio),
|
||||
("Size", FieldKind::Radio),
|
||||
("Color", FieldKind::Choice),
|
||||
]);
|
||||
assert_ne!(fields[3].on, fields[4].on, "each radio has its own state");
|
||||
assert_eq!(fields[5].options, ["Red", "Blue"]);
|
||||
worker.fill(doc, 0, fields[0].id, "changed").expect("fills the old field");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[1].id, "Zoe").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[2].id, &fields[2].on).expect("checks");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[3].id, &fields[3].on).expect("picks");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[4].id, &fields[4].on).expect("picks the other");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[5].id, "Blue").expect("chooses");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.fill(doc, 0, fields[3].id, "Nonsense").is_err(), "a state the button lacks");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
let read = other.marks(reopened, 0).expect("marks").fields;
|
||||
let values: Vec<(&str, bool)> = read.iter().map(|f| (f.value.as_str(), f.checked)).collect();
|
||||
assert_eq!(values[..2], [("changed", false), ("Zoe", false)]);
|
||||
assert!(read[2].checked && !read[3].checked && read[4].checked, "one radio of the group");
|
||||
assert_eq!(read[5].value, "Blue");
|
||||
assert!(has_color(&mut other, reopened, [76, 304, 190, 12], [0, 0, 0]), "the typed name is drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_names_stay_unique_and_groups_go_with_their_last_button() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "field0", [72.0, 300.0, 272.0, 320.0])).is_err(), "taken");
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Radio, "field0", [72.0, 300.0, 86.0, 314.0])).is_err(), "not a group");
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "a.b", [72.0, 300.0, 272.0, 320.0])).is_err(), "a dotted name");
|
||||
let mut empty = field(FieldKind::Choice, "Pick", [72.0, 300.0, 272.0, 320.0]);
|
||||
empty.options.clear();
|
||||
assert!(worker.add_field(doc, 0, &empty).is_err(), "a choice of nothing");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
assert!(worker.change_field(doc, 0, fields[1].id, Some("field0"), None).is_err(), "renamed onto another");
|
||||
worker.change_field(doc, 0, fields[0].id, Some("Title"), None).expect("renames");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.change_field(doc, 0, fields[0].id, None, Some(&["x".into()])).is_err(), "text has no options");
|
||||
worker.delete_field(doc, 0, fields[1].id).expect("deletes the only button");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "Size", [72.0, 300.0, 272.0, 320.0])).expect("the group is gone");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let names: Vec<String> = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, ["Title", "Size"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_choice_takes_new_options_and_drops_a_lost_value() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.add_field(doc, 0, &field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[1].id;
|
||||
worker.fill(doc, 0, id, "Red").expect("chooses");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.change_field(doc, 0, id, None, Some(&["Red".into(), "Green".into()])).expect("keeps red");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").fields[1].value, "Red");
|
||||
worker.change_field(doc, 0, id, None, Some(&["Green".into()])).expect("drops red");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").fields[1].clone();
|
||||
assert_eq!((read.value.as_str(), read.options), ("", vec!["Green".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_value_stays_drawn_through_undo_and_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[1].id;
|
||||
worker.fill(doc, 0, id, "Zoe").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, true).expect("redoes before a draw");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "gone");
|
||||
worker.undo(doc, true).expect("redoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_field_renames_within_its_parent() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { fields: &[0, 0], parent: Some("Person"), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Person.field0", "Person.field1"]);
|
||||
assert!(worker.change_field(doc, 0, fields[0].id, Some("field1"), None).is_err(), "a sibling's name");
|
||||
worker.change_field(doc, 0, fields[0].id, Some("First"), None).expect("renames");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "First", [72.0, 300.0, 272.0, 320.0])).expect("a top field of the same leaf");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let names: Vec<String> = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, ["Person.First", "Person.field1", "First"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_name_is_listed_widget_or_not() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { fields: &[0], bare: Some(("Text1", "Child")), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
assert_eq!(worker.field_names(doc).expect("names"), ["field0", "Text1", "Text1.Child"]);
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "Text1", [72.0, 300.0, 272.0, 320.0])).is_err(), "the bare parent holds it");
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! OCR and the hidden text layer, real worker (PDF-08).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, OcrLine, Worker, Writing, Wrote,
|
||||
sample::{pdf, scan},
|
||||
tessdata,
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Points a pixel when scanning.
|
||||
const SCALE: f32 = 200.0 / 72.0;
|
||||
|
||||
/// Page one of `doc`, saved as a gray scan.
|
||||
fn scan_of(worker: &mut Worker, doc: Doc, out: PathBuf) -> PathBuf {
|
||||
let (w, h) = ((612.0 * SCALE).ceil() as i32, (792.0 * SCALE).ceil() as i32);
|
||||
let bgra = worker.render(doc, 0, (SCALE, 0), [0, 0, w, h], false).expect("draws").bgra;
|
||||
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
|
||||
std::fs::write(&out, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
|
||||
out
|
||||
}
|
||||
|
||||
/// A scan of two English lines, opened.
|
||||
fn english(dir: &Path) -> (Worker, Doc) {
|
||||
let source = dir.join("text.pdf");
|
||||
std::fs::write(&source, pdf(&[(612.0, 792.0, &["Hello world", "Second line"])], &[])).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&source).expect("opens").doc;
|
||||
let path = scan_of(&mut worker, doc, dir.join("scan.pdf"));
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn words(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_reads_and_its_text_layer_finds_without_drawing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
assert_eq!(words(&mut worker, doc), "", "a scan has no text");
|
||||
let lines = worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).expect("reads");
|
||||
let read: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
|
||||
assert!(read.iter().any(|l| l.contains("Hello world")) && read.iter().any(|l| l.contains("Second line")), "{read:?}");
|
||||
let hello = lines.iter().find(|l| l.text.contains("Hello")).expect("hello");
|
||||
assert!((hello.rect[0] - 72.0).abs() < 6.0 && (hello.rect[3] - 76.0).abs() < 8.0, "in page points: {:?}", hello.rect);
|
||||
let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra;
|
||||
let mark = worker.text_layer(doc, &[(0, lines)], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(words(&mut worker, doc).contains("Hello world"), "searchable now");
|
||||
assert_eq!(worker.search(doc, 0, "second line").expect("finds").len(), 1);
|
||||
assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra == before, "nothing drawn");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(words(&mut worker, doc), "", "one step");
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), mark);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert!(words(&mut other, reopened).contains("Hello world"), "kept in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_layer_replaces_the_last_or_adds_beside_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays again");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 2, "two layers");
|
||||
worker.text_layer(doc, &[(0, vec![line])], true).expect("replaces");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 1, "old layers gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_layer_changes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let good = (0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "fine".into() }]);
|
||||
for bad in [
|
||||
(0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "𠀀".into() }]),
|
||||
(0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: String::new() }]),
|
||||
(5, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "x".into() }]),
|
||||
] {
|
||||
assert!(worker.text_layer(doc, &[good.clone(), bad.clone()], true).is_err(), "{bad:?}");
|
||||
}
|
||||
assert_eq!((worker.history(doc).expect("a mark"), words(&mut worker, doc)), (before, String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_reads_in_both_scripts() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = dir.path().join("blank.pdf");
|
||||
std::fs::write(&source, pdf(&[(612.0, 792.0, &[])], &[])).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&source).expect("opens").doc;
|
||||
for (font, text, at) in [("cjk-sc", "简体中文识别", 100.0), ("cjk-tc", "繁體中文識別", 300.0)] {
|
||||
let writing = Writing { block: None, expected: String::new(), text: text.into(), font: font.into(), size: 36.0, rgb: 0, bx: [72.0, at, 400.0] };
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
let path = scan_of(&mut worker, doc, dir.path().join("scan.pdf"));
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
let lines = worker.ocr_page(doc, 0, "chi_sim+chi_tra+eng", &tessdata().expect("data"), 300.0).expect("reads");
|
||||
let read: String = lines.iter().map(|l| l.text.replace(' ', "")).collect();
|
||||
assert!(read.contains("简体中文") && read.contains("繁體中文"), "{read}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_language_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let error = worker.ocr_page(doc, 0, "xyz", &tessdata().expect("data"), 300.0).expect_err("no such data");
|
||||
assert!(error.to_string().contains("Tesseract"), "{error}");
|
||||
assert!(worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).is_ok(), "the worker goes on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_that_does_not_load_fails_the_read() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let data = dir.path().join("tessdata");
|
||||
std::fs::create_dir(&data).expect("a data dir");
|
||||
std::fs::copy(tessdata().expect("data").join("eng.traineddata"), data.join("eng.traineddata")).expect("copies");
|
||||
let error = worker.ocr_page(doc, 0, "eng+chi_sim", &data, 300.0).expect_err("chi_sim is missing");
|
||||
assert!(error.to_string().contains("1 of 2 languages"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_added_after_a_layer_still_shows() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra;
|
||||
let writing = Writing { block: None, expected: String::new(), text: "VISIBLE".into(), font: "helvetica".into(), size: 24.0, rgb: 0, bx: [72.0, 600.0, 300.0] };
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra != before, "the new text is drawn");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Images, text, optimized copies, printing leave, repaired files (C11.8).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Optimizing, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, one_page, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn two_pages(dir: &Path) -> std::path::PathBuf {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_exports_as_a_png_of_its_size() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let png = dir.path().join("page.png");
|
||||
worker.export_image(doc, 1, 144.0, &png).expect("exports");
|
||||
let bytes = std::fs::read(&png).expect("reads");
|
||||
assert_eq!(&bytes[1..4], b"PNG");
|
||||
let size = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().expect("four bytes"));
|
||||
assert_eq!((size(16), size(20)), (1224, 1584), "twice the points");
|
||||
assert!(worker.export_image(doc, 0, 5000.0, &png).is_err(), "an absurd resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_exports_page_by_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let txt = dir.path().join("doc.txt");
|
||||
worker.export_text(doc, &txt).expect("exports");
|
||||
let text = std::fs::read_to_string(&txt).expect("UTF-8");
|
||||
let pages: Vec<&str> = text.split('\u{c}').collect();
|
||||
assert_eq!(pages.len(), 2);
|
||||
assert!(pages[0].contains("First page") && pages[1].contains("Second page"), "{text:?}");
|
||||
}
|
||||
|
||||
/// A 600-pixel picture drawn 100 points wide: 432 dpi.
|
||||
fn heavy(dir: &Path) -> std::path::PathBuf {
|
||||
let pixels = vec![120u8; 600 * 600 * 3];
|
||||
let mut picture = format!("<< /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", pixels.len()).into_bytes();
|
||||
picture.extend_from_slice(&pixels);
|
||||
picture.extend_from_slice(b"\nendstream");
|
||||
let path = dir.join("heavy.pdf");
|
||||
let mut bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &["PICTURE".to_string()]);
|
||||
let at = bytes.windows(7).position(|w| w == b"PICTURE").expect("the slot");
|
||||
bytes.splice(at..at + 7, picture);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optimized_copy_is_smaller_and_its_picture_downsampled() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = heavy(dir.path());
|
||||
let (mut worker, doc) = opened(&source);
|
||||
let out = dir.path().join("small.pdf");
|
||||
worker.optimize(doc, &out, Optimizing { compress: true, from: 300, to: 150 }).expect("optimizes");
|
||||
let (before, after) = (std::fs::metadata(&source).expect("size").len(), std::fs::metadata(&out).expect("size").len());
|
||||
assert!(after * 10 < before, "{before} -> {after}");
|
||||
let mut other = self::worker();
|
||||
let read = other.open(&out).expect("opens").doc;
|
||||
assert_eq!(other.pages(read).expect("pages"), 1);
|
||||
let text = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
let width: u32 = text.split("/Width ").nth(1).and_then(|rest| rest.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).expect("a width");
|
||||
assert!((205..=212).contains(&width), "150 dpi over 100 points, not {width}");
|
||||
assert!(worker.optimize(doc, &out, Optimizing { compress: true, from: 150, to: 300 }).is_err(), "up is not down");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_and_copying_need_leave() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let limited = dir.path().join("limited.pdf");
|
||||
worker.protect(doc, &limited, Some(&Lock { user: String::new(), owner: "owner".into(), allow: may::EDIT })).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
for refused in [other.print_copy(read, &dir.path().join("p.pdf")), other.export_text(read, &dir.path().join("t.txt"))] {
|
||||
assert_eq!(refused.expect_err("refused").kind(), ErrorKind::PermissionDenied);
|
||||
}
|
||||
worker.print_copy(doc, &dir.path().join("print.pdf")).expect("an unprotected file prints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_opens_as_a_repaired_copy_and_stays_as_it_was() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = two_pages(dir.path());
|
||||
let mut bytes = std::fs::read(&path).expect("reads");
|
||||
// Breaks every cross-reference offset.
|
||||
let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer");
|
||||
bytes.truncate(at);
|
||||
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
let report = worker.repair_report(doc).expect("asks").expect("repaired");
|
||||
assert!(!report.trim().is_empty(), "MuPDF says what it found");
|
||||
assert_eq!(worker.pages(doc).expect("pages"), 2);
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits the copy");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the damaged original stays");
|
||||
let elsewhere = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut fresh, clean) = opened(&two_pages(elsewhere.path()));
|
||||
assert_eq!(fresh.repair_report(clean).expect("asks"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_object_with_a_sound_trailer_is_repaired_on_open() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = two_pages(dir.path());
|
||||
let mut bytes = std::fs::read(&path).expect("reads");
|
||||
// The page tree's offset points nowhere; startxref is sound.
|
||||
let table = bytes.windows(6).position(|w| w == b"\nxref\n").expect("the table") + 6;
|
||||
let entries = table + bytes[table..].iter().position(|b| *b == b'\n').expect("a header") + 1;
|
||||
let at = entries + 2 * 20;
|
||||
bytes[at..at + 10].copy_from_slice(b"0000000009");
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
assert!(worker.repair_report(doc).expect("asks").is_some(), "found at open, not mid-edit");
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_stream_under_a_sound_table_is_damage_and_fails_exports() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let broken = "<< /Length 12 /Filter /FlateDecode >>\nstream\nnot a zlib!!\nendstream".to_string();
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let bytes = one_page("", " /Contents 5 0 R", "", &[broken]);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
let report = worker.repair_report(doc).expect("asks").expect("damage is reported");
|
||||
assert!(!report.trim().is_empty());
|
||||
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "partial text is no export");
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits the copy");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_copy_carries_no_lock() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let locked = dir.path().join("locked.pdf");
|
||||
worker.protect(doc, &locked, Some(&Lock { user: "user".into(), owner: "owner".into(), allow: may::PRINT })).expect("protects");
|
||||
let (mut other, read) = opened(&locked);
|
||||
assert!(other.unlock(read, "user").expect("tries"));
|
||||
let print = dir.path().join("print.pdf");
|
||||
other.print_copy(read, &print).expect("writes");
|
||||
let mut third = self::worker();
|
||||
let opened = third.open(&print).expect("opens");
|
||||
assert!(!opened.locked, "the printer needs no password");
|
||||
assert_eq!(third.pages(opened.doc).expect("pages"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_picture_shows_when_drawn_or_checked() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let picture = "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length 12 >>\nstream\nnot a zlib!!\nendstream".to_string();
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &[picture]);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, doc) = opened(&path);
|
||||
assert_eq!(worker.repair_report(doc).expect("asks"), None, "pictures wait to be drawn");
|
||||
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
|
||||
assert!(tile.damaged, "drawing finds it");
|
||||
assert!(worker.repair_report(doc).expect("asks").is_some());
|
||||
let (mut other, again) = opened(&path);
|
||||
assert!(other.check_pictures(again).expect("checks"), "the check finds it undrawn");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Worker records reach the app log; document text never.
|
||||
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_pdf_engine::{LinkChange, Target, Worker, sample::pdf};
|
||||
|
||||
/// Every record the app would log, kept for the test.
|
||||
struct Kept;
|
||||
|
||||
fn kept() -> &'static Mutex<Vec<String>> {
|
||||
static KEPT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
KEPT.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
impl log::Log for Kept {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
kept().lock().unwrap().push(format!("{} {} {}", record.level(), record.target(), record.args()));
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_stream_keeps_its_text_out_of_the_log() {
|
||||
log::set_boxed_logger(Box::new(Kept)).unwrap();
|
||||
log::set_max_level(log::LevelFilter::Trace);
|
||||
// The worker logs at debug; the relay filters.
|
||||
unsafe { std::env::set_var("ELY_LOG", "debug") };
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("broken.pdf");
|
||||
// An unknown operator: MuPDF warns, quoting it.
|
||||
let line = "ZqxPrivate42) Tj ZqxPrivate42 (tail";
|
||||
std::fs::write(&path, pdf(&[(612.0, 792.0, &[line])], &[])).unwrap();
|
||||
let mut worker = Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).unwrap();
|
||||
let opened = worker.open(&path).unwrap();
|
||||
let _ = worker.text(opened.doc, 0);
|
||||
// A refused address holds a credential, unquoted.
|
||||
let secret = Target::Web("ftp://ana:ZqxPrivate42@files.example/document".into());
|
||||
let at = worker.now(opened.doc).unwrap();
|
||||
assert!(worker.change_link(at, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: secret }).is_err());
|
||||
worker.close(opened.doc).unwrap();
|
||||
drop(worker);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let records = kept().lock().unwrap().clone();
|
||||
assert!(records.iter().any(|r| r.contains("native output withheld")), "MuPDF spoke: {records:#?}");
|
||||
assert!(records.iter().any(|r| r.contains("ely_pdf_worker") || r.contains("pdf-worker")), "the worker's own records pass");
|
||||
let leaked: Vec<_> = records.iter().filter(|r| r.contains("ZqxPrivate42")).collect();
|
||||
assert!(leaked.is_empty(), "document text reached the log: {leaked:#?}");
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Removal and cleaning, checked in the file (QA-14).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Cleaning, Doc, NewAnnot, OcrLine, Region, Shape, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Two lines, a picture at 72..172 across, 242..292 down.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let extras = Extras { picture: true, ..Extras::default() };
|
||||
std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn words(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
/// The box of the first `n` characters.
|
||||
fn first(worker: &mut Worker, doc: Doc, n: usize) -> [f32; 4] {
|
||||
let chars = worker.text(doc, 0).expect("text");
|
||||
let (a, b) = (chars[0].rect, chars[n - 1].rect);
|
||||
[a[0] - 1.0, a[1] - 1.0, b[2] + 0.5, b[3] + 1.0]
|
||||
}
|
||||
|
||||
fn reopen(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_text_is_gone_from_the_file_and_the_rest_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello = first(&mut worker, doc, 5);
|
||||
let line = OcrLine { rect: hello, text: "Hidden".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line])], false).expect("an invisible layer over it");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let report = worker.redact(doc, &[Region { page: 0, rect: hello }], &out).expect("removes");
|
||||
assert_eq!((report.text, report.light, report.versions), (0, 0, 1));
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark, "the open document is untouched");
|
||||
assert!(words(&mut worker, doc).contains("Hello"));
|
||||
let (mut other, written) = reopen(&out);
|
||||
let left = words(&mut other, written);
|
||||
assert!(!left.contains("Hello") && !left.contains("Hidden"), "{left}");
|
||||
assert!(left.contains("world") && left.contains("Second line"), "{left}");
|
||||
let bytes = std::fs::read(&out).expect("reads");
|
||||
assert!(!bytes.windows(5).any(|w| w == b"Hello"), "no trace in the bytes");
|
||||
assert_eq!(bytes.windows(9).filter(|w| w == b"startxref").count(), 1, "one revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_under_a_region_loses_those_pixels() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [72.0, 242.0, 122.0, 292.0] }], &out).expect("removes");
|
||||
let (mut other, written) = reopen(&out);
|
||||
let left = other.render(written, 0, (1.0, 0), [124, 244, 46, 46], false).expect("draws").bgra;
|
||||
assert!(left.chunks_exact(4).any(|p| p[0] > 100 || p[1] > 100 || p[2] > 100), "the right half keeps its colour");
|
||||
let gone = other.render(written, 0, (1.0, 0), [74, 244, 46, 46], false).expect("draws").bgra;
|
||||
assert!(gone.chunks_exact(4).all(|p| p[0] < 40 && p[1] < 40 && p[2] < 40), "the left half is black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_in_a_region_go_and_others_stay() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let note = |at| NewAnnot { page: 0, shape: Shape::Note(at), rgb: None, contents: "secret".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note((80.0, 400.0)), note((400.0, 600.0))]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [70.0, 390.0, 120.0, 430.0] }], &out).expect("removes");
|
||||
let (mut other, written) = reopen(&out);
|
||||
let kept = other.marks(written, 0).expect("marks").annots;
|
||||
assert_eq!(kept.len(), 1, "the far note stays");
|
||||
assert!(kept[0].rect[0] > 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_regions_write_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let out = dir.path().join("out.pdf");
|
||||
assert!(worker.redact(doc, &[], &out).is_err());
|
||||
assert!(worker.redact(doc, &[Region { page: 4, rect: [0.0, 0.0, 10.0, 10.0] }], &out).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleaning_reaches_what_it_counted_and_leaves_none() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.set_properties(doc, &[("Title", "Secret plan"), ("Author", "Zoe")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "attached").expect("writes");
|
||||
worker.attach(doc, "a.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.text_layer(doc, &[(0, vec![OcrLine { rect: [72.0, 600.0, 200.0, 620.0], text: "invisible".into() }])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert!(reach.meta >= 2 && reach.annots == 1 && reach.files == 1 && reach.hidden == 1, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let all = Cleaning { meta: true, annots: true, files: true, hidden: true };
|
||||
let left = worker.clean(doc, all, &out).expect("cleans");
|
||||
assert_eq!((left.meta, left.annots, left.files, left.hidden, left.scripts), (0, 0, 0, 0, 0));
|
||||
let (mut other, written) = reopen(&out);
|
||||
assert_eq!(other.properties(written).expect("reads").title, None);
|
||||
assert!(other.attachments(written).expect("lists").is_empty());
|
||||
assert!(!words(&mut other, written).contains("invisible"));
|
||||
assert!(words(&mut other, written).contains("Hello world"), "seen text stays");
|
||||
assert_eq!(worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"), reach, "the open document is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleaning_one_kind_leaves_the_others() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.set_properties(doc, &[("Title", "Plan")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let left = worker.clean(doc, Cleaning { annots: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert_eq!(left.annots, 0);
|
||||
assert!(left.meta >= 1, "properties were not asked for");
|
||||
assert!(worker.clean(doc, Cleaning::default(), &dir.path().join("none.pdf")).is_err(), "nothing chosen");
|
||||
}
|
||||
|
||||
/// Helvetica as object 5, for the hand-made pages.
|
||||
fn helvetica() -> String {
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()
|
||||
}
|
||||
|
||||
fn written(dir: &Path, bytes: Vec<u8>) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
reopen(&path)
|
||||
}
|
||||
|
||||
fn holds(path: &Path, needle: &str) -> bool {
|
||||
std::fs::read(path).expect("reads").windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_touching_a_region_goes_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", "", "1 0 0 rg 100 700 200 40 re f", &[]));
|
||||
let out = dir.path().join("out.pdf");
|
||||
let report = worker.redact(doc, &[Region { page: 0, rect: [90.0, 45.0, 180.0, 100.0] }], &out).expect("removes");
|
||||
assert_eq!(report.objects, 0);
|
||||
let (mut other, read) = reopen(&out);
|
||||
let right = other.render(read, 0, (1.0, 0), [200, 55, 90, 30], false).expect("draws").bgra;
|
||||
assert!(right.chunks_exact(4).all(|p| p[2] < 200 || p[1] > 200), "no red left past the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_in_a_region_takes_its_parent_and_value() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /FT /Tx /T (parent) /V (SECRET-FIELD-VALUE) /Kids [6 0 R] >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [70 700 220 740] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [5 0 R] >>", " /Annots [6 0 R]", "", &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 40.0, 230.0, 100.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, "SECRET-FIELD-VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_layers_faint_text_and_hidden_notes_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "BT /F1 12 Tf 72 700 Td (VISIBLE) Tj ET \
|
||||
/OC /H BDC BT /F1 12 Tf 72 680 Td (SECRET-OFF-LAYER) Tj ET EMC \
|
||||
q /G0 gs BT /F1 12 Tf 72 660 Td (SECRET-FAINT) Tj ET Q";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /OCG /Name (Hidden) >>".to_string(),
|
||||
"<< /Type /ExtGState /ca 0 >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Text /F 2 /Rect [300 300 320 320] /Contents (SECRET-HIDDEN-NOTE) >>".to_string(),
|
||||
];
|
||||
let catalog = " /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >>";
|
||||
let page = " /Resources << /Font << /F1 5 0 R >> /Properties << /H 6 0 R >> /ExtGState << /G0 7 0 R >> >> /Annots [8 0 R]";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, page, content, &objects));
|
||||
let scratch = dir.path().join("scan.pdf");
|
||||
let reach = worker.clean_scan(doc, &scratch).expect("scans");
|
||||
assert_eq!((reach.hidden, reach.hidden_annots, reach.annots), (1, 1, 0), "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans without /Names");
|
||||
for secret in ["SECRET-OFF-LAYER", "SECRET-FAINT", "SECRET-HIDDEN-NOTE"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
assert!(holds(&out, "VISIBLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_on_pages_cleans_out_too() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let xmp = "<x:xmpmeta>SECRET-PAGE-METADATA</x:xmpmeta>";
|
||||
let objects = [format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Metadata 5 0 R", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.meta, 1);
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { meta: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-PAGE-METADATA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_content_says_nothing_after_removal() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /MCID 0 /ActualText (SECRET-ACTUAL) >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(),
|
||||
"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /Alt (SECRET-STRUCT) /E (SECRET-EXPANDED) >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes");
|
||||
for secret in ["SECRET-ACTUAL", "SECRET-STRUCT", "SECRET-EXPANDED", "Hello"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_regions_both_remove() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello = first(&mut worker, doc, 5);
|
||||
let after = [hello[2] - 2.0, hello[1], hello[2] + 60.0, hello[3]];
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: hello }, Region { page: 0, rect: after }], &out).expect("removes");
|
||||
let (mut other, read) = reopen(&out);
|
||||
assert!(!words(&mut other, read).contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_metadata_and_associated_files_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let secret = "SECRET-AF-BYTES";
|
||||
let objects = [
|
||||
"<< /Type /Filespec /F (a.txt) /EF << /F 6 0 R >> >>".to_string(),
|
||||
format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{secret}\nendstream", secret.len()),
|
||||
];
|
||||
let page = " /Extra << /Deep << /PieceInfo << /App << /Private (SECRET-PIECE) >> >> >> >>";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AF [5 0 R]", page, "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!((reach.meta, reach.files), (1, 1), "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { meta: true, files: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, secret) && !holds(&out, "SECRET-PIECE") && !holds(&out, "/AF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_on_links_fields_and_chains_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /Type /Annot /Subtype /Link /Rect [72 600 200 620] /A << /S /JavaScript /JS (SECRET-LINK-JS) >> >>".to_string(),
|
||||
"<< /FT /Tx /T (p) /AA << /K << /S /JavaScript /JS (SECRET-FIELD-JS) >> >> /Kids [7 0 R] >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [72 500 200 520] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let catalog = " /AcroForm << /Fields [6 0 R] >> /OpenAction << /S /GoTo /D [3 0 R /Fit] \
|
||||
/Next << /S /JavaScript /JS (SECRET-NEXT-JS) /Next << /S /Named /N /LastPage >> >> >>";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, " /Annots [5 0 R 7 0 R]", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.scripts, 3, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
for secret in ["SECRET-LINK-JS", "SECRET-FIELD-JS", "SECRET-NEXT-JS"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
assert!(holds(&out, "/LastPage") && holds(&out, "/GoTo"), "what follows a script stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_placed_by_their_kids_say_nothing_after_removal() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(),
|
||||
"<< /Type /StructElem /S /P /P 6 0 R /K << /Type /MCR /Pg 3 0 R /MCID 0 >> /ActualText (SECRET-MCR) >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, "SECRET-MCR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redacted_page_loses_its_thumbnail() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pixels = "SECRET-THUMB";
|
||||
let objects = [format!("<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n{pixels}\nendstream", pixels.len())];
|
||||
let page = " /Resources << /XObject << /Im 5 0 R >> >> /Thumb 5 0 R";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", page, "q 100 0 0 50 0 742 cm /Im Do Q", &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [0.0, 0.0, 100.0, 50.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, pixels) && !holds(&out, "/Thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_takes_what_its_marks_say() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /ActualText (SECRET-HIDDEN-ACTUAL) >> BDC BT /F1 12 Tf 3 Tr 72 700 Td (Unseen) Tj ET EMC";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Resources << /Font << /F1 5 0 R >> >>", content, &[helvetica()]));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-HIDDEN-ACTUAL") && !holds(&out, "Unseen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_links_and_fields_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /Type /Annot /Subtype /Link /F 2 /Rect [72 600 200 620] /Contents (SECRET-HIDDEN-LINK) /A << /S /URI /URI (https://x.test/SECRET-HIDDEN-LINK) >> >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /F 2 /FT /Tx /T (f) /V (SECRET-HIDDEN-WIDGET) /Rect [72 500 200 520] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [6 0 R] >>", " /Annots [5 0 R 6 0 R]", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.hidden_annots, 2, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-HIDDEN-LINK") && !holds(&out, "SECRET-HIDDEN-WIDGET"));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Protected copies and what they allow (PDF-11).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn plain(dir: &Path) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
/// Writes a copy that opens with "user", allowing only `allow`.
|
||||
fn locked(dir: &Path, allow: u32) -> std::path::PathBuf {
|
||||
let (mut worker, doc) = plain(dir);
|
||||
let out = dir.join("locked.pdf");
|
||||
let lock = Lock { user: "user".into(), owner: "owner".into(), allow };
|
||||
worker.protect(doc, &out, Some(&lock)).expect("protects");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_copy_asks_and_holds_its_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), may::PRINT);
|
||||
let bytes = std::fs::read(&out).expect("reads");
|
||||
assert!(bytes.windows(5).any(|w| w == b"AESV3"), "AES-256");
|
||||
let mut worker = worker();
|
||||
let opened = worker.open(&out).expect("opens");
|
||||
assert!(opened.locked);
|
||||
assert!(!worker.unlock(opened.doc, "wrong").expect("tries"));
|
||||
assert!(worker.unlock(opened.doc, "user").expect("tries"));
|
||||
let allowed = worker.permissions(opened.doc).expect("reads");
|
||||
assert_eq!(allowed & (may::PRINT | may::COPY | may::EDIT | may::OWNER), may::PRINT);
|
||||
let refused = worker.rotate_pages(opened.doc, &[0], 90).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "assemble".to_string()));
|
||||
let text: String = worker.text(opened.doc, 0).expect("still reads").iter().map(|c| c.c).collect();
|
||||
assert!(text.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_owner_password_keeps_access_and_the_right_one_lifts_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), 0);
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
worker.unlock(doc, "user").expect("unlocks");
|
||||
assert!(!worker.owner(doc, "nope").expect("tries"));
|
||||
assert_eq!(worker.pages(doc).expect("still open"), 1);
|
||||
assert!(worker.text(doc, 0).is_ok());
|
||||
assert!(worker.owner(doc, "owner").expect("tries"));
|
||||
assert_ne!(worker.permissions(doc).expect("reads") & may::OWNER, 0);
|
||||
worker.rotate_pages(doc, &[0], 90).expect("allowed now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_protection_needs_the_owner() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), may::PRINT | may::COPY | may::EDIT);
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
worker.unlock(doc, "user").expect("unlocks");
|
||||
let bare = dir.path().join("bare.pdf");
|
||||
let refused = worker.protect(doc, &bare, None).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
assert!(!bare.exists());
|
||||
worker.owner(doc, "owner").expect("tries");
|
||||
worker.protect(doc, &bare, None).expect("unprotects");
|
||||
let mut other = self::worker();
|
||||
assert!(!other.open(&bare).expect("opens").locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_password_is_needed_and_must_differ() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = plain(dir.path());
|
||||
let out = dir.path().join("out.pdf");
|
||||
for (user, owner) in [("a", ""), ("same", "same")] {
|
||||
let lock = Lock { user: user.into(), owner: owner.into(), allow: 0 };
|
||||
assert!(worker.protect(doc, &out, Some(&lock)).is_err(), "{user}/{owner}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_open_password_still_holds_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = plain(dir.path());
|
||||
let out = dir.path().join("out.pdf");
|
||||
let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT };
|
||||
worker.protect(doc, &out, Some(&lock)).expect("protects");
|
||||
let mut other = self::worker();
|
||||
let opened = other.open(&out).expect("opens");
|
||||
assert!(!opened.locked);
|
||||
let refused = other.annotate(opened.doc, &[]).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "annotate".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_owner_try_on_aes_128_keeps_the_text_readable() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/aes128.pdf");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&fixture).expect("opens").doc;
|
||||
assert!(worker.unlock(doc, "user").expect("tries"));
|
||||
assert!(!worker.owner(doc, "nope").expect("tries"));
|
||||
let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect();
|
||||
assert_eq!(text.trim(), "Hello");
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Signing by update and checking what was signed (PDF-12, QA-15).
|
||||
|
||||
use std::{io::ErrorKind, path::{Path, PathBuf}};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Digest, Doc, Lock, Signing, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, one_page, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn plain(dir: &Path) -> PathBuf {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
fn signing(object: Option<u32>, name: &str, password: &str) -> Signing {
|
||||
let pfx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.p12");
|
||||
Signing {
|
||||
page: 0,
|
||||
object,
|
||||
rect: [72.0, 600.0, 272.0, 660.0],
|
||||
name: name.into(),
|
||||
pfx: pfx.to_str().expect("UTF-8").into(),
|
||||
pfx_password: password.into(),
|
||||
reason: "Approved".into(),
|
||||
location: "Here".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_signature_checks_and_the_document_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = plain(dir.path());
|
||||
let (mut worker, doc) = opened(&path);
|
||||
let before = worker.history(doc).expect("history");
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
assert!(worker.signatures(doc, &path).expect("reads").is_empty(), "the open document has none");
|
||||
assert_eq!(worker.history(doc).expect("history"), before);
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
let signed = found[0].signed.as_ref().expect("signed");
|
||||
assert_eq!((signed.digest, signed.later, signed.broken), (Digest::Intact, false, false));
|
||||
assert_eq!((signed.common.as_str(), signed.reason.as_str(), signed.location.as_str()), ("Ely Test Signer", "Approved", "Here"));
|
||||
assert!(signed.date.starts_with("D:"), "{}", signed.date);
|
||||
assert_eq!(found[0].page, Some(0), "found through the page's annotations");
|
||||
assert!(signed.certs.first().is_some_and(|der| der.first() == Some(&0x30)), "DER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_certificate_password_writes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
assert!(worker.sign(doc, &out, &signing(None, "Signature1", "nope")).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_byte_shows_as_changed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let mut bytes = std::fs::read(&out).expect("reads");
|
||||
let at = bytes.windows(5).position(|w| w == b"Hello").expect("the text");
|
||||
bytes[at] = b'J';
|
||||
std::fs::write(&out, bytes).expect("writes");
|
||||
let (mut other, read) = opened(&out);
|
||||
let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed");
|
||||
assert_eq!(signed.digest, Digest::Changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_signature_leaves_the_first_intact_and_marked_later() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let once = dir.path().join("once.pdf");
|
||||
worker.sign(doc, &once, &signing(None, "First", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&once);
|
||||
let twice = dir.path().join("twice.pdf");
|
||||
let mut second = signing(None, "Second", "ely");
|
||||
second.rect = [300.0, 600.0, 500.0, 660.0];
|
||||
other.sign(read, &twice, &second).expect("signs again");
|
||||
let (mut third, last) = opened(&twice);
|
||||
let found = third.signatures(last, &twice).expect("reads");
|
||||
let first = found.iter().find(|s| s.name == "First").and_then(|s| s.signed.clone()).expect("first");
|
||||
assert_eq!((first.digest, first.later), (Digest::Intact, true));
|
||||
let next = found.iter().find(|s| s.name == "Second").and_then(|s| s.signed.clone()).expect("second");
|
||||
assert_eq!((next.digest, next.later), (Digest::Intact, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_is_signed_in_place() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let field = "<< /Type /Annot /Subtype /Widget /FT /Sig /T (Sign here) /Rect [72 600 272 660] /P 3 0 R /F 4 >>".to_string();
|
||||
let path = dir.path().join("form.pdf");
|
||||
std::fs::write(&path, one_page(" /AcroForm << /Fields [5 0 R] /SigFlags 3 >>", " /Annots [5 0 R]", "", &[field])).expect("writes");
|
||||
let (mut worker, doc) = opened(&path);
|
||||
let empty = worker.signatures(doc, &path).expect("reads");
|
||||
assert_eq!((empty.len(), empty[0].signed.is_none(), empty[0].name.as_str()), (1, true, "Sign here"));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(Some(empty[0].object), "", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_field_needs_leave_to_change_the_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let limited = dir.path().join("limited.pdf");
|
||||
let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT | may::FILL };
|
||||
worker.protect(doc, &limited, Some(&lock)).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
let refused = other.sign(read, &dir.path().join("signed.pdf"), &signing(None, "S", "ely")).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_save_of_a_signed_file_carries_no_signature() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
assert_eq!(other.signed(read).expect("counts"), 1);
|
||||
other.rotate_pages(read, &[0], 90).expect("edits");
|
||||
let copy = dir.path().join("copy.pdf");
|
||||
other.save(read, ©).expect("saves");
|
||||
let (mut third, again) = opened(©);
|
||||
assert_eq!(third.signed(again).expect("counts"), 0);
|
||||
let fields = third.signatures(again, ©).expect("reads");
|
||||
assert_eq!((fields.len(), fields[0].signed.is_none()), (1, true), "the field stays, empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signer_is_the_certificate_given() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&out);
|
||||
let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed");
|
||||
let der = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.der")).expect("reads");
|
||||
assert_eq!(signed.certs, vec![der]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_signature_writes_in_the_script_face_and_says_what_it_lacks() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&plain(dir.path()));
|
||||
let writing = |text: &str| ely_pdf_engine::Writing {
|
||||
block: None,
|
||||
expected: String::new(),
|
||||
text: text.into(),
|
||||
font: "script".into(),
|
||||
size: 28.0,
|
||||
rgb: 0,
|
||||
bx: [72.0, 600.0, 400.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing("Ada Lovelace"), 0.0).expect("writes"), ely_pdf_engine::Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect();
|
||||
assert!(text.contains("Ada Lovelace"), "{text}");
|
||||
let saved = dir.path().join("typed.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let bytes = std::fs::read(&saved).expect("reads");
|
||||
assert!(bytes.windows(10).any(|w| w == b"GreatVibes"), "the script face is embedded");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing("王"), 0.0).expect("asks"), ely_pdf_engine::Wrote::Missing('王')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_are_read_from_the_file_named() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
let (mark, copy) = (other.history(read).expect("history"), dir.path().join("copy.pdf"));
|
||||
other.save(read, ©).expect("saves");
|
||||
assert_eq!(other.history(read).expect("history"), mark, "saving edits nothing");
|
||||
assert!(other.signatures(read, ©).expect("reads")[0].signed.is_none(), "the copy holds none");
|
||||
assert!(other.signatures(read, &signed).expect("reads")[0].signed.is_some(), "the signed file still does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_on_no_page_is_still_listed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let mut bytes = std::fs::read(&out).expect("reads");
|
||||
// No page lists the widget; the form still does.
|
||||
let at = bytes.windows(7).rposition(|w| w == b"/Annots").expect("the page's annotations");
|
||||
bytes[at + 1] = b'X';
|
||||
std::fs::write(&out, bytes).expect("writes");
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!((found[0].page, found[0].signed.is_some()), (None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_leaves_the_history_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&plain(dir.path()));
|
||||
assert!(worker.save(doc, &dir.path().join("missing/out.pdf")).is_err());
|
||||
let mark = worker.rotate_pages(doc, &[0], 90).expect("rotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_ne!(mark, "0", "the edit is a step of its own");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_emptied_by_a_save_can_be_signed_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
let copy = dir.path().join("copy.pdf");
|
||||
other.save(read, ©).expect("saves");
|
||||
let (mut third, again) = opened(©);
|
||||
let field = third.signatures(again, ©).expect("reads")[0].clone();
|
||||
let out = dir.path().join("resigned.pdf");
|
||||
third.sign(again, &out, &signing(Some(field.object), "", "ely")).expect("signs the emptied field");
|
||||
let found = third.signatures(again, &out).expect("reads");
|
||||
assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Bookmarks, links, properties, attachments against the real worker (PDF-07).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
BookmarkChange, Doc, LinkChange, Target, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Two pages, bookmarks One (with Sub) and Two, a link.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let pages: [(f32, f32, &[&str]); 2] = [(612.0, 792.0, &["Page one"]), (612.0, 792.0, &["Page two"])];
|
||||
let marks = [("One", 0, &[("Sub", 1)][..]), ("Two", 1, &[][..])];
|
||||
std::fs::write(path, pdf_with(&pages, &marks, &Extras { links: &[(0, 1)], ..Extras::default() })).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
/// The outline as indented titles with their pages.
|
||||
fn tree(worker: &mut Worker, doc: Doc) -> Vec<String> {
|
||||
fn walk(marks: &[ely_pdf_engine::Bookmark], depth: usize, out: &mut Vec<String>) {
|
||||
for mark in marks {
|
||||
out.push(format!("{}{} {}", "-".repeat(depth), mark.title, mark.page.map_or(-1, |p| p as i64)));
|
||||
walk(&mark.down, depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&worker.outline(doc).expect("outline"), 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_add_rename_retarget_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "Two 1"]);
|
||||
worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![2], title: "Three".into(), page: 1, y: 100.0 }).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![0, 1], title: "Under".into(), page: 0, y: 0.0 }).expect("adds below");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let rename = BookmarkChange::Edit { path: vec![1], expect: "Two".into(), title: Some("Deux".into()), to: Some((0, 50.0)) };
|
||||
worker.change_bookmark(doc, &rename).expect("edits");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]);
|
||||
assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![1], expect: "Two".into() }).is_err(), "renamed since");
|
||||
assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![9], expect: "One".into() }).is_err(), "no such place");
|
||||
worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![0], expect: "One".into() }).expect("deletes with its children");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc), ["Deux 0", "Three 1"]);
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc).len(), 5, "one step");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(tree(&mut other, reopened), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_list_add_retarget_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let links = worker.links(doc, 0).expect("links");
|
||||
assert_eq!((links.len(), links[0].page), (1, Some(1)), "the sample links to page two");
|
||||
let web = Target::Web("https://example.com/".into());
|
||||
worker.change_link(doc, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: web.clone() }).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let links = worker.links(doc, 0).expect("links");
|
||||
assert_eq!((links[1].uri.as_str(), links[1].page), ("https://example.com/", None));
|
||||
let first = links[0].uri.clone();
|
||||
worker.change_link(doc, 0, &LinkChange::Edit { index: 0, expect: first.clone(), to: Target::Page(0, 200.0) }).expect("retargets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.links(doc, 0).expect("links")[0].page, Some(0));
|
||||
assert!(worker.change_link(doc, 0, &LinkChange::Delete { index: 0, expect: first }).is_err(), "changed since");
|
||||
for unsafe_uri in ["javascript:alert(1)", "file:///etc/passwd"] {
|
||||
let add = LinkChange::Add { rect: [72.0, 400.0, 200.0, 420.0], to: Target::Web(unsafe_uri.into()) };
|
||||
assert!(worker.change_link(doc, 0, &add).is_err(), "{unsafe_uri}");
|
||||
}
|
||||
let now = worker.links(doc, 0).expect("links")[1].uri.clone();
|
||||
worker.change_link(doc, 0, &LinkChange::Delete { index: 1, expect: now }).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.links(doc, 0).expect("links").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_read_set_and_keep() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert_eq!(worker.properties(doc).expect("reads").title, None, "the sample has none");
|
||||
worker.set_properties(doc, &[("Title", "Report"), ("Author", "Zoe")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.properties(doc).expect("reads");
|
||||
assert_eq!((read.title.as_deref(), read.author.as_deref()), (Some("Report"), Some("Zoe")));
|
||||
assert!(worker.set_properties(doc, &[("Title", "x"), ("Producer", "Ely")]).is_err(), "the producer is the producer's");
|
||||
assert_eq!(worker.properties(doc).expect("reads").title.as_deref(), Some("Report"), "nothing changed");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.properties(reopened).expect("reads").author.as_deref(), Some("Zoe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_embed_read_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert!(worker.attachments(doc).expect("lists").is_empty());
|
||||
let file = dir.path().join("notes.txt");
|
||||
std::fs::write(&file, "seven words of notes for the file").expect("writes");
|
||||
worker.attach(doc, "notes.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.attach(doc, "b.txt", &file).expect("attaches another");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let list = worker.attachments(doc).expect("lists");
|
||||
let names: Vec<(&str, u64)> = list.iter().map(|a| (a.name.as_str(), a.size)).collect();
|
||||
assert_eq!(names, [("b.txt", 33), ("notes.txt", 33)], "by name");
|
||||
assert_eq!(worker.attachment(doc, "n:notes.txt").expect("reads"), b"seven words of notes for the file");
|
||||
assert!(worker.attach(doc, "notes.txt", &file).is_err(), "the name is taken");
|
||||
worker.detach(doc, "n:b.txt").expect("removes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.attachment(doc, "n:b.txt").is_err());
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
let kept: Vec<String> = other.attachments(reopened).expect("lists").into_iter().map(|a| a.name).collect();
|
||||
assert_eq!(kept, ["notes.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_on_a_page_lists_reads_and_goes_by_its_key() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { attached: Some(("note.txt", b"notes")), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let list = worker.attachments(doc).expect("lists");
|
||||
assert_eq!(list.len(), 1, "the page's file is an attachment");
|
||||
assert_eq!((list[0].name.as_str(), list[0].page, list[0].size), ("note.txt", Some(0), 5), "its size from its bytes");
|
||||
assert!(list[0].key.starts_with("a:0:"));
|
||||
assert_eq!(worker.attachment(doc, &list[0].key).expect("reads"), b"notes");
|
||||
worker.detach(doc, &list[0].key).expect("removes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.attachments(doc).expect("lists").is_empty());
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "with its annotation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_an_attachment_leaves_its_bytes_in_the_document() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let file = dir.path().join("note.txt");
|
||||
std::fs::write(&file, "notes").expect("writes");
|
||||
worker.attach(doc, "note.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
for _ in 0..2 {
|
||||
assert_eq!(worker.attachment(doc, "n:note.txt").expect("reads"), b"notes");
|
||||
}
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark, "reading changes nothing");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.attachment(reopened, "n:note.txt").expect("reads"), b"notes", "whole in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_read_before_a_change_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let (old, new) = (dir.path().join("old.txt"), dir.path().join("new.txt"));
|
||||
std::fs::write(&old, "old report").expect("writes");
|
||||
std::fs::write(&new, "new report").expect("writes");
|
||||
let first = doc.at(&worker.attach(doc, "report.txt", &old).expect("attaches")).expect("the state");
|
||||
let gone = first.at(&worker.detach(first, "n:report.txt").expect("removes")).expect("the state");
|
||||
let now = gone.at(&worker.attach(gone, "report.txt", &new).expect("attaches again")).expect("the state");
|
||||
// The same key names another file now: refused.
|
||||
let read = worker.attachment(first, "n:report.txt").expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&read), "{read}");
|
||||
let removed = worker.detach(first, "n:report.txt").expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&removed), "{removed}");
|
||||
assert_eq!(worker.attachment(now, "n:report.txt").expect("reads"), b"new report", "the new file stays");
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The worker against generated PDFs.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{Doc, Worker, sample::pdf};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
|
||||
fn opened(dir: &Path, bytes: &[u8]) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
let mut worker = worker();
|
||||
let opened = worker.open(&path).expect("opens");
|
||||
assert!(!opened.locked);
|
||||
(worker, opened.doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_measure_render_and_read_their_text() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let bytes = pdf(
|
||||
&[
|
||||
(612.0, 792.0, &["Ely Office contract", "Second line"]),
|
||||
(842.0, 595.0, &["Wide page"]),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 2);
|
||||
assert_eq!(
|
||||
worker.bounds(doc, 0, 2).expect("bounds"),
|
||||
[[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 842.0, 595.0]]
|
||||
);
|
||||
// Page one's top left, twice its size: text is dark.
|
||||
let tile = worker
|
||||
.render(doc, 0, (2.0, 0), [0, 0, 600, 300], false)
|
||||
.expect("renders").bgra;
|
||||
assert_eq!(tile.len(), 600 * 300 * 4);
|
||||
let dark = tile.chunks_exact(4).filter(|p| p[0] < 128).count();
|
||||
assert!(dark > 100, "text shows: {dark}");
|
||||
let night = worker
|
||||
.render(doc, 0, (2.0, 0), [0, 0, 600, 300], true)
|
||||
.expect("renders dark").bgra;
|
||||
let light = night.chunks_exact(4).filter(|p| p[0] > 128).count();
|
||||
assert!(
|
||||
light > 100 && night[0] < 64,
|
||||
"dark mode turns paper dark and text light"
|
||||
);
|
||||
// A quarter turn makes the page tall side wide.
|
||||
assert!(
|
||||
worker
|
||||
.render(doc, 1, (1.0, 90), [0, 0, 595, 842], false)
|
||||
.is_ok()
|
||||
);
|
||||
let text: String = worker
|
||||
.text(doc, 0)
|
||||
.expect("reads")
|
||||
.iter()
|
||||
.map(|c| c.c)
|
||||
.collect();
|
||||
assert!(
|
||||
text.contains("Ely Office contract") && text.contains("Second line"),
|
||||
"{text}"
|
||||
);
|
||||
let chars = worker.text(doc, 0).expect("reads");
|
||||
assert!(
|
||||
chars.windows(2).all(|w| w[0].line <= w[1].line),
|
||||
"reading order"
|
||||
);
|
||||
assert!(
|
||||
chars[0].rect[1] < chars.last().expect("chars").rect[1],
|
||||
"first line above"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_every_hit_and_bookmarks_nest() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pages: &[(f32, f32, &[&str])] = &[
|
||||
(612.0, 792.0, &["alpha beta alpha"]),
|
||||
(612.0, 792.0, &["no match here"]),
|
||||
(612.0, 792.0, &["Alpha at last"]),
|
||||
];
|
||||
let bytes = pdf(pages, &[("Intro", 0, &[("Detail", 1)]), ("End", 2, &[])]);
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
let hits: Vec<usize> = (0..3)
|
||||
.map(|page| worker.search(doc, page, "alpha").expect("searches").len())
|
||||
.collect();
|
||||
assert_eq!(hits, [2, 0, 1], "case is ignored");
|
||||
let quad = worker.search(doc, 0, "beta").expect("searches")[0][0];
|
||||
assert!(quad[0] > 72.0 && quad[1] > 0.0, "{quad:?}");
|
||||
let marks = worker.outline(doc).expect("outline");
|
||||
let flat: Vec<(&str, Option<usize>, usize)> = marks
|
||||
.iter()
|
||||
.map(|m| (m.title.as_str(), m.page, m.down.len()))
|
||||
.collect();
|
||||
assert_eq!(flat, [("Intro", Some(0), 1), ("End", Some(2), 0)]);
|
||||
assert_eq!(
|
||||
(marks[0].down[0].title.as_str(), marks[0].down[0].page),
|
||||
("Detail", Some(1))
|
||||
);
|
||||
worker.close(doc).expect("closes");
|
||||
assert!(worker.pages(doc).is_err(), "a closed document is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_file_is_an_error_and_the_worker_lives_on() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("junk.pdf");
|
||||
std::fs::write(&path, b"this is not a pdf at all").expect("writes");
|
||||
let mut worker = worker();
|
||||
let failed = worker.open(&path).expect_err("not a PDF");
|
||||
assert!(!failed.to_string().is_empty());
|
||||
assert!(worker.open(&dir.path().join("missing.pdf")).is_err());
|
||||
let good = dir.path().join("good.pdf");
|
||||
std::fs::write(&good, pdf(&[(612.0, 792.0, &["Still here"])], &[])).expect("writes");
|
||||
assert_eq!(
|
||||
worker.open(&good).map(|o| o.locked).ok(),
|
||||
Some(false),
|
||||
"the worker still works"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thousand_pages_show_the_first_in_time() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let lines: &[&str] = &["A long report, page after page."];
|
||||
let pages: Vec<(f32, f32, &[&str])> = (0..1000).map(|_| (612.0, 792.0, lines)).collect();
|
||||
let bytes = pdf(&pages, &[]);
|
||||
let started = std::time::Instant::now();
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 1000);
|
||||
// What the viewer asks before its first frame.
|
||||
worker.bounds(doc, 0, 100).expect("the first sizes");
|
||||
worker.render(doc, 0, (2.0, 0), [0, 0, 512, 512], false).expect("the first tile");
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_secs(3), "PERF-05: {took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_hit_counts_once_however_many_or_however_split() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let busy = ["alpha "; 12].concat();
|
||||
let many: Vec<&str> = (0..50).map(|_| busy.as_str()).collect();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(1000.0, 1400.0, &many), (612.0, 792.0, &["hello", "world"])];
|
||||
let (mut worker, doc) = opened(dir.path(), &pdf(pages, &[]));
|
||||
assert_eq!(worker.search(doc, 0, "alpha").expect("searches").len(), 600, "no cap on hits");
|
||||
let split = worker.search(doc, 1, "hello world").expect("searches");
|
||||
assert_eq!(split.len(), 1, "one phrase over two lines");
|
||||
assert_eq!(split[0].len(), 2, "with a quad on each line");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_worker_from_other_sources_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Answers HELLO with someone else's stamp.
|
||||
let stale = dir.path().join("stale-worker");
|
||||
std::fs::write(
|
||||
&stale,
|
||||
"#!/usr/bin/env python3\nimport struct, sys\nhead = sys.stdin.buffer.read(13)\nstamp = b'old'\nbody = struct.pack('<I', len(stamp)) + stamp\nsys.stdout.buffer.write(struct.pack('<I', len(body)) + bytes([100]) + head[5:13] + body)\nsys.stdout.flush()\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&stale, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("executable");
|
||||
let refused = Worker::spawn_from(&stale).err().expect("refused");
|
||||
assert!(refused.to_string().contains("other sources"), "{refused}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_reply_to_another_request_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Answers HELLO under the next request's id.
|
||||
let crossed = dir.path().join("crossed-worker");
|
||||
std::fs::write(
|
||||
&crossed,
|
||||
"#!/usr/bin/env python3\nimport struct, sys\nhead = sys.stdin.buffer.read(13)\nasked = struct.unpack('<Q', head[5:13])[0]\nbody = struct.pack('<I', 3) + b'any'\nsys.stdout.buffer.write(struct.pack('<I', len(body)) + bytes([100]) + struct.pack('<Q', asked + 1) + body)\nsys.stdout.flush()\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&crossed, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("executable");
|
||||
let refused = Worker::spawn_from(&crossed).err().expect("refused");
|
||||
assert!(refused.to_string().contains("not 1"), "{refused}");
|
||||
}
|
||||
|
||||
/// A noisy RGB PNG, stored uncompressed: large on disk.
|
||||
fn big_png(path: &Path, side: u32) {
|
||||
let crc = |bytes: &[u8]| {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for byte in bytes {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xedb8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
};
|
||||
let chunk = |kind: &[u8], data: &[u8]| {
|
||||
let body = [kind, data].concat();
|
||||
[&(data.len() as u32).to_be_bytes()[..], &body, &crc(&body).to_be_bytes()].concat()
|
||||
};
|
||||
let mut raw = Vec::new();
|
||||
let mut seed = 7u32;
|
||||
for _ in 0..side {
|
||||
raw.push(0);
|
||||
for _ in 0..side * 3 {
|
||||
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
raw.push((seed >> 16) as u8);
|
||||
}
|
||||
}
|
||||
// zlib with stored blocks; Adler-32 at the end.
|
||||
let mut zlib = vec![0x78, 0x01];
|
||||
let blocks: Vec<&[u8]> = raw.chunks(65_535).collect();
|
||||
for (n, block) in blocks.iter().enumerate() {
|
||||
zlib.push(u8::from(n + 1 == blocks.len()));
|
||||
let len = block.len() as u16;
|
||||
zlib.extend_from_slice(&len.to_le_bytes());
|
||||
zlib.extend_from_slice(&(!len).to_le_bytes());
|
||||
zlib.extend_from_slice(block);
|
||||
}
|
||||
let (mut a, mut b) = (1u32, 0u32);
|
||||
for byte in &raw {
|
||||
a = (a + u32::from(*byte)) % 65_521;
|
||||
b = (b + a) % 65_521;
|
||||
}
|
||||
zlib.extend_from_slice(&((b << 16) | a).to_be_bytes());
|
||||
let header = [&side.to_be_bytes()[..], &side.to_be_bytes(), &[8, 2, 0, 0, 0]].concat();
|
||||
let png = [&b"\x89PNG\r\n\x1a\n"[..], &chunk(b"IHDR", &header), &chunk(b"IDAT", &zlib), &chunk(b"IEND", &[])].concat();
|
||||
std::fs::write(path, png).expect("writes");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failed_open_keeps_no_memory() {
|
||||
// An image MuPDF opens, then refuses to journal.
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let png = dir.path().join("big.png");
|
||||
big_png(&png, 1200);
|
||||
let mut worker = Worker::spawn().expect("a worker");
|
||||
let rss = |pid: u32| -> u64 {
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().expect("ps");
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().expect("kilobytes")
|
||||
};
|
||||
// MuPDF's 256 MB store fills first; then nothing grows.
|
||||
for _ in 0..70 {
|
||||
let refused = worker.open(&png).err().expect("not a PDF");
|
||||
assert!(refused.to_string().contains("not a PDF"), "{refused}");
|
||||
}
|
||||
let before = rss(worker.pid());
|
||||
for _ in 0..50 {
|
||||
assert!(worker.open(&png).is_err(), "not a PDF");
|
||||
}
|
||||
let grown = rss(worker.pid()).saturating_sub(before);
|
||||
assert!(grown < 40_000, "50 more failed opens of a 4 MB image kept {grown} KiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_belongs_to_its_worker() {
|
||||
let first = tempfile::tempdir().expect("a temp dir");
|
||||
let (old_worker, old) = opened(first.path(), &pdf(&[(300.0, 500.0, &["A"])], &[]));
|
||||
drop(old_worker);
|
||||
let second = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(second.path(), &pdf(&[(300.0, 500.0, &["B"])], &[]));
|
||||
// An old worker's handle never reaches this one's documents.
|
||||
assert!(worker.close(old).is_err(), "another worker's document closed");
|
||||
assert!(worker.pages(old).is_err(), "another worker's document answered");
|
||||
assert_eq!(worker.pages(doc).expect("its own document"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_made_for_an_older_state_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pages: &[(f32, f32, &[&str])] = &[(300.0, 500.0, &["A"]), (300.0, 500.0, &["B"]), (300.0, 500.0, &["C"])];
|
||||
let (mut worker, before) = opened(dir.path(), &pdf(pages, &[]));
|
||||
let mark = worker.delete_pages(before, &[0]).expect("deletes A");
|
||||
let after = before.at(&mark).expect("the new state");
|
||||
// Page 1 was B, now C; the old aim fails.
|
||||
let stale = worker.rotate_pages(before, &[1], 90).expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&stale), "{stale}");
|
||||
assert!(worker.bounds(before, 0, 1).is_err(), "a read for an older state");
|
||||
assert_eq!(worker.bounds(after, 1, 1).expect("C")[0], [0.0, 0.0, 300.0, 500.0], "C stayed upright");
|
||||
worker.rotate_pages(after, &[1], 90).expect("rotates C");
|
||||
// Whole-document requests take any state.
|
||||
assert_eq!(worker.pages(before).expect("pages"), 2);
|
||||
}
|
||||
Reference in new issue
Block a user