#![cfg(feature = "pdf")]
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream};
use crate::error::{Error, Result};
use crate::tools::workspace::{Workspace, Wrote};
const PAGE: (f32, f32) = (612.0, 792.0);
const MARGIN: f32 = 72.0;
const BODY: (f32, f32) = (12.0, 14.0);
const WRAP: usize = 78;
const DIAGONAL: f32 = std::f32::consts::FRAC_1_SQRT_2;
const PARENT_LIMIT: usize = 32;
fn unreadable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("{rel} is not a readable PDF: {e}"))
}
fn unwritable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("cannot build the PDF for {rel}: {e}"))
}
fn open(ws: &Workspace, rel: &str) -> Result<Document> {
let bytes = ws.read_bytes(rel)?;
Document::load_mem(&bytes).map_err(|e| unreadable(rel, e))
}
fn save(ws: &Workspace, rel: &str, doc: &mut Document) -> Result<Wrote> {
let mut buf = Vec::new();
doc.save_to(&mut buf).map_err(|e| unwritable(rel, e))?;
ws.write_bytes(rel, &buf)
}
fn encode(s: &str) -> Vec<u8> {
s.chars()
.map(|c| if (c as u32) < 0x100 { c as u8 } else { b'?' })
.collect()
}
fn wrap(text: &str) -> Vec<String> {
let mut out = Vec::new();
for para in text.split('\n') {
let mut line = String::new();
for word in para.split_whitespace() {
let chars: Vec<char> = word.chars().collect();
for piece in chars.chunks(WRAP) {
let piece: String = piece.iter().collect();
if line.is_empty() {
line = piece;
} else if line.chars().count() + 1 + piece.chars().count() <= WRAP {
line.push(' ');
line.push_str(&piece);
} else {
out.push(std::mem::replace(&mut line, piece));
}
}
}
out.push(line);
}
out
}
fn media_box(doc: &Document, page_id: ObjectId) -> [f32; 4] {
let mut id = page_id;
for _ in 0..PARENT_LIMIT {
let Ok(dict) = doc.get_dictionary(id) else {
break;
};
if let Ok(arr) = dict.get_deref(b"MediaBox", doc).and_then(Object::as_array) {
let nums: Vec<f32> = arr.iter().filter_map(|o| o.as_float().ok()).collect();
if let [llx, lly, urx, ury] = nums[..] {
return [llx, lly, urx, ury];
}
}
let Ok(parent) = dict.get(b"Parent").and_then(Object::as_reference) else {
break;
};
id = parent;
}
[0.0, 0.0, PAGE.0, PAGE.1]
}
fn own_resources(doc: &mut Document, page_id: ObjectId) -> Result<()> {
if doc
.get_dictionary(page_id)
.is_ok_and(|p| p.has(b"Resources"))
{
return Ok(());
}
let mut inherited = None;
let mut id = page_id;
for _ in 0..PARENT_LIMIT {
let Ok(dict) = doc.get_dictionary(id) else {
break;
};
if let Ok(res) = dict.get(b"Resources") {
inherited = Some(res.clone());
break;
}
let Ok(parent) = dict.get(b"Parent").and_then(Object::as_reference) else {
break;
};
id = parent;
}
let res = inherited.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
doc.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| Error::Config(format!("page {page_id:?} is not a dictionary: {e}")))?
.set("Resources", res);
Ok(())
}
pub fn write_new(ws: &Workspace, rel: &str, pages: &[String]) -> Result<Wrote> {
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => "WinAnsiEncoding",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
let mut kids = Vec::with_capacity(pages.len());
for text in pages {
let mut ops = vec![
Operation::new("BT", vec![]),
Operation::new(
"Tf",
vec![Object::Name(b"F1".to_vec()), Object::Real(BODY.0)],
),
Operation::new("TL", vec![Object::Real(BODY.1)]),
Operation::new(
"Td",
vec![Object::Real(MARGIN), Object::Real(PAGE.1 - MARGIN)],
),
];
for line in wrap(text) {
ops.push(Operation::new(
"Tj",
vec![Object::string_literal(encode(&line))],
));
ops.push(Operation::new("T*", vec![]));
}
ops.push(Operation::new("ET", vec![]));
let stream = Content { operations: ops }
.encode()
.map_err(|e| unwritable(rel, e))?;
let content_id = doc.add_object(Stream::new(Dictionary::new(), stream));
kids.push(Object::Reference(doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"Contents" => content_id,
})));
}
let count = kids.len() as i64;
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => kids,
"Count" => count,
"Resources" => resources_id,
"MediaBox" => vec![
Object::Real(0.0), Object::Real(0.0),
Object::Real(PAGE.0), Object::Real(PAGE.1),
],
}),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
save(ws, rel, &mut doc)
}
pub fn read_text(ws: &Workspace, rel: &str) -> Result<String> {
let bytes = ws.read_bytes(rel)?;
match std::panic::catch_unwind(|| pdf_extract::extract_text_from_mem(&bytes)) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(unreadable(rel, e)),
Err(panic) => {
let what = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "the extractor panicked".to_string());
Err(unreadable(rel, format!("text extraction failed: {what}")))
}
}
}
pub fn watermark(ws: &Workspace, rel: &str, text: &str) -> Result<Wrote> {
if text.is_empty() {
return Err(Error::Config(format!(
"cannot watermark {rel} with an empty string"
)));
}
let mut doc = open(ws, rel)?;
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => "WinAnsiEncoding",
});
let glyphs = encode(text);
for (_, page_id) in doc.get_pages() {
let [llx, lly, urx, ury] = media_box(&doc, page_id);
let (w, h) = (urx - llx, ury - lly);
let (cx, cy) = (llx + w / 2.0, lly + h / 2.0);
let diagonal = (w * w + h * h).sqrt();
let size = (diagonal * 0.8 / (0.55 * glyphs.len().max(1) as f32)).clamp(8.0, 96.0);
let half = 0.55 * size * glyphs.len() as f32 / 2.0;
own_resources(&mut doc, page_id)?;
let form = Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![
Object::Real(llx), Object::Real(lly),
Object::Real(urx), Object::Real(ury),
],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
},
Content {
operations: vec![
Operation::new("q", vec![]),
Operation::new(
"rg",
vec![Object::Real(0.75), Object::Real(0.75), Object::Real(0.75)],
),
Operation::new(
"cm",
vec![
Object::Real(DIAGONAL),
Object::Real(DIAGONAL),
Object::Real(-DIAGONAL),
Object::Real(DIAGONAL),
Object::Real(cx),
Object::Real(cy),
],
),
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), Object::Real(size)]),
Operation::new("Td", vec![Object::Real(-half), Object::Real(0.0)]),
Operation::new("Tj", vec![Object::string_literal(glyphs.clone())]),
Operation::new("ET", vec![]),
Operation::new("Q", vec![]),
],
}
.encode()
.map_err(|e| unwritable(rel, e))?,
);
let form_id = doc.add_object(form);
let name = format!("IoWm{}_{}", form_id.0, form_id.1);
doc.add_xobject(page_id, name.as_bytes(), form_id)
.map_err(|e| unwritable(rel, e))?;
let mut contents: Vec<Object> = doc
.get_page_contents(page_id)
.into_iter()
.map(Object::Reference)
.collect();
let opener = doc.add_object(Stream::new(Dictionary::new(), b"q\n".to_vec()));
let stamp = doc.add_object(Stream::new(
Dictionary::new(),
format!("Q\nq\n/{name} Do\nQ\n").into_bytes(),
));
contents.insert(0, Object::Reference(opener));
contents.push(Object::Reference(stamp));
doc.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| unwritable(rel, e))?
.set("Contents", contents);
}
save(ws, rel, &mut doc)
}
fn form_fields(doc: &Document) -> Vec<(String, String, ObjectId)> {
fn walk(
doc: &Document,
ids: &[ObjectId],
prefix: &str,
depth: usize,
out: &mut Vec<(String, String, ObjectId)>,
) {
if depth > PARENT_LIMIT {
return;
}
for id in ids {
let Ok(dict) = doc.get_dictionary(*id) else {
continue;
};
let leaf = dict
.get(b"T")
.and_then(Object::as_str)
.map(|s| String::from_utf8_lossy(s).to_string())
.unwrap_or_default();
if leaf.is_empty() {
continue;
}
let qualified = if prefix.is_empty() {
leaf.clone()
} else {
format!("{prefix}.{leaf}")
};
let kids: Vec<ObjectId> = dict
.get_deref(b"Kids", doc)
.and_then(Object::as_array)
.map(|a| a.iter().filter_map(|o| o.as_reference().ok()).collect())
.unwrap_or_default();
let named_kids: Vec<ObjectId> = kids
.iter()
.copied()
.filter(|k| {
doc.get_dictionary(*k)
.is_ok_and(|d| d.get(b"T").and_then(Object::as_str).is_ok())
})
.collect();
if named_kids.is_empty() {
out.push((qualified, leaf, *id));
} else {
walk(doc, &named_kids, &qualified, depth + 1, out);
}
}
}
let Ok(acro) = acroform(doc) else {
return Vec::new();
};
let roots: Vec<ObjectId> = acro
.get_deref(b"Fields", doc)
.and_then(Object::as_array)
.map(|a| a.iter().filter_map(|o| o.as_reference().ok()).collect())
.unwrap_or_default();
let mut out = Vec::new();
walk(doc, &roots, "", 0, &mut out);
out
}
fn widgets(doc: &Document, field: ObjectId) -> Vec<ObjectId> {
let kids: Vec<ObjectId> = doc
.get_dictionary(field)
.and_then(|d| d.get_deref(b"Kids", doc).and_then(Object::as_array))
.map(|a| a.iter().filter_map(|o| o.as_reference().ok()).collect())
.unwrap_or_default();
if kids.is_empty() {
vec![field]
} else {
kids
}
}
fn da_font(da: &[u8]) -> (Vec<u8>, f32) {
let text = String::from_utf8_lossy(da);
let toks: Vec<&str> = text.split_whitespace().collect();
for (i, tok) in toks.iter().enumerate() {
if *tok == "Tf" && i >= 2 {
if let (Some(name), Ok(size)) =
(toks[i - 2].strip_prefix('/'), toks[i - 1].parse::<f32>())
{
if !name.is_empty() {
return (name.as_bytes().to_vec(), size.max(0.0));
}
}
}
}
(b"Helv".to_vec(), 0.0)
}
fn set_appearance(
doc: &mut Document,
widget: ObjectId,
value: &str,
da: &[u8],
dr_fonts: &Dictionary,
fallback: &mut Option<ObjectId>,
rel: &str,
) -> Result<()> {
let rect: Vec<f32> = doc
.get_dictionary(widget)
.and_then(|d| d.get_deref(b"Rect", doc).and_then(Object::as_array))
.map(|a| a.iter().filter_map(|o| o.as_float().ok()).collect())
.unwrap_or_default();
let (w, h) = match rect[..] {
[x0, y0, x1, y1] => ((x1 - x0).abs(), (y1 - y0).abs()),
_ => return Ok(()),
};
if w <= 0.0 || h <= 0.0 {
return Ok(());
}
let (name, size) = da_font(da);
let size = if size > 0.0 {
size
} else {
(h * 0.66).clamp(4.0, 12.0)
};
let (name, font) = match dr_fonts.get(&name).ok().cloned() {
Some(font) => (name, font),
None => {
let id = *fallback.get_or_insert_with(|| {
doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => "WinAnsiEncoding",
})
});
(b"Helv".to_vec(), Object::Reference(id))
}
};
let mut fonts = Dictionary::new();
fonts.set(name.clone(), font);
let mut resources = Dictionary::new();
resources.set("Font", fonts);
let content = Content {
operations: vec![
Operation::new("BMC", vec![Object::Name(b"Tx".to_vec())]),
Operation::new("q", vec![]),
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(name), Object::Real(size)]),
Operation::new(
"Td",
vec![Object::Real(2.0), Object::Real(((h - size) / 2.0).max(2.0))],
),
Operation::new("Tj", vec![Object::string_literal(encode(value))]),
Operation::new("ET", vec![]),
Operation::new("Q", vec![]),
Operation::new("EMC", vec![]),
],
}
.encode()
.map_err(|e| unwritable(rel, e))?;
let form_id = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"FormType" => 1_i64,
"BBox" => vec![
Object::Real(0.0), Object::Real(0.0),
Object::Real(w), Object::Real(h),
],
"Resources" => resources,
},
content,
));
let mut ap = Dictionary::new();
ap.set("N", Object::Reference(form_id));
doc.get_dictionary_mut(widget)
.map_err(|e| unwritable(rel, e))?
.set("AP", ap);
Ok(())
}
fn acroform(doc: &Document) -> Result<&Dictionary> {
let catalog = doc
.catalog()
.map_err(|e| Error::Config(format!("the PDF has no catalog: {e}")))?;
catalog
.get_deref(b"AcroForm", doc)
.and_then(Object::as_dict)
.map_err(|_| Error::Config("the PDF has no AcroForm (it contains no form fields)".into()))
}
pub fn fill_form(ws: &Workspace, rel: &str, fields: &[(String, String)]) -> Result<Wrote> {
let mut doc = open(ws, rel)?;
let known = form_fields(&doc);
if known.is_empty() {
acroform(&doc)?;
return Err(Error::Config(format!("{rel} has no fillable form fields")));
}
let mut targets: Vec<(ObjectId, &str)> = Vec::with_capacity(fields.len());
for (name, value) in fields {
let hits: Vec<ObjectId> = known
.iter()
.filter(|(q, leaf, _)| q == name || leaf == name)
.map(|(_, _, id)| *id)
.collect();
match hits[..] {
[id] => targets.push((id, value.as_str())),
[] => {
let names: Vec<&str> = known.iter().map(|(q, _, _)| q.as_str()).collect();
return Err(Error::Config(format!(
"{rel} has no form field named {name:?}; it has: {}",
names.join(", ")
)));
}
_ => {
return Err(Error::Config(format!(
"{name:?} names more than one field in {rel}; use the fully qualified name"
)))
}
}
}
let (form_da, dr_fonts) = {
let acro = acroform(&doc)?;
let da = acro
.get(b"DA")
.and_then(Object::as_str)
.unwrap_or_default()
.to_vec();
let fonts = acro
.get_deref(b"DR", &doc)
.and_then(Object::as_dict)
.and_then(|dr| dr.get_deref(b"Font", &doc).and_then(Object::as_dict))
.ok()
.cloned()
.unwrap_or_default();
(da, fonts)
};
let mut fallback_font = None;
for (id, value) in targets {
let is_button = doc
.get_dictionary(id)
.and_then(|d| d.get_deref(b"FT", &doc).and_then(Object::as_name))
.is_ok_and(|ft| ft == b"Btn");
if is_button {
let field = doc
.get_object_mut(id)
.and_then(Object::as_dict_mut)
.map_err(|e| unwritable(rel, e))?;
field.set("V", Object::Name(encode(value)));
field.set("AS", Object::Name(encode(value)));
continue;
}
let da = doc
.get_dictionary(id)
.and_then(|d| d.get_deref(b"DA", &doc).and_then(Object::as_str))
.map(<[u8]>::to_vec)
.unwrap_or_else(|_| form_da.clone());
let widgets = widgets(&doc, id);
for target in std::iter::once(id).chain(widgets.iter().copied()) {
if let Ok(dict) = doc.get_dictionary_mut(target) {
let _ = dict.remove(b"AP");
}
}
doc.get_object_mut(id)
.and_then(Object::as_dict_mut)
.map_err(|e| unwritable(rel, e))?
.set("V", Object::string_literal(encode(value)));
if !value.is_empty() {
for widget in widgets {
set_appearance(
&mut doc,
widget,
value,
&da,
&dr_fonts,
&mut fallback_font,
rel,
)?;
}
}
}
let acro_ref = doc
.catalog()
.ok()
.and_then(|c| c.get(b"AcroForm").ok())
.and_then(|o| o.as_reference().ok());
match acro_ref {
Some(id) => doc
.get_dictionary_mut(id)
.map_err(|e| unwritable(rel, e))?
.set("NeedAppearances", true),
None => doc
.catalog_mut()
.and_then(|c| c.get_mut(b"AcroForm"))
.and_then(Object::as_dict_mut)
.map_err(|e| unwritable(rel, e))?
.set("NeedAppearances", true),
}
save(ws, rel, &mut doc)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::policy::Policy;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn guarded(root: &std::path::Path) -> Workspace {
Workspace::with_policy(
root,
Policy::default()
.layer("base")
.allow_read("*")
.allow_write("*")
.deny_read("secrets/*")
.deny_write("secrets/*"),
)
}
fn form_fixture(ws: &Workspace, rel: &str) {
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => "Type1",
"BaseFont" => "Helvetica", "Encoding" => "WinAnsiEncoding",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "Helv" => font_id },
});
let content_id = doc.add_object(Stream::new(
Dictionary::new(),
Content {
operations: vec![
Operation::new("BT", vec![]),
Operation::new(
"Tf",
vec![Object::Name(b"Helv".to_vec()), Object::Real(12.0)],
),
Operation::new("Td", vec![Object::Real(72.0), Object::Real(700.0)]),
Operation::new("Tj", vec![Object::string_literal("Membership application")]),
Operation::new("ET", vec![]),
],
}
.encode()
.unwrap(),
));
let stale_ap = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Form",
"BBox" => vec![Object::Real(0.0), Object::Real(0.0),
Object::Real(300.0), Object::Real(20.0)],
},
b"/Tx BMC EMC".to_vec(),
));
let text_field = doc.add_object(dictionary! {
"Type" => "Annot", "Subtype" => "Widget", "FT" => "Tx",
"T" => Object::string_literal("full_name"),
"V" => Object::string_literal(""),
"DA" => Object::string_literal("/Helv 12 Tf 0 g"),
"Rect" => vec![Object::Real(100.0), Object::Real(600.0),
Object::Real(400.0), Object::Real(620.0)],
"P" => page_id,
"AP" => dictionary! { "N" => stale_ap },
});
let check_field = doc.add_object(dictionary! {
"Type" => "Annot", "Subtype" => "Widget", "FT" => "Btn",
"T" => Object::string_literal("subscribe"),
"V" => Object::Name(b"Off".to_vec()),
"AS" => Object::Name(b"Off".to_vec()),
"Rect" => vec![Object::Real(100.0), Object::Real(560.0),
Object::Real(120.0), Object::Real(580.0)],
"P" => page_id,
});
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"Contents" => content_id,
"Annots" => vec![Object::Reference(text_field), Object::Reference(check_field)],
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
"Resources" => resources_id,
"MediaBox" => vec![Object::Real(0.0), Object::Real(0.0),
Object::Real(612.0), Object::Real(792.0)],
}),
);
let acro_id = doc.add_object(dictionary! {
"Fields" => vec![Object::Reference(text_field), Object::Reference(check_field)],
"DA" => Object::string_literal("/Helv 12 Tf 0 g"),
"DR" => resources_id,
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
"AcroForm" => acro_id,
});
doc.trailer.set("Root", catalog_id);
let mut buf = Vec::new();
doc.save_to(&mut buf).unwrap();
ws.write_bytes(rel, &buf).unwrap();
}
#[test]
fn write_new_then_read_text_round_trips_recognisable_text() {
let d = dir();
let ws = Workspace::new(d.path());
assert_eq!(
write_new(
&ws,
"out/report.pdf",
&[
String::from("Quarterly revenue rose"),
String::from("Headcount held flat")
],
)
.unwrap(),
Wrote::Created
);
let text = read_text(&ws, "out/report.pdf").unwrap();
assert!(
text.contains("Quarterly revenue rose"),
"the first page's text came back: {text:?}"
);
assert!(
text.contains("Headcount held flat"),
"and the second page's: {text:?}"
);
}
#[test]
fn write_new_wraps_a_long_line_without_losing_any_of_its_words() {
let d = dir();
let ws = Workspace::new(d.path());
let long = "alpha ".repeat(60) + "omega";
write_new(&ws, "long.pdf", &[long]).unwrap();
let text = read_text(&ws, "long.pdf").unwrap();
assert_eq!(
text.matches("alpha").count(),
60,
"every word survived the wrap: {text:?}"
);
assert!(text.contains("omega"), "including the last: {text:?}");
}
#[test]
fn watermarking_stamps_every_page_and_leaves_the_original_text_extractable() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(
&ws,
"deck.pdf",
&[
String::from("First page body"),
String::from("Second page body"),
String::from("Third page body"),
],
)
.unwrap();
assert_eq!(watermark(&ws, "deck.pdf", "DRAFT").unwrap(), Wrote::Changed);
let bytes = ws.read_bytes("deck.pdf").unwrap();
let per_page = pdf_extract::extract_text_from_mem_by_pages(&bytes).unwrap();
assert_eq!(per_page.len(), 3, "no page was added or dropped");
for (i, text) in per_page.iter().enumerate() {
assert!(
text.contains("DRAFT"),
"page {} carries the stamp: {text:?}",
i + 1
);
}
assert!(per_page[0].contains("First page body"), "{:?}", per_page[0]);
assert!(
per_page[1].contains("Second page body"),
"{:?}",
per_page[1]
);
assert!(per_page[2].contains("Third page body"), "{:?}", per_page[2]);
}
#[test]
fn watermarking_appends_to_the_page_rather_than_replacing_its_content() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(&ws, "one.pdf", &[String::from("Original body text")]).unwrap();
let before = {
let doc = Document::load_mem(&ws.read_bytes("one.pdf").unwrap()).unwrap();
let page_id = *doc.get_pages().values().next().unwrap();
doc.get_page_content(page_id)
};
watermark(&ws, "one.pdf", "CONFIDENTIAL").unwrap();
let doc = Document::load_mem(&ws.read_bytes("one.pdf").unwrap()).unwrap();
let page_id = *doc.get_pages().values().next().unwrap();
let after = doc.get_page_content(page_id);
assert!(
after.windows(before.len()).any(|w| w == before.as_slice()),
"the original content stream is still present verbatim inside the new one"
);
assert!(
after.len() > before.len(),
"and something was added around it"
);
}
#[test]
fn watermarking_twice_leaves_both_stamps_and_the_body() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(&ws, "twice.pdf", &[String::from("Body survives")]).unwrap();
watermark(&ws, "twice.pdf", "DRAFT").unwrap();
watermark(&ws, "twice.pdf", "COPY").unwrap();
let text = read_text(&ws, "twice.pdf").unwrap();
for expected in ["Body survives", "DRAFT", "COPY"] {
assert!(text.contains(expected), "{expected} is missing: {text:?}");
}
}
#[test]
fn watermarking_a_page_with_inherited_resources_keeps_its_own_font_reachable() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(&ws, "inherit.pdf", &[String::from("Needs its font")]).unwrap();
watermark(&ws, "inherit.pdf", "DRAFT").unwrap();
let doc = Document::load_mem(&ws.read_bytes("inherit.pdf").unwrap()).unwrap();
let page_id = *doc.get_pages().values().next().unwrap();
let fonts = doc.get_page_fonts(page_id).unwrap();
assert!(
fonts.contains_key(b"F1".as_slice()),
"the inherited font is still resolvable from the page, got {:?}",
fonts.keys().collect::<Vec<_>>()
);
}
fn appearance_of(doc: &Document, widget: ObjectId) -> Option<Vec<u8>> {
let ap = doc
.get_dictionary(widget)
.ok()?
.get_deref(b"AP", doc)
.ok()?
.as_dict()
.ok()?;
ap.get_deref(b"N", doc)
.ok()?
.as_stream()
.ok()?
.get_plain_content()
.ok()
}
fn fields_by_name(doc: &Document) -> BTreeMap<String, ObjectId> {
form_fields(doc)
.into_iter()
.map(|(q, _, id)| (q, id))
.collect()
}
#[test]
fn filling_a_form_sets_the_value_and_asks_the_viewer_to_redraw_it() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "form.pdf");
assert_eq!(
fill_form(
&ws,
"form.pdf",
&[
("full_name".to_string(), "Ada Lovelace".to_string()),
("subscribe".to_string(), "Yes".to_string()),
],
)
.unwrap(),
Wrote::Changed
);
let doc = Document::load_mem(&ws.read_bytes("form.pdf").unwrap()).unwrap();
let by_name = fields_by_name(&doc);
let text = doc.get_dictionary(by_name["full_name"]).unwrap();
assert_eq!(
text.get(b"V").unwrap().as_str().unwrap(),
b"Ada Lovelace",
"the value landed"
);
let drawn = appearance_of(&doc, by_name["full_name"]).expect("the field has an /AP /N");
assert!(
drawn.windows(12).any(|w| w == b"Ada Lovelace"),
"the appearance draws the new value: {:?}",
String::from_utf8_lossy(&drawn)
);
let acro = acroform(&doc).unwrap();
assert!(
acro.get(b"NeedAppearances")
.and_then(Object::as_bool)
.unwrap(),
"and the AcroForm asks the viewer to build the new appearance"
);
let check = doc.get_dictionary(by_name["subscribe"]).unwrap();
assert_eq!(check.get(b"V").unwrap().as_name().unwrap(), b"Yes");
assert_eq!(check.get(b"AS").unwrap().as_name().unwrap(), b"Yes");
}
fn operands<'a>(ops: &'a [Operation], operator: &str) -> Option<&'a [Object]> {
ops.iter()
.find(|op| op.operator == operator)
.map(|op| op.operands.as_slice())
}
#[test]
fn a_filled_text_field_carries_an_appearance_stream_that_draws_the_value() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "form.pdf");
fill_form(
&ws,
"form.pdf",
&[("full_name".to_string(), "Ada Lovelace".to_string())],
)
.unwrap();
let doc = Document::load_mem(&ws.read_bytes("form.pdf").unwrap()).unwrap();
let id = fields_by_name(&doc)["full_name"];
let drawn = appearance_of(&doc, id).expect("the filled field has an /AP /N");
assert!(
drawn.windows(12).any(|w| w == b"Ada Lovelace"),
"the decoded stream draws the value: {:?}",
String::from_utf8_lossy(&drawn)
);
let ops = Content::decode(&drawn).unwrap().operations;
assert_eq!(operands(&ops, "BMC").unwrap()[0].as_name().unwrap(), b"Tx");
let tf = operands(&ops, "Tf").unwrap();
assert_eq!(tf[0].as_name().unwrap(), b"Helv");
assert_eq!(tf[1].as_float().unwrap(), 12.0);
let ap = doc
.get_dictionary(id)
.unwrap()
.get_deref(b"AP", &doc)
.unwrap()
.as_dict()
.unwrap();
let stream = ap.get_deref(b"N", &doc).unwrap().as_stream().unwrap();
assert_eq!(
stream.dict.get(b"Subtype").unwrap().as_name().unwrap(),
b"Form"
);
let bbox: Vec<f32> = stream
.dict
.get(b"BBox")
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|o| o.as_float().unwrap())
.collect();
assert_eq!(bbox, vec![0.0, 0.0, 300.0, 20.0], "the widget's /Rect size");
let font = stream
.dict
.get_deref(b"Resources", &doc)
.unwrap()
.as_dict()
.unwrap()
.get_deref(b"Font", &doc)
.unwrap()
.as_dict()
.unwrap()
.get_deref(b"Helv", &doc)
.unwrap()
.as_dict()
.unwrap();
assert_eq!(
font.get(b"BaseFont").unwrap().as_name().unwrap(),
b"Helvetica"
);
}
#[test]
fn filling_a_field_with_an_empty_value_leaves_no_appearance_behind() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "blank.pdf");
form_fixture(&ws, "filled.pdf");
fill_form(
&ws,
"blank.pdf",
&[("full_name".to_string(), String::new())],
)
.unwrap();
fill_form(
&ws,
"filled.pdf",
&[("full_name".to_string(), "Ada".to_string())],
)
.unwrap();
let blank = Document::load_mem(&ws.read_bytes("blank.pdf").unwrap()).unwrap();
let id = fields_by_name(&blank)["full_name"];
assert!(
!blank.get_dictionary(id).unwrap().has(b"AP"),
"the key itself is gone, not merely emptied"
);
assert!(appearance_of(&blank, id).is_none());
let filled = Document::load_mem(&ws.read_bytes("filled.pdf").unwrap()).unwrap();
let id = fields_by_name(&filled)["full_name"];
assert!(appearance_of(&filled, id).is_some());
}
#[test]
fn a_value_with_brackets_and_backslashes_round_trips_into_the_content_stream() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "form.pdf");
let value = r"Ada (Lovelace\Byron) )";
fill_form(
&ws,
"form.pdf",
&[("full_name".to_string(), value.to_string())],
)
.unwrap();
let doc = Document::load_mem(&ws.read_bytes("form.pdf").unwrap()).unwrap();
let id = fields_by_name(&doc)["full_name"];
let drawn = appearance_of(&doc, id).unwrap();
let shown = String::from_utf8_lossy(&drawn).to_string();
assert!(
shown.contains(r"\\") && shown.contains(r"\)"),
"the backslash and the unbalanced bracket are escaped in the stream: {shown}"
);
let ops = Content::decode(&drawn).unwrap().operations;
assert_eq!(
operands(&ops, "Tj").unwrap()[0].as_str().unwrap(),
value.as_bytes(),
"and a parser reading the stream back gets the value the caller set"
);
assert_eq!(
doc.get_dictionary(id)
.unwrap()
.get(b"V")
.unwrap()
.as_str()
.unwrap(),
value.as_bytes(),
"the stored value is untouched by the escaping"
);
}
#[test]
fn an_auto_sized_default_appearance_draws_at_a_size_that_fits_the_box() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "auto.pdf");
{
let mut doc = Document::load_mem(&ws.read_bytes("auto.pdf").unwrap()).unwrap();
let id = fields_by_name(&doc)["full_name"];
doc.get_dictionary_mut(id)
.unwrap()
.set("DA", Object::string_literal("/Helv 0 Tf 0 g"));
let mut buf = Vec::new();
doc.save_to(&mut buf).unwrap();
ws.write_bytes("auto.pdf", &buf).unwrap();
}
fill_form(
&ws,
"auto.pdf",
&[("full_name".to_string(), "Ada".to_string())],
)
.unwrap();
let doc = Document::load_mem(&ws.read_bytes("auto.pdf").unwrap()).unwrap();
let id = fields_by_name(&doc)["full_name"];
let ops = Content::decode(&appearance_of(&doc, id).unwrap())
.unwrap()
.operations;
let size = operands(&ops, "Tf").unwrap()[1].as_float().unwrap();
assert!(
size > 0.0 && size <= 20.0,
"the 0 became a real size inside the field's 20pt height, got {size}"
);
}
#[test]
fn reading_a_default_appearance_finds_the_font_and_size_it_names() {
assert_eq!(da_font(b"/Helv 12 Tf 0 g"), (b"Helv".to_vec(), 12.0));
assert_eq!(da_font(b"0 g /TiRo 9.5 Tf"), (b"TiRo".to_vec(), 9.5));
assert_eq!(da_font(b"/Helv 0 Tf"), (b"Helv".to_vec(), 0.0));
assert_eq!(da_font(b""), (b"Helv".to_vec(), 0.0));
assert_eq!(da_font(b"0 g"), (b"Helv".to_vec(), 0.0));
}
#[test]
fn naming_a_field_that_is_not_there_lists_the_ones_that_are() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "form.pdf");
let e = fill_form(
&ws,
"form.pdf",
&[("surname".to_string(), "Lovelace".to_string())],
)
.unwrap_err();
let shown = e.to_string();
assert!(
shown.contains("surname") && shown.contains("full_name") && shown.contains("subscribe"),
"the error tells the model what it could have asked for, got {shown}"
);
}
#[test]
fn a_batch_with_one_unknown_field_writes_none_of_it() {
let d = dir();
let ws = Workspace::new(d.path());
form_fixture(&ws, "form.pdf");
let before = ws.read_bytes("form.pdf").unwrap();
assert!(fill_form(
&ws,
"form.pdf",
&[
("full_name".to_string(), "Ada Lovelace".to_string()),
("nope".to_string(), "x".to_string()),
],
)
.is_err());
assert_eq!(
ws.read_bytes("form.pdf").unwrap(),
before,
"the file on disk is untouched"
);
}
#[test]
fn a_pdf_without_a_form_is_an_error_not_a_silent_success() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(&ws, "plain.pdf", &[String::from("no fields here")]).unwrap();
let e = fill_form(&ws, "plain.pdf", &[("a".to_string(), "b".to_string())]).unwrap_err();
assert!(
e.to_string().contains("AcroForm"),
"the message says what the file is missing, got {e}"
);
}
#[test]
fn a_file_that_is_not_a_pdf_is_an_error_not_a_panic() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes("notes.pdf", b"this is plainly not a PDF")
.unwrap();
ws.write_bytes("truncated.pdf", b"%PDF-1.7\n1 0 obj\n<< /Type /Cat")
.unwrap();
for rel in ["notes.pdf", "truncated.pdf"] {
for err in [
read_text(&ws, rel).unwrap_err(),
watermark(&ws, rel, "DRAFT").unwrap_err(),
fill_form(&ws, rel, &[("a".to_string(), "b".to_string())]).unwrap_err(),
] {
let shown = err.to_string();
assert!(
shown.contains(rel) && shown.contains("not a readable PDF"),
"the message names the file and what is wrong with it, got {shown}"
);
}
}
}
#[test]
fn a_pdf_that_makes_the_extractor_panic_comes_back_as_an_error() {
let d = dir();
let ws = Workspace::new(d.path());
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => "Type1",
"BaseFont" => "Helvetica", "Encoding" => "NoSuchEncoding",
});
let resources_id = doc.add_object(dictionary! {
"Font" => dictionary! { "F1" => font_id },
});
let content_id = doc.add_object(Stream::new(
Dictionary::new(),
Content {
operations: vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), Object::Real(12.0)]),
Operation::new("Td", vec![Object::Real(72.0), Object::Real(700.0)]),
Operation::new("Tj", vec![Object::string_literal("boom")]),
Operation::new("ET", vec![]),
],
}
.encode()
.unwrap(),
));
let page_id = doc.add_object(dictionary! {
"Type" => "Page", "Parent" => pages_id, "Contents" => content_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
"Resources" => resources_id,
"MediaBox" => vec![Object::Real(0.0), Object::Real(0.0),
Object::Real(612.0), Object::Real(792.0)],
}),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut buf = Vec::new();
doc.save_to(&mut buf).unwrap();
ws.write_bytes("hostile.pdf", &buf).unwrap();
let err = read_text(&ws, "hostile.pdf").unwrap_err();
let shown = err.to_string();
assert!(
shown.contains("hostile.pdf") && shown.contains("text extraction failed"),
"the run survives and the model is told which file did it, got {shown}"
);
write_new(&ws, "fine.pdf", &[String::from("readable")]).unwrap();
assert!(read_text(&ws, "fine.pdf").unwrap().contains("readable"));
}
#[test]
fn a_denied_path_is_refused_for_reading_and_for_writing() {
let d = dir();
let open = Workspace::new(d.path());
write_new(&open, "secrets/doc.pdf", &[String::from("classified")]).unwrap();
form_fixture(&open, "secrets/form.pdf");
let ws = guarded(d.path());
let read = read_text(&ws, "secrets/doc.pdf");
assert!(
matches!(&read, Err(Error::Refused { act, target, .. })
if act == "read" && target == "secrets/doc.pdf"),
"got {read:?}"
);
let written = write_new(&ws, "secrets/new.pdf", &[String::from("x")]);
assert!(
matches!(&written, Err(Error::Refused { act, target, .. })
if act == "write" && target == "secrets/new.pdf"),
"got {written:?}"
);
for edit in [
watermark(&ws, "secrets/doc.pdf", "DRAFT"),
fill_form(
&ws,
"secrets/form.pdf",
&[("full_name".to_string(), "x".to_string())],
),
] {
assert!(
matches!(&edit, Err(Error::Refused { act, .. }) if act == "read"),
"the edit is stopped at the read, before a byte of it is parsed: got {edit:?}"
);
}
}
#[test]
fn the_same_operations_succeed_on_a_path_the_policy_allows() {
let d = dir();
let ws = guarded(d.path());
assert_eq!(
write_new(&ws, "open/doc.pdf", &[String::from("classified")]).unwrap(),
Wrote::Created
);
assert!(read_text(&ws, "open/doc.pdf")
.unwrap()
.contains("classified"));
assert_eq!(
watermark(&ws, "open/doc.pdf", "DRAFT").unwrap(),
Wrote::Changed
);
form_fixture(&ws, "open/form.pdf");
assert_eq!(
fill_form(
&ws,
"open/form.pdf",
&[("full_name".to_string(), "Ada".to_string())]
)
.unwrap(),
Wrote::Changed
);
}
#[test]
fn an_empty_watermark_is_refused_rather_than_stamping_nothing() {
let d = dir();
let ws = Workspace::new(d.path());
write_new(&ws, "doc.pdf", &[String::from("body")]).unwrap();
assert!(watermark(&ws, "doc.pdf", "").is_err());
}
#[test]
fn text_outside_latin_1_becomes_a_visible_substitute_rather_than_a_wrong_glyph() {
assert_eq!(encode("caf\u{e9}"), b"caf\xe9");
assert_eq!(encode("\u{65e5}\u{672c}"), b"??");
}
#[test]
fn wrapping_keeps_every_line_within_the_column() {
let lines = wrap(&format!("{} tail", "x".repeat(200)));
assert!(lines.iter().all(|l| l.chars().count() <= WRAP), "{lines:?}");
let joined: String = lines.concat();
assert!(joined.contains("tail"));
assert_eq!(joined.matches('x').count(), 200, "no character was dropped");
}
}