ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
288 lines
12 KiB
Rust
288 lines
12 KiB
Rust
//! 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);
|
|
}
|