mod border;
mod da;
pub(crate) mod emit;
pub mod field_body;
pub(crate) mod fmt;
pub mod font_map;
pub mod freetext;
mod markup;
pub(crate) mod popup;
mod shapes;
pub mod widget;
use kurbo::{Affine, Rect};
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use pdfrum_object::{Array, Dict, Name, Object, Resolve, names as obj_names};
use crate::annot::{Subtype, appearance, quad};
use crate::names;
use crate::vt;
#[derive(Debug, Clone, PartialEq)]
pub struct GeneratedAp {
pub stream: Vec<u8>,
pub bbox: Rect,
pub matrix: Affine,
pub resources: Dict,
pub rect_override: Option<Rect>,
pub as_override: Option<Name>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum Appearance {
#[default]
Untouched,
Generated(GeneratedAp),
Suppressed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum FocusBox {
#[default]
None,
Inflated,
Rect(Rect),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Focus {
pub annot: usize,
pub box_: FocusBox,
}
impl Focus {
#[must_use]
pub fn at(annot: usize) -> Focus {
Focus {
annot,
box_: FocusBox::None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AnnotOverlay {
entries: Vec<Appearance>,
focus: Option<Focus>,
hover: Option<usize>,
live_edit: Option<usize>,
}
impl AnnotOverlay {
#[must_use]
pub fn with_capacity(count: usize) -> AnnotOverlay {
AnnotOverlay {
entries: vec![Appearance::Untouched; count],
focus: None,
hover: None,
live_edit: None,
}
}
pub fn set_focus(&mut self, focus: Focus) {
self.focus = Some(focus);
}
#[must_use]
pub fn focus(&self) -> Option<Focus> {
self.focus
}
pub fn set_hover(&mut self, annot: usize) {
self.hover = Some(annot);
}
#[must_use]
pub fn hover(&self) -> Option<usize> {
self.hover
}
pub fn set_live_edit(&mut self, annot: usize) {
self.live_edit = Some(annot);
}
#[must_use]
pub fn live_edit(&self) -> Option<usize> {
self.live_edit
}
#[must_use]
pub fn is_live_edit(&self, index: usize) -> bool {
self.live_edit == Some(index)
}
pub fn set(&mut self, index: usize, generated: GeneratedAp) {
self.set_appearance(index, Appearance::Generated(generated));
}
pub fn set_appearance(&mut self, index: usize, appearance: Appearance) {
if let Some(slot) = self.entries.get_mut(index) {
*slot = appearance;
}
}
#[must_use]
pub fn get(&self, index: usize) -> Option<&GeneratedAp> {
match self.appearance(index) {
Appearance::Generated(generated) => Some(generated),
Appearance::Untouched | Appearance::Suppressed => None,
}
}
#[must_use]
pub fn appearance(&self, index: usize) -> &Appearance {
self.entries.get(index).unwrap_or(&Appearance::Untouched)
}
pub fn merge_over(&mut self, other: &AnnotOverlay) {
for (index, entry) in other.entries.iter().enumerate() {
if matches!(entry, Appearance::Untouched) {
continue;
}
self.set_appearance(index, entry.clone());
}
if let Some(focus) = other.focus {
self.focus = Some(focus);
}
if let Some(hover) = other.hover {
self.hover = Some(hover);
}
if let Some(live_edit) = other.live_edit {
self.live_edit = Some(live_edit);
}
}
#[must_use]
pub fn rect(&self, index: usize, raw: Rect) -> Rect {
self.get(index)
.and_then(|generated| generated.rect_override)
.unwrap_or(raw)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub struct TextFont<'a> {
pub font: &'a pdfrum_font::Font,
pub metrics: vt::Metrics<'a>,
}
impl std::fmt::Debug for TextFont<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextFont")
.field("metrics", &self.metrics)
.finish_non_exhaustive()
}
}
fn unmapped_code(font: &pdfrum_font::Font, code: u32) -> pdfrum_font::CharCode {
match font {
pdfrum_font::Font::Type0(_) => pdfrum_font::CharCode(code),
pdfrum_font::Font::Simple(_) | pdfrum_font::Font::Type3(_) => {
pdfrum_font::CharCode(code & 0xff)
}
}
}
impl TextFont<'_> {
#[must_use]
pub fn encode(&self, code: u32) -> Vec<u8> {
let name = self.font.base_font_name();
if name == b"Symbol" || name == b"ZapfDingbats" {
return vec![u8::try_from(code & 0xff).unwrap_or(0)];
}
let mut out = Vec::new();
let mapped = char::from_u32(code)
.and_then(|ch| self.font.char_code_from_unicode(ch))
.unwrap_or_else(|| unmapped_code(self.font, code));
self.font.append_char(&mut out, mapped);
out
}
#[must_use]
pub fn char_width(font: &pdfrum_font::Font, code: u32) -> i32 {
let charcode = char::from_u32(code)
.and_then(|ch| font.char_code_from_unicode(ch))
.unwrap_or_else(|| unmapped_code(font, code));
#[allow(clippy::cast_possible_truncation)]
{
font.char_width(charcode) as i32
}
}
#[must_use]
pub fn metrics_of<'a>(
font: &'a pdfrum_font::Font,
width: &'a dyn Fn(u32) -> i32,
) -> vt::Metrics<'a> {
vt::Metrics {
width,
ascent: font.type_ascent(),
descent: font.type_descent(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Substitute<'a> {
pub alias: &'a Name,
pub dict: &'a Dict,
pub font: &'a pdfrum_font::Font,
}
pub struct FormFonts {
entries: Vec<(Name, pdfrum_font::Font)>,
substitutes: Vec<(Name, Dict, pdfrum_font::Font)>,
}
impl std::fmt::Debug for FormFonts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FormFonts")
.field(
"names",
&self.entries.iter().map(|(n, _)| n).collect::<Vec<_>>(),
)
.finish_non_exhaustive()
}
}
impl FormFonts {
#[must_use]
pub fn load<R: Resolve>(
catalog: &Dict,
r: &R,
ctx: &mut pdfrum_page::BuildContext,
) -> std::sync::Arc<FormFonts> {
ctx.form_fonts(FormFonts::key(catalog, r), |ctx| {
FormFonts::build(catalog, r, ctx)
})
}
fn key<R: Resolve>(catalog: &Dict, r: &R) -> pdfrum_page::FormFontsKey {
match catalog.raw(names::ACRO_FORM) {
Some(Object::Ref(reference)) => return pdfrum_page::FormFontsKey::Form(*reference),
Some(_) => {}
None => return pdfrum_page::FormFontsKey::None,
}
let fonts = catalog
.dict(names::ACRO_FORM, r)
.and_then(|form| form.dict(names::DR, r))
.and_then(|resources| resources.raw(names::FONT).cloned());
match fonts {
Some(Object::Ref(reference)) => pdfrum_page::FormFontsKey::DirectResources(reference),
Some(_) => pdfrum_page::FormFontsKey::Direct,
None => pdfrum_page::FormFontsKey::None,
}
}
#[must_use]
fn build<R: Resolve>(catalog: &Dict, r: &R, ctx: &mut pdfrum_page::BuildContext) -> FormFonts {
let (limits, mut diags) = (
pdfrum_common::Limits::default(),
pdfrum_common::Diagnostics::default(),
);
let mut load = |dict: &Dict| {
pdfrum_font::load_with_options(
dict,
r,
&ctx.fonts,
&ctx.substitution,
&limits,
&mut diags,
)
};
let mut entries = Vec::new();
let fonts = catalog
.dict(names::ACRO_FORM, r)
.and_then(|form| form.dict(names::DR, r))
.and_then(|resources| resources.dict(names::FONT, r));
if let Some(fonts) = fonts {
for key in fonts.keys() {
let Some(dict) = fonts.dict(key, r) else {
continue;
};
if let Some(font) = load(&dict) {
entries.push((key.clone(), font));
}
}
}
entries.extend(load(&freetext::fallback_font()).map(|font| (Name::new(Vec::new()), font)));
let mut substitutes = Vec::new();
for charset in font_map::SUBSTITUTABLE_CHARSETS {
let Some(dict) = font_map::substitute_font_dict(*charset) else {
continue;
};
if let Some(font) = load(&dict) {
substitutes.push((Name::new(font_map::substitute_alias(*charset)), dict, font));
}
}
FormFonts {
entries,
substitutes,
}
}
#[must_use]
pub fn substitute(&self, charset: pdfrum_font::Charset) -> Option<Substitute<'_>> {
let alias = font_map::substitute_alias(charset);
self.substitutes
.iter()
.find(|(name, _, _)| name.as_bytes() == alias)
.map(|(name, dict, font)| Substitute {
alias: name,
dict,
font,
})
}
#[must_use]
pub fn face(&self, name: &[u8]) -> Option<&pdfrum_font::Font> {
self.entries
.iter()
.find(|(key, _)| key.as_bytes() == name)
.or_else(|| self.entries.last())
.map(|(_, font)| font)
}
#[must_use]
pub fn text_font<'a>(
&'a self,
name: &[u8],
width: &'a dyn Fn(u32) -> i32,
) -> Option<TextFont<'a>> {
let font = self.face(name)?;
Some(TextFont {
metrics: TextFont::metrics_of(font, width),
font,
})
}
}
#[must_use]
pub fn generate_appearances<R: Resolve>(
page: &Dict,
r: &R,
diags: &mut Diagnostics,
) -> AnnotOverlay {
let Some(annots) = page.array(obj_names::ANNOTS, r) else {
return AnnotOverlay::default();
};
let mut overlay = AnnotOverlay::with_capacity(annots.len());
for index in 0..annots.len() {
let Some(dict) = annots.dict_at(index, r) else {
continue;
};
if crate::annot::is_popup(&dict, r) {
continue;
}
if let Some(generated) = generate_one(&dict, r, diags) {
overlay.set(index, generated);
} else if let Some(generated) = widget::generate(&dict, r) {
diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
overlay.set(index, generated);
}
}
overlay
}
#[must_use]
pub fn generate_appearances_with_text<R: Resolve>(
page: &Dict,
catalog: &Dict,
fonts: Option<&FormFonts>,
r: &R,
diags: &mut Diagnostics,
) -> AnnotOverlay {
let Some(annots) = page.array(obj_names::ANNOTS, r) else {
return AnnotOverlay::default();
};
let mut overlay = AnnotOverlay::with_capacity(annots.len());
for index in 0..annots.len() {
let Some(dict) = annots.dict_at(index, r) else {
continue;
};
if crate::annot::is_popup(&dict, r) {
continue;
}
let named = fonts.and_then(|fonts| fonts.face(&font_name_of(&dict, catalog, r)));
let da_charset = named.map_or(pdfrum_font::Charset::Ansi, font_map::font_charset);
let substitute = fonts.and_then(|fonts| {
font_map::SUBSTITUTABLE_CHARSETS
.iter()
.find(|charset| **charset != da_charset)
.and_then(|charset| fonts.substitute(*charset))
});
let width = named.map(|font| {
move |code: u32| match substitute {
Some(sub) if !font_map::da_font_writes(font, da_charset, code) => {
font_map::substitute_width(sub.font, code)
}
_ => TextFont::char_width(font, code),
}
});
let text_font = named.zip(width.as_ref()).map(|(font, width)| TextFont {
metrics: TextFont::metrics_of(font, width),
font,
});
let generated = generate_one(&dict, r, diags)
.or_else(|| generate_text_bearing(&dict, catalog, text_font.as_ref(), r, diags))
.or_else(|| {
match text_font.as_ref() {
Some(font) => widget::generate_with_text(&dict, catalog, font, substitute, r),
None => widget::generate(&dict, r),
}
.inspect(|_| {
diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
})
});
if let Some(generated) = generated {
overlay.set(index, generated);
}
}
overlay
}
fn font_name_of<R: Resolve>(dict: &Dict, catalog: &Dict, r: &R) -> Vec<u8> {
let form = catalog.dict(names::ACRO_FORM, r).unwrap_or_default();
freetext::default_appearance(dict, &form, r)
.map(|appearance| appearance.font_name)
.unwrap_or_default()
}
fn generate_text_bearing<R: Resolve>(
dict: &Dict,
catalog: &Dict,
text_font: Option<&TextFont<'_>>,
r: &R,
diags: &mut Diagnostics,
) -> Option<GeneratedAp> {
if !should_generate(dict, r) {
return None;
}
let subtype = Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
if subtype != Subtype::FreeText {
return None;
}
let font = text_font?;
let generated = freetext::free_text(
dict,
catalog,
r,
&font.metrics,
&|code| font.encode(code),
diags,
)?;
diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
Some(GeneratedAp {
stream: generated.stream,
bbox: dict.rect(obj_names::RECT, r),
matrix: Affine::IDENTITY,
resources: resources_dict(
ext_gstate_dict(dict, false, r),
generated.font_resources.clone(),
),
rect_override: None,
as_override: None,
})
}
#[must_use]
pub(crate) fn generate_one<R: Resolve>(
dict: &Dict,
r: &R,
diags: &mut Diagnostics,
) -> Option<GeneratedAp> {
if !should_generate(dict, r) {
return None;
}
let subtype = Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
let generated = match subtype {
Subtype::Circle => markup::circle(dict, r),
Subtype::Highlight => markup::highlight(dict, r),
Subtype::Ink => markup::ink(dict, r, diags)?,
Subtype::Square => markup::square(dict, r),
Subtype::Squiggly => markup::squiggly(dict, r),
Subtype::StrikeOut => markup::strike_out(dict, r),
Subtype::Text => markup::text(dict, r),
Subtype::Underline => markup::underline(dict, r),
_ => return None,
};
diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
let rect = generated
.rect_override
.unwrap_or_else(|| dict.rect(obj_names::RECT, r));
let bbox = if generated.is_text_markup {
quad::bounding_rect_from_quad_points(dict.array(names::QUAD_POINTS, r).as_ref())
} else {
rect
};
Some(GeneratedAp {
stream: generated.stream,
bbox,
matrix: Affine::IDENTITY,
resources: resources_dict(
ext_gstate_dict(dict, generated.blend_multiply, r),
generated.font_resources.clone(),
),
rect_override: generated.rect_override,
as_override: None,
})
}
#[must_use]
pub(crate) fn should_generate<R: Resolve>(dict: &Dict, r: &R) -> bool {
if appearance::has_appearance(dict, r) {
return false;
}
let flags = crate::annot::AnnotFlags::from_bits(dict.int(names::F, r).unwrap_or(0));
!flags.is_hidden()
}
#[must_use]
pub(crate) fn ext_gstate_dict<R: Resolve>(dict: &Dict, multiply: bool, r: &R) -> Dict {
let opacity = if dict.contains_key(names::CA) {
dict.number(names::CA, r).unwrap_or(0.0)
} else {
1.0
};
let blend = if multiply {
names::MULTIPLY
} else {
obj_names::NORMAL
};
let state = Dict::from_pairs([
(
obj_names::TYPE.clone(),
Object::Name(names::EXT_G_STATE.clone()),
),
(names::CA.clone(), Object::Real(opacity)),
(names::CA_LOWER.clone(), Object::Real(opacity)),
(names::AIS.clone(), Object::Bool(false)),
(names::BM.clone(), Object::Name(blend.clone())),
]);
Dict::from_pairs([(names::GS.clone(), Object::Dict(state))])
}
#[must_use]
pub(crate) fn resources_dict(ext_gstate: Dict, font: Option<Dict>) -> Dict {
let mut resources = Dict::new();
resources.push(names::EXT_G_STATE.clone(), Object::Dict(ext_gstate));
if let Some(font) = font {
resources.push(names::FONT.clone(), Object::Dict(font));
}
resources
}
#[must_use]
pub fn stream_dict(generated: &GeneratedAp) -> Dict {
Dict::from_pairs([
(names::FORM_TYPE.clone(), Object::Int(1)),
(
obj_names::TYPE.clone(),
Object::Name(names::XOBJECT.clone()),
),
(
obj_names::SUBTYPE.clone(),
Object::Name(names::FORM.clone()),
),
(
names::MATRIX.clone(),
Object::Array(matrix_array(generated.matrix)),
),
(
names::BBOX.clone(),
Object::Array(rect_array(generated.bbox)),
),
(
names::RESOURCES.clone(),
Object::Dict(generated.resources.clone()),
),
(
names::LENGTH.clone(),
Object::Int(i64::try_from(generated.stream.len()).unwrap_or(0)),
),
])
}
#[allow(clippy::cast_possible_truncation)]
fn matrix_array(matrix: Affine) -> Array {
Array::of(
matrix
.as_coeffs()
.into_iter()
.map(|value| Object::Real(value as f32)),
)
}
fn rect_array(rect: Rect) -> Array {
use crate::geom;
Array::of(
[
geom::left(rect),
geom::bottom(rect),
geom::right(rect),
geom::top(rect),
]
.map(Object::Real),
)
}
#[cfg(test)]
mod tests {
use super::{
AnnotOverlay, Appearance, GeneratedAp, ext_gstate_dict, generate_appearances, generate_one,
should_generate,
};
use crate::geom;
use pdfrum_common::Diagnostics;
use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Resolve, Stream};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn numbers(values: &[f32]) -> Object {
Object::Array(Array::of(values.iter().copied().map(Object::from)))
}
struct Store(std::collections::HashMap<u32, std::sync::Arc<Object>>);
impl Store {
fn of(pairs: impl IntoIterator<Item = (u32, Object)>) -> Store {
Store(
pairs
.into_iter()
.map(|(num, obj)| (num, std::sync::Arc::new(obj)))
.collect(),
)
}
}
impl Resolve for Store {
fn fetch(
&self,
r: pdfrum_object::ObjRef,
) -> Result<std::sync::Arc<Object>, pdfrum_object::Error> {
self.0
.get(&r.num)
.map(std::sync::Arc::clone)
.ok_or(pdfrum_object::Error::UnresolvedRef(r))
}
}
fn reference(num: u32) -> Object {
Object::Ref(pdfrum_object::ObjRef::new(num, 0))
}
fn sticky_note() -> Dict {
dict(&[
("Subtype", Object::Name(Name::from("Text"))),
("Rect", numbers(&[10.0, 20.0, 200.0, 300.0])),
])
}
#[test]
fn an_annotation_with_an_appearance_stream_generates_nothing() {
let with_ap = dict(&[
("Subtype", Object::Name(Name::from("Text"))),
(
"AP",
Object::Dict(dict(&[(
"N",
Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::from(b"x".to_vec()),
))),
)])),
),
]);
assert!(!should_generate(&with_ap, &NoResolve));
assert!(should_generate(&sticky_note(), &NoResolve));
}
#[test]
fn a_hidden_annotation_never_generates() {
let mut hidden = sticky_note();
hidden.push(Name::from("F"), Object::Int(2));
assert!(!should_generate(&hidden, &NoResolve));
}
#[test]
fn a_character_the_face_cannot_map_is_still_written() {
let font = pdfrum_font::Font::load_standard(
pdfrum_font::StandardFont::Times,
&pdfrum_font::FontCache::new(),
);
let width = |code: u32| super::TextFont::char_width(&font, code);
let text = super::TextFont {
metrics: super::TextFont::metrics_of(&font, &width),
font: &font,
};
assert_eq!(text.encode(0x05D1), vec![0xD1]);
assert_eq!(text.encode(u32::from('A')), vec![b'A']);
assert_eq!(
super::TextFont::char_width(&font, 0x05D1),
super::TextFont::char_width(&font, 0xD1)
);
}
#[test]
fn a_sticky_notes_rectangle_override_reaches_the_overlay() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([Object::Dict(sticky_note())])),
)]);
let mut diags = Diagnostics::default();
let overlay = generate_appearances(&page, &NoResolve, &mut diags);
assert_eq!(overlay.len(), 1);
let raw = geom::rect(10.0, 20.0, 200.0, 300.0);
assert_eq!(overlay.rect(0, raw), geom::rect(10.0, 20.0, 30.0, 40.0));
assert_eq!(
overlay.get(0).map(|generated| generated.bbox),
Some(geom::rect(10.0, 20.0, 30.0, 40.0))
);
}
#[test]
fn a_pop_up_written_into_the_file_is_skipped_by_the_walk() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([Object::Dict(dict(&[(
"Subtype",
Object::Name(Name::from("Popup")),
)]))])),
)]);
let mut diags = Diagnostics::default();
let overlay = generate_appearances(&page, &NoResolve, &mut diags);
assert_eq!(overlay.len(), 1);
assert!(overlay.get(0).is_none());
}
#[test]
fn a_subtype_with_no_generator_produces_nothing() {
let stamp = dict(&[("Subtype", Object::Name(Name::from("Stamp")))]);
let mut diags = Diagnostics::default();
assert!(generate_one(&stamp, &NoResolve, &mut diags).is_none());
}
#[test]
fn a_text_markup_bounding_box_comes_from_the_quadrilaterals() {
let highlight = dict(&[
("Subtype", Object::Name(Name::from("Highlight"))),
("Rect", numbers(&[0.0, 0.0, 5.0, 5.0])),
(
"QuadPoints",
numbers(&[10.0, 20.0, 30.0, 20.0, 10.0, 10.0, 30.0, 10.0]),
),
]);
let mut diags = Diagnostics::default();
let got = generate_one(&highlight, &NoResolve, &mut diags).expect("generates");
assert_eq!(got.bbox, geom::rect(10.0, 10.0, 30.0, 20.0));
}
#[test]
fn the_graphics_state_takes_its_alpha_from_the_opacity_key() {
let opaque = ext_gstate_dict(&Dict::new(), false, &NoResolve);
let state = opaque
.dict(&Name::from("GS"), &NoResolve)
.expect("one entry");
assert_eq!(state.number(&Name::from("CA"), &NoResolve), Some(1.0));
assert_eq!(
state.name(&Name::from("BM")).map(Name::as_bytes),
Some(&b"Normal"[..])
);
let half = dict(&[("CA", Object::from(0.5_f32))]);
let state = ext_gstate_dict(&half, true, &NoResolve)
.dict(&Name::from("GS"), &NoResolve)
.expect("one entry");
assert_eq!(state.number(&Name::from("ca"), &NoResolve), Some(0.5));
assert_eq!(
state.name(&Name::from("BM")).map(Name::as_bytes),
Some(&b"Multiply"[..])
);
}
fn made(stream: &str) -> GeneratedAp {
GeneratedAp {
stream: stream.as_bytes().to_vec(),
bbox: geom::rect(0.0, 0.0, 1.0, 1.0),
matrix: kurbo::Affine::IDENTITY,
resources: Dict::default(),
rect_override: None,
as_override: None,
}
}
#[test]
fn a_supplied_overlay_wins_only_where_it_has_something_to_say() {
let mut base = AnnotOverlay::with_capacity(4);
base.set(0, made("base zero"));
base.set(1, made("base one"));
base.set(2, made("base two"));
let mut supplied = AnnotOverlay::with_capacity(4);
supplied.set(1, made("live one"));
supplied.set_appearance(2, Appearance::Suppressed);
base.merge_over(&supplied);
assert_eq!(
base.get(0).map(|g| g.stream.clone()),
Some(b"base zero".to_vec()),
"an untouched entry leaves the generated one alone"
);
assert_eq!(
base.get(1).map(|g| g.stream.clone()),
Some(b"live one".to_vec()),
"a supplied entry replaces the generated one"
);
assert_eq!(
base.appearance(2),
&Appearance::Suppressed,
"suppression survives the merge as a value"
);
assert_eq!(base.get(2), None, "a suppressed entry has no stream");
assert_eq!(base.appearance(3), &Appearance::Untouched);
}
#[test]
fn suppressed_and_untouched_differ_only_where_it_matters() {
let mut overlay = AnnotOverlay::with_capacity(2);
overlay.set_appearance(0, Appearance::Suppressed);
assert_eq!(overlay.get(0), None);
assert_eq!(overlay.get(1), None);
assert_ne!(overlay.appearance(0), overlay.appearance(1));
assert_eq!(overlay.appearance(99), &Appearance::Untouched);
}
#[test]
fn a_supplied_entry_past_the_end_is_dropped() {
let mut base = AnnotOverlay::with_capacity(1);
let mut supplied = AnnotOverlay::with_capacity(5);
supplied.set(4, made("nowhere"));
base.merge_over(&supplied);
assert_eq!(base.len(), 1);
assert_eq!(base.get(4), None);
}
#[test]
fn only_the_named_annotation_is_a_live_edit() {
let mut overlay = AnnotOverlay::with_capacity(3);
overlay.set(0, made("a"));
overlay.set(1, made("b"));
assert_eq!(overlay.live_edit(), None);
assert!(!overlay.is_live_edit(0));
overlay.set_live_edit(1);
assert_eq!(overlay.live_edit(), Some(1));
assert!(overlay.is_live_edit(1));
assert!(!overlay.is_live_edit(0));
assert!(!overlay.is_live_edit(2));
}
#[test]
fn a_session_editing_a_second_field_replaces_the_first() {
let mut overlay = AnnotOverlay::with_capacity(3);
overlay.set_live_edit(0);
overlay.set_live_edit(2);
assert_eq!(overlay.live_edit(), Some(2));
assert!(!overlay.is_live_edit(0));
}
#[test]
fn merging_carries_the_live_edit_mark_over() {
let mut base = AnnotOverlay::with_capacity(2);
let mut supplied = AnnotOverlay::with_capacity(2);
supplied.set(1, made("edited"));
supplied.set_live_edit(1);
base.merge_over(&supplied);
assert!(base.is_live_edit(1));
let untouched = AnnotOverlay::with_capacity(2);
base.merge_over(&untouched);
assert!(base.is_live_edit(1));
}
#[test]
fn the_form_faces_are_built_once_per_document_and_shared() {
let catalog = dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(7, 0)))]);
let mut ctx = pdfrum_page::BuildContext::new();
let first = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
let second = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
assert!(
std::sync::Arc::ptr_eq(&first, &second),
"a second load of one document's form must hit the cache"
);
}
#[test]
fn a_catalog_with_no_form_shares_one_set_of_faces() {
let mut ctx = pdfrum_page::BuildContext::new();
let first = super::FormFonts::load(&dict(&[]), &NoResolve, &mut ctx);
let second = super::FormFonts::load(
&dict(&[("Type", Object::Name(Name::from("Catalog")))]),
&NoResolve,
&mut ctx,
);
assert!(std::sync::Arc::ptr_eq(&first, &second));
}
#[test]
fn two_documents_do_not_share_one_contexts_form_faces() {
let mut ctx = pdfrum_page::BuildContext::new();
let one = super::FormFonts::load(
&dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(7, 0)))]),
&NoResolve,
&mut ctx,
);
let two = super::FormFonts::load(
&dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(8, 0)))]),
&NoResolve,
&mut ctx,
);
assert!(!std::sync::Arc::ptr_eq(&one, &two));
}
fn wholly_direct_form() -> Dict {
dict(&[(
"DR",
Object::Dict(dict(&[(
"Font",
Object::Dict(dict(&[("Helv", Object::Dict(dict(&[])))])),
)])),
)])
}
#[test]
fn a_form_whose_fonts_are_written_out_in_full_is_not_cached() {
let catalog = dict(&[("AcroForm", Object::Dict(wholly_direct_form()))]);
let mut ctx = pdfrum_page::BuildContext::new();
let first = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
let second = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
assert!(!std::sync::Arc::ptr_eq(&first, &second));
}
#[test]
fn a_direct_form_declaring_no_fonts_is_the_no_form_case() {
let mut ctx = pdfrum_page::BuildContext::new();
let empty_form = super::FormFonts::load(
&dict(&[(
"AcroForm",
Object::Dict(dict(&[("Fields", Object::Array(Array::default()))])),
)]),
&NoResolve,
&mut ctx,
);
let no_form = super::FormFonts::load(&dict(&[]), &NoResolve, &mut ctx);
assert!(
std::sync::Arc::ptr_eq(&empty_form, &no_form),
"a form with no default resources builds nothing a form-less \
catalog does not"
);
}
#[test]
fn a_direct_form_is_keyed_on_the_font_dictionary_it_names() {
let form = |num: u32| {
dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"DR",
Object::Dict(dict(&[("Font", reference(num))])),
)])),
)])
};
let store = Store::of([(7, Object::Dict(dict(&[]))), (8, Object::Dict(dict(&[])))]);
let mut ctx = pdfrum_page::BuildContext::new();
let first = super::FormFonts::load(&form(7), &store, &mut ctx);
let again = super::FormFonts::load(&form(7), &store, &mut ctx);
let other = super::FormFonts::load(&form(8), &store, &mut ctx);
assert!(
std::sync::Arc::ptr_eq(&first, &again),
"one font dictionary asked twice must hit the cache"
);
assert!(
!std::sync::Arc::ptr_eq(&first, &other),
"two font dictionaries must not share a slot"
);
}
#[test]
fn the_key_reads_the_font_dictionarys_spelling_and_not_its_value() {
use pdfrum_page::FormFontsKey;
let store = Store::of([(7, Object::Dict(dict(&[])))]);
let key = |catalog: &Dict| super::FormFonts::key(catalog, &store);
assert_eq!(key(&dict(&[])), FormFontsKey::None);
assert_eq!(
key(&dict(&[("AcroForm", reference(7))])),
FormFontsKey::Form(pdfrum_object::ObjRef::new(7, 0))
);
assert_eq!(
key(&dict(&[(
"AcroForm",
Object::Dict(dict(&[("Fields", Object::Array(Array::default()))]))
)])),
FormFontsKey::None,
"a direct form with no `/DR /Font` depends on nothing"
);
assert_eq!(
key(&dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"DR",
Object::Dict(dict(&[("Font", reference(7))]))
)]))
)])),
FormFontsKey::DirectResources(pdfrum_object::ObjRef::new(7, 0))
);
assert_eq!(
key(&dict(&[("AcroForm", Object::Dict(wholly_direct_form()))])),
FormFontsKey::Direct
);
}
#[test]
fn an_overlay_that_marks_no_live_edit_leaves_every_index_ordinary() {
let mut overlay = AnnotOverlay::with_capacity(4);
overlay.set(2, made("generated"));
assert_eq!(overlay.live_edit(), None);
for index in 0..6 {
assert!(!overlay.is_live_edit(index), "index {index}");
}
}
}