use oxideav_scene::Scene;
use crate::attachments::{
emit_embedded_file_stream, emit_embedded_files_name_tree, emit_filespec_dict, Attachment,
};
use crate::error::PdfError;
use crate::info::{build_info_dict, has_metadata};
use crate::objects::{Dict, Document, Object, ObjectId};
use crate::page::{build_pages, PageInput};
use crate::resources::ResourceCollector;
use crate::writer::render_frame_for_linearize as render_frame;
#[derive(Debug, Clone)]
pub struct Annotation {
pub source_page_index: usize,
pub rect: [f32; 4],
pub author: Option<String>,
pub modified: Option<String>,
pub flags: Option<u32>,
pub colour: Option<Vec<f32>>,
pub border: Option<Vec<f32>>,
pub kind: AnnotationKind,
}
#[derive(Debug, Clone)]
pub enum AnnotationKind {
Text {
contents: String,
icon: Option<String>,
open: bool,
},
Link {
uri: String,
},
FreeText {
contents: String,
default_appearance: Option<String>,
quadding: FreeTextQuadding,
},
Highlight {
quad_points: Vec<[f32; 8]>,
},
Underline { quad_points: Vec<[f32; 8]> },
Squiggly { quad_points: Vec<[f32; 8]> },
StrikeOut { quad_points: Vec<[f32; 8]> },
Stamp {
icon: Option<String>,
contents: Option<String>,
},
Square {
interior_colour: Option<Vec<f32>>,
line_width: Option<f32>,
},
Circle {
interior_colour: Option<Vec<f32>>,
line_width: Option<f32>,
},
Ink {
strokes: Vec<Vec<f32>>,
},
Line {
endpoints: [f32; 4],
line_endings: Option<[String; 2]>,
interior_colour: Option<Vec<f32>>,
leader_line: Option<f32>,
leader_line_extension: Option<f32>,
leader_line_offset: Option<f32>,
cap: bool,
intent: Option<String>,
},
Polygon {
vertices: Vec<f32>,
interior_colour: Option<Vec<f32>>,
intent: Option<String>,
},
PolyLine {
vertices: Vec<f32>,
line_endings: Option<[String; 2]>,
interior_colour: Option<Vec<f32>>,
intent: Option<String>,
},
Caret {
rect_diffs: Option<[f32; 4]>,
symbol: CaretSymbol,
},
Popup {
parent_index: Option<usize>,
open: bool,
},
FileAttachment {
icon: Option<String>,
file_name: String,
file_bytes: Vec<u8>,
mime_type: Option<String>,
},
Sound {
icon: Option<String>,
sampling_rate: f32,
channels: u32,
bits_per_sample: u32,
encoding: SoundEncoding,
sound_samples: Vec<u8>,
},
PrinterMark {
mark_name: Option<String>,
},
Watermark {
fixed_print: Option<FixedPrintSpec>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SoundEncoding {
#[default]
Raw,
Signed,
MuLaw,
ALaw,
}
impl SoundEncoding {
fn as_name(self) -> Option<&'static str> {
match self {
Self::Raw => None,
Self::Signed => Some("Signed"),
Self::MuLaw => Some("muLaw"),
Self::ALaw => Some("ALaw"),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FixedPrintSpec {
pub matrix: Option<[f32; 6]>,
pub h: Option<f32>,
pub v: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CaretSymbol {
#[default]
None,
Paragraph,
}
impl CaretSymbol {
fn as_name(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Paragraph => Some("P"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FreeTextQuadding {
#[default]
Left,
Center,
Right,
}
impl FreeTextQuadding {
fn as_int(self) -> i64 {
match self {
Self::Left => 0,
Self::Center => 1,
Self::Right => 2,
}
}
}
const DEFAULT_FREETEXT_DA: &str = "/Helv 12 Tf 0 g";
pub fn write_pdf_with_annotations(
scene: &Scene,
annotations: &[Annotation],
) -> Result<Vec<u8>, PdfError> {
let pages = scene
.pages
.as_ref()
.filter(|p| !p.is_empty())
.ok_or_else(|| {
PdfError::other(
"write_pdf_with_annotations: scene is not in pages mode (scene.pages is None or empty)",
)
})?;
let n_pages = pages.len();
validate_annotations(annotations, n_pages)?;
struct Rendered<'a> {
frame: &'a oxideav_core::vector::VectorFrame,
width: f32,
height: f32,
content_bytes: Vec<u8>,
resources: ResourceCollector,
}
let rendered: Vec<Rendered<'_>> = pages
.iter()
.map(|page| {
let (content_bytes, resources) = render_frame(&page.content);
Rendered {
frame: &page.content,
width: page.width,
height: page.height,
content_bytes,
resources,
}
})
.collect();
let inputs: Vec<PageInput<'_>> = rendered
.into_iter()
.map(|r| PageInput {
width: r.width,
height: r.height,
content_bytes: r.content_bytes,
resources: r.resources,
frame: r.frame,
})
.collect();
let mut doc = Document::new();
let pages_build = build_pages(&mut doc, inputs);
if has_metadata(&scene.metadata) {
let info_id = doc.add(Object::Dict(build_info_dict(&scene.metadata)));
doc.info = Some(info_id);
}
let annotation_ids: Vec<ObjectId> = (0..annotations.len()).map(|_| doc.allocate_id()).collect();
let mut filespec_ids: Vec<Option<ObjectId>> = vec![None; annotations.len()];
let mut sound_stream_ids: Vec<Option<ObjectId>> = vec![None; annotations.len()];
let mut name_tree_entries: Vec<(String, ObjectId)> = Vec::new();
for (i, annot) in annotations.iter().enumerate() {
match &annot.kind {
AnnotationKind::FileAttachment {
file_name,
file_bytes,
mime_type,
..
} => {
let mut attach = Attachment::new(file_name.clone(), file_bytes.clone());
if let Some(mime) = mime_type {
attach = attach.with_mime_type(mime.clone());
}
let stream_id = emit_embedded_file_stream(&mut doc, &attach);
let filespec_id = emit_filespec_dict(&mut doc, &attach, stream_id);
filespec_ids[i] = Some(filespec_id);
name_tree_entries.push((file_name.clone(), filespec_id));
}
AnnotationKind::Sound {
sampling_rate,
channels,
bits_per_sample,
encoding,
sound_samples,
..
} => {
let stream_id = emit_sound_stream(
&mut doc,
*sampling_rate,
*channels,
*bits_per_sample,
*encoding,
sound_samples.clone(),
);
sound_stream_ids[i] = Some(stream_id);
}
_ => {}
}
}
if !name_tree_entries.is_empty() {
let names_dict_id = emit_embedded_files_name_tree(&mut doc, &mut name_tree_entries);
let catalog = doc.object_mut(pages_build.catalog_id).ok_or_else(|| {
PdfError::other(
"write_pdf_with_annotations: catalog id missing for /Names patch (FileAttachment)",
)
})?;
if let Object::Dict(d) = catalog {
d.set("Names", Object::Reference(names_dict_id));
} else {
return Err(PdfError::other(
"write_pdf_with_annotations: catalog object is not a Dict",
));
}
}
let appearance_ids: Vec<Option<ObjectId>> = annotations
.iter()
.map(|annot| {
build_normal_appearance(annot)
.map(|content| emit_appearance_stream(&mut doc, content, annot.rect))
})
.collect();
let mut by_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
for (i, annot) in annotations.iter().enumerate() {
let mut dict = build_annotation_dict(
annot,
pages_build.page_ids[annot.source_page_index],
&annotation_ids,
filespec_ids[i],
sound_stream_ids[i],
)?;
if let Some(ap_id) = appearance_ids[i] {
dict.set(
"AP",
Object::Dict(Dict::new().with("N", Object::Reference(ap_id))),
);
}
doc.add_object(annotation_ids[i], Object::Dict(dict));
by_page[annot.source_page_index].push(annotation_ids[i]);
}
for (page_idx, annot_ids) in by_page.iter().enumerate() {
if annot_ids.is_empty() {
continue;
}
let page_id = pages_build.page_ids[page_idx];
let page_obj = doc.object_mut(page_id).ok_or_else(|| {
PdfError::other("write_pdf_with_annotations: page id missing after build_pages")
})?;
if let Object::Dict(d) = page_obj {
d.set(
"Annots",
Object::Array(annot_ids.iter().map(|i| Object::Reference(*i)).collect()),
);
} else {
return Err(PdfError::other(
"write_pdf_with_annotations: page object is not a Dict",
));
}
}
let mut out = Vec::with_capacity(4096);
doc.write_to(&mut out)?;
Ok(out)
}
fn validate_annotations(annotations: &[Annotation], n_pages: usize) -> Result<(), PdfError> {
let n_annots = annotations.len();
for (i, a) in annotations.iter().enumerate() {
if a.source_page_index >= n_pages {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} source_page_index {} \
out of range (scene has {n_pages} page(s))",
a.source_page_index,
)));
}
match &a.kind {
AnnotationKind::Ink { strokes } => {
if strokes.is_empty() {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Ink has no strokes",
)));
}
for (j, s) in strokes.iter().enumerate() {
if s.len() < 2 || s.len() % 2 != 0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Ink stroke #{j} \
needs an even number of coords ≥ 2 (got {})",
s.len()
)));
}
}
}
AnnotationKind::Highlight { quad_points }
| AnnotationKind::Underline { quad_points }
| AnnotationKind::Squiggly { quad_points }
| AnnotationKind::StrikeOut { quad_points }
if quad_points.is_empty() =>
{
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} text-markup \
/QuadPoints array is empty",
)));
}
AnnotationKind::Polygon { vertices, .. }
| AnnotationKind::PolyLine { vertices, .. }
if vertices.len() < 4 || vertices.len() % 2 != 0 =>
{
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} polygon/polyline \
/Vertices needs an even number of coords ≥ 4 (got {})",
vertices.len()
)));
}
AnnotationKind::Caret {
rect_diffs: Some(rd),
..
} => {
if rd.iter().any(|v| *v < 0.0) {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Caret /RD \
components must all be ≥ 0 (got {rd:?})",
)));
}
let width = a.rect[2] - a.rect[0];
let height = a.rect[3] - a.rect[1];
if rd[0] + rd[2] >= width || rd[1] + rd[3] >= height {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Caret /RD \
inset must fit inside /Rect (rd={rd:?}, rect={:?})",
a.rect,
)));
}
}
AnnotationKind::Popup {
parent_index: Some(idx),
..
} => {
if *idx >= n_annots {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Popup parent_index {idx} \
out of range (only {n_annots} annotation(s) supplied)",
)));
}
if *idx == i {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Popup parent_index points \
at itself; a Popup cannot be its own /Parent (§12.5.6.14)",
)));
}
if matches!(annotations[*idx].kind, AnnotationKind::Popup { .. }) {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Popup parent_index {idx} \
points at another /Popup; the parent must be a markup annotation \
per §12.5.6.14",
)));
}
}
AnnotationKind::FileAttachment { file_name, .. } if file_name.is_empty() => {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /FileAttachment \
file_name is empty (§7.11.2 requires a non-empty file name)",
)));
}
AnnotationKind::Sound {
sampling_rate,
channels,
bits_per_sample,
sound_samples,
..
} => {
if !sampling_rate.is_finite() || *sampling_rate <= 0.0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Sound sampling_rate \
must be a positive finite value (got {sampling_rate}) — §13.3 /R is samples/sec",
)));
}
if *channels == 0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Sound channels must be \
≥ 1 (§13.3 /C is the channel count)",
)));
}
if *bits_per_sample == 0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Sound bits_per_sample \
must be ≥ 1 (§13.3 /B is bits per sample value)",
)));
}
if sound_samples.is_empty() {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Sound sound_samples is \
empty (§12.5.6.16 /Sound stream carries the sample data)",
)));
}
}
AnnotationKind::Watermark {
fixed_print: Some(fp),
} => {
if let Some(m) = fp.matrix {
if m.iter().any(|v| !v.is_finite()) {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Watermark \
/FixedPrint /Matrix entries must all be finite (got {m:?})",
)));
}
}
if let Some(h) = fp.h {
if !h.is_finite() || h < 0.0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Watermark \
/FixedPrint /H must be a finite non-negative number \
(got {h}) — §12.5.6.22 Table 191 negative-values warning",
)));
}
}
if let Some(v) = fp.v {
if !v.is_finite() || v < 0.0 {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /Watermark \
/FixedPrint /V must be a finite non-negative number \
(got {v}) — §12.5.6.22 Table 191 negative-values warning",
)));
}
}
}
AnnotationKind::PrinterMark {
mark_name: Some(name),
} if name.is_empty() => {
return Err(PdfError::other(format!(
"write_pdf_with_annotations: annotation #{i} /PrinterMark \
/MN mark name must be non-empty (§7.3.5 / §12.5.6.20 Table 362)",
)));
}
_ => {}
}
}
Ok(())
}
fn rect_array(rect: [f32; 4]) -> Object {
Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect())
}
fn emit_appearance_stream(doc: &mut Document, content: Vec<u8>, bbox: [f32; 4]) -> ObjectId {
let dict = Dict::new()
.with("Type", Object::Name("XObject".into()))
.with("Subtype", Object::Name("Form".into()))
.with("BBox", rect_array(bbox));
doc.add(Object::Stream(crate::objects::Stream::new(dict, content)))
}
fn push_colour_op(out: &mut String, comps: &[f32], fill: bool) -> bool {
use crate::operators::format_real;
let op = match (comps.len(), fill) {
(1, true) => "g",
(1, false) => "G",
(3, true) => "rg",
(3, false) => "RG",
(4, true) => "k",
(4, false) => "K",
_ => return false,
};
for c in comps {
out.push_str(&format_real(f64::from(*c)));
out.push(' ');
}
out.push_str(op);
out.push('\n');
true
}
pub(crate) const ARC_KAPPA: f32 = 0.552_284_8;
fn build_normal_appearance(annot: &Annotation) -> Option<Vec<u8>> {
use crate::operators::format_real;
let fr = |v: f32| format_real(f64::from(v));
let stroke_comps: Option<&[f32]> = match &annot.colour {
Some(c) if c.is_empty() => None,
Some(c) => Some(c.as_slice()),
None => Some(&[0.0f32; 1][..]),
};
match &annot.kind {
AnnotationKind::Square {
interior_colour,
line_width,
}
| AnnotationKind::Circle {
interior_colour,
line_width,
} => {
let w = line_width.unwrap_or(1.0).max(0.0);
let fill_comps = interior_colour.as_deref().filter(|c| !c.is_empty());
let stroking = w > 0.0 && stroke_comps.is_some();
let filling = fill_comps.is_some();
if !filling && !stroking {
return None;
}
let mut ops = String::new();
let mut painted_colour = false;
if let Some(c) = fill_comps {
painted_colour |= push_colour_op(&mut ops, c, true);
}
if stroking {
if let Some(c) = stroke_comps {
painted_colour |= push_colour_op(&mut ops, c, false);
}
ops.push_str(&fr(w));
ops.push_str(" w\n");
}
if !painted_colour {
return None;
}
let inset = if stroking { w / 2.0 } else { 0.0 };
let (x0, y0) = (annot.rect[0] + inset, annot.rect[1] + inset);
let (x1, y1) = (annot.rect[2] - inset, annot.rect[3] - inset);
if x1 <= x0 || y1 <= y0 {
return None;
}
if matches!(annot.kind, AnnotationKind::Square { .. }) {
ops.push_str(&format!(
"{} {} {} {} re\n",
fr(x0),
fr(y0),
fr(x1 - x0),
fr(y1 - y0)
));
} else {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (rx, ry) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
let (kx, ky) = (rx * ARC_KAPPA, ry * ARC_KAPPA);
ops.push_str(&format!("{} {} m\n", fr(cx + rx), fr(cy)));
for (c1, c2, end) in [
((cx + rx, cy + ky), (cx + kx, cy + ry), (cx, cy + ry)),
((cx - kx, cy + ry), (cx - rx, cy + ky), (cx - rx, cy)),
((cx - rx, cy - ky), (cx - kx, cy - ry), (cx, cy - ry)),
((cx + kx, cy - ry), (cx + rx, cy - ky), (cx + rx, cy)),
] {
ops.push_str(&format!(
"{} {} {} {} {} {} c\n",
fr(c1.0),
fr(c1.1),
fr(c2.0),
fr(c2.1),
fr(end.0),
fr(end.1)
));
}
ops.push_str("h\n");
}
ops.push_str(match (filling, stroking) {
(true, true) => "B\n",
(true, false) => "f\n",
_ => "S\n",
});
Some(ops.into_bytes())
}
AnnotationKind::Line { endpoints, .. } => {
let c = stroke_comps?;
let mut ops = String::new();
if !push_colour_op(&mut ops, c, false) {
return None;
}
ops.push_str(&fr(annotation_border_width(annot)));
ops.push_str(" w\n");
ops.push_str(&format!(
"{} {} m\n{} {} l\nS\n",
fr(endpoints[0]),
fr(endpoints[1]),
fr(endpoints[2]),
fr(endpoints[3])
));
Some(ops.into_bytes())
}
AnnotationKind::Ink { strokes } => {
let c = stroke_comps?;
let mut ops = String::new();
if !push_colour_op(&mut ops, c, false) {
return None;
}
ops.push_str(&fr(annotation_border_width(annot)));
ops.push_str(" w\n1 J 1 j\n"); let mut any = false;
for stroke in strokes {
if stroke.len() < 4 {
continue;
}
any = true;
ops.push_str(&format!("{} {} m\n", fr(stroke[0]), fr(stroke[1])));
for xy in stroke.chunks_exact(2).skip(1) {
ops.push_str(&format!("{} {} l\n", fr(xy[0]), fr(xy[1])));
}
}
if !any {
return None;
}
ops.push_str("S\n");
Some(ops.into_bytes())
}
AnnotationKind::Polygon {
vertices,
interior_colour,
..
}
| AnnotationKind::PolyLine {
vertices,
interior_colour,
..
} => {
if vertices.len() < 4 {
return None;
}
let closed = matches!(annot.kind, AnnotationKind::Polygon { .. });
let fill_comps = if closed {
interior_colour.as_deref().filter(|c| !c.is_empty())
} else {
None
};
let mut ops = String::new();
let mut painted = false;
if let Some(c) = fill_comps {
painted |= push_colour_op(&mut ops, c, true);
}
let stroking = if let Some(c) = stroke_comps {
let ok = push_colour_op(&mut ops, c, false);
if ok {
ops.push_str(&fr(annotation_border_width(annot)));
ops.push_str(" w\n");
}
painted |= ok;
ok
} else {
false
};
if !painted {
return None;
}
ops.push_str(&format!("{} {} m\n", fr(vertices[0]), fr(vertices[1])));
for xy in vertices.chunks_exact(2).skip(1) {
ops.push_str(&format!("{} {} l\n", fr(xy[0]), fr(xy[1])));
}
if closed {
ops.push_str("h\n");
}
ops.push_str(match (fill_comps.is_some(), stroking) {
(true, true) => "B\n",
(true, false) => "f\n",
_ => "S\n",
});
Some(ops.into_bytes())
}
AnnotationKind::Highlight { quad_points } => {
let c = stroke_comps?;
let mut ops = String::new();
if !push_colour_op(&mut ops, c, true) {
return None;
}
let mut any = false;
for q in quad_points {
let Some((x0, y0, x1, y1)) = quad_bbox(q) else {
continue;
};
any = true;
ops.push_str(&format!(
"{} {} {} {} re\n",
fr(x0),
fr(y0),
fr(x1 - x0),
fr(y1 - y0)
));
}
if !any {
return None;
}
ops.push_str("f\n");
Some(ops.into_bytes())
}
AnnotationKind::Underline { quad_points }
| AnnotationKind::StrikeOut { quad_points }
| AnnotationKind::Squiggly { quad_points } => {
let c = stroke_comps?;
let mut ops = String::new();
if !push_colour_op(&mut ops, c, false) {
return None;
}
let mut any = false;
for q in quad_points {
let Some((x0, y0, x1, y1)) = quad_bbox(q) else {
continue;
};
let h = y1 - y0;
let w = h * 0.07;
any = true;
ops.push_str(&fr(w.max(0.1)));
ops.push_str(" w\n");
match &annot.kind {
AnnotationKind::Underline { .. } => {
let y = y0 + h * 0.1;
ops.push_str(&format!(
"{} {} m\n{} {} l\nS\n",
fr(x0),
fr(y),
fr(x1),
fr(y)
));
}
AnnotationKind::StrikeOut { .. } => {
let y = y0 + h * 0.5;
ops.push_str(&format!(
"{} {} m\n{} {} l\nS\n",
fr(x0),
fr(y),
fr(x1),
fr(y)
));
}
_ => {
let amp = (h * 0.1).max(0.1);
ops.push_str(&format!("{} {} m\n", fr(x0), fr(y0)));
let mut x = x0 + amp;
let mut up = true;
while x < x1 {
let y = if up { y0 + amp } else { y0 };
ops.push_str(&format!("{} {} l\n", fr(x), fr(y)));
up = !up;
x += amp;
}
ops.push_str("S\n");
}
}
}
if !any {
return None;
}
Some(ops.into_bytes())
}
_ => None,
}
}
fn quad_bbox(q: &[f32; 8]) -> Option<(f32, f32, f32, f32)> {
let xs = [q[0], q[2], q[4], q[6]];
let ys = [q[1], q[3], q[5], q[7]];
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
for x in xs {
x0 = x0.min(x);
x1 = x1.max(x);
}
for y in ys {
y0 = y0.min(y);
y1 = y1.max(y);
}
if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite())
|| x1 <= x0
|| y1 <= y0
{
return None;
}
Some((x0, y0, x1, y1))
}
fn annotation_border_width(annot: &Annotation) -> f32 {
match annot.border.as_ref().and_then(|b| b.get(2)).copied() {
Some(w) if w.is_finite() && w > 0.0 => w,
_ => 1.0,
}
}
fn colour_array(values: &[f32]) -> Object {
Object::Array(values.iter().map(|v| Object::Real(*v as f64)).collect())
}
fn border_array(values: &[f32]) -> Object {
Object::Array(values.iter().map(|v| Object::Real(*v as f64)).collect())
}
fn text_string(s: &str) -> Object {
if s.bytes().all(|b| b.is_ascii() && b != 0) {
Object::LiteralString(s.as_bytes().to_vec())
} else {
let mut bytes = vec![0xFE, 0xFF];
for cp in s.encode_utf16() {
bytes.push((cp >> 8) as u8);
bytes.push((cp & 0xFF) as u8);
}
Object::HexString(bytes)
}
}
fn flatten_quad_points(qp: &[[f32; 8]]) -> Object {
let mut out: Vec<Object> = Vec::with_capacity(qp.len() * 8);
for tuple in qp {
for v in tuple {
out.push(Object::Real(*v as f64));
}
}
Object::Array(out)
}
fn build_annotation_dict(
annot: &Annotation,
page_id: ObjectId,
annotation_ids: &[ObjectId],
filespec_id: Option<ObjectId>,
sound_stream_id: Option<ObjectId>,
) -> Result<Dict, PdfError> {
let mut d = Dict::new()
.with("Type", Object::Name("Annot".into()))
.with("Rect", rect_array(annot.rect))
.with("P", Object::Reference(page_id))
.with("F", Object::Integer(annot.flags.unwrap_or(4) as i64));
if let Some(t) = &annot.author {
d.set("T", text_string(t));
}
if let Some(m) = &annot.modified {
d.set("M", text_string(m));
}
if let Some(c) = &annot.colour {
d.set("C", colour_array(c));
}
if let Some(b) = &annot.border {
d.set("Border", border_array(b));
} else {
d.set(
"Border",
Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(0),
]),
);
}
match &annot.kind {
AnnotationKind::Text {
contents,
icon,
open,
} => {
d.set("Subtype", Object::Name("Text".into()));
d.set("Contents", text_string(contents));
d.set(
"Name",
Object::Name(icon.clone().unwrap_or_else(|| "Note".into())),
);
d.set("Open", Object::Bool(*open));
}
AnnotationKind::Link { uri } => {
d.set("Subtype", Object::Name("Link".into()));
let action = Dict::new()
.with("Type", Object::Name("Action".into()))
.with("S", Object::Name("URI".into()))
.with("URI", Object::LiteralString(uri.as_bytes().to_vec()));
d.set("A", Object::Dict(action));
}
AnnotationKind::FreeText {
contents,
default_appearance,
quadding,
} => {
d.set("Subtype", Object::Name("FreeText".into()));
d.set("Contents", text_string(contents));
let da = default_appearance.as_deref().unwrap_or(DEFAULT_FREETEXT_DA);
d.set("DA", Object::LiteralString(da.as_bytes().to_vec()));
d.set("Q", Object::Integer(quadding.as_int()));
}
AnnotationKind::Highlight { quad_points } => {
d.set("Subtype", Object::Name("Highlight".into()));
d.set("QuadPoints", flatten_quad_points(quad_points));
}
AnnotationKind::Underline { quad_points } => {
d.set("Subtype", Object::Name("Underline".into()));
d.set("QuadPoints", flatten_quad_points(quad_points));
}
AnnotationKind::Squiggly { quad_points } => {
d.set("Subtype", Object::Name("Squiggly".into()));
d.set("QuadPoints", flatten_quad_points(quad_points));
}
AnnotationKind::StrikeOut { quad_points } => {
d.set("Subtype", Object::Name("StrikeOut".into()));
d.set("QuadPoints", flatten_quad_points(quad_points));
}
AnnotationKind::Stamp { icon, contents } => {
d.set("Subtype", Object::Name("Stamp".into()));
d.set(
"Name",
Object::Name(icon.clone().unwrap_or_else(|| "Draft".into())),
);
if let Some(c) = contents {
d.set("Contents", text_string(c));
}
}
AnnotationKind::Square {
interior_colour,
line_width,
} => {
d.set("Subtype", Object::Name("Square".into()));
if let Some(ic) = interior_colour {
d.set("IC", colour_array(ic));
}
if let Some(w) = line_width {
let bs = Dict::new()
.with("Type", Object::Name("Border".into()))
.with("W", Object::Real(*w as f64));
d.set("BS", Object::Dict(bs));
}
}
AnnotationKind::Circle {
interior_colour,
line_width,
} => {
d.set("Subtype", Object::Name("Circle".into()));
if let Some(ic) = interior_colour {
d.set("IC", colour_array(ic));
}
if let Some(w) = line_width {
let bs = Dict::new()
.with("Type", Object::Name("Border".into()))
.with("W", Object::Real(*w as f64));
d.set("BS", Object::Dict(bs));
}
}
AnnotationKind::Ink { strokes } => {
d.set("Subtype", Object::Name("Ink".into()));
let mut inklist: Vec<Object> = Vec::with_capacity(strokes.len());
for stroke in strokes {
let pts: Vec<Object> = stroke.iter().map(|v| Object::Real(*v as f64)).collect();
inklist.push(Object::Array(pts));
}
d.set("InkList", Object::Array(inklist));
}
AnnotationKind::Line {
endpoints,
line_endings,
interior_colour,
leader_line,
leader_line_extension,
leader_line_offset,
cap,
intent,
} => {
d.set("Subtype", Object::Name("Line".into()));
d.set(
"L",
Object::Array(endpoints.iter().map(|v| Object::Real(*v as f64)).collect()),
);
if let Some(le) = line_endings {
d.set("LE", line_ending_pair(le));
}
if let Some(ic) = interior_colour {
d.set("IC", colour_array(ic));
}
if let Some(ll) = leader_line {
d.set("LL", Object::Real(*ll as f64));
}
if let Some(lle) = leader_line_extension {
d.set("LLE", Object::Real(*lle as f64));
}
if let Some(llo) = leader_line_offset {
d.set("LLO", Object::Real(*llo as f64));
}
if *cap {
d.set("Cap", Object::Bool(true));
}
if let Some(it) = intent {
d.set("IT", Object::Name(it.clone()));
}
}
AnnotationKind::Polygon {
vertices,
interior_colour,
intent,
} => {
d.set("Subtype", Object::Name("Polygon".into()));
d.set(
"Vertices",
Object::Array(vertices.iter().map(|v| Object::Real(*v as f64)).collect()),
);
if let Some(ic) = interior_colour {
d.set("IC", colour_array(ic));
}
if let Some(it) = intent {
d.set("IT", Object::Name(it.clone()));
}
}
AnnotationKind::PolyLine {
vertices,
line_endings,
interior_colour,
intent,
} => {
d.set("Subtype", Object::Name("PolyLine".into()));
d.set(
"Vertices",
Object::Array(vertices.iter().map(|v| Object::Real(*v as f64)).collect()),
);
if let Some(le) = line_endings {
d.set("LE", line_ending_pair(le));
}
if let Some(ic) = interior_colour {
d.set("IC", colour_array(ic));
}
if let Some(it) = intent {
d.set("IT", Object::Name(it.clone()));
}
}
AnnotationKind::Caret { rect_diffs, symbol } => {
d.set("Subtype", Object::Name("Caret".into()));
if let Some(rd) = rect_diffs {
d.set(
"RD",
Object::Array(rd.iter().map(|v| Object::Real(*v as f64)).collect()),
);
}
if let Some(name) = symbol.as_name() {
d.set("Sy", Object::Name(name.into()));
}
}
AnnotationKind::Popup { parent_index, open } => {
d.set("Subtype", Object::Name("Popup".into()));
if let Some(idx) = parent_index {
d.set("Parent", Object::Reference(annotation_ids[*idx]));
}
if *open {
d.set("Open", Object::Bool(true));
}
}
AnnotationKind::FileAttachment { icon, .. } => {
d.set("Subtype", Object::Name("FileAttachment".into()));
let fs = filespec_id.ok_or_else(|| {
PdfError::other(
"build_annotation_dict: /FileAttachment is missing its filespec id \
(pre-pass skipped?)",
)
})?;
d.set("FS", Object::Reference(fs));
let icon_name = icon.clone().unwrap_or_else(|| "PushPin".into());
d.set("Name", Object::Name(icon_name));
}
AnnotationKind::Sound { icon, .. } => {
d.set("Subtype", Object::Name("Sound".into()));
let snd = sound_stream_id.ok_or_else(|| {
PdfError::other(
"build_annotation_dict: /Sound is missing its stream id \
(pre-pass skipped?)",
)
})?;
d.set("Sound", Object::Reference(snd));
let icon_name = icon.clone().unwrap_or_else(|| "Speaker".into());
d.set("Name", Object::Name(icon_name));
}
AnnotationKind::PrinterMark { mark_name } => {
d.set("Subtype", Object::Name("PrinterMark".into()));
if let Some(name) = mark_name {
d.set("MN", Object::Name(name.clone()));
}
}
AnnotationKind::Watermark { fixed_print } => {
d.set("Subtype", Object::Name("Watermark".into()));
if let Some(fp) = fixed_print {
d.set("FixedPrint", Object::Dict(build_fixed_print_dict(fp)));
}
}
}
Ok(d)
}
fn build_fixed_print_dict(fp: &FixedPrintSpec) -> Dict {
let mut d = Dict::new().with("Type", Object::Name("FixedPrint".into()));
if let Some(m) = fp.matrix {
d.set(
"Matrix",
Object::Array(m.iter().map(|v| Object::Real(*v as f64)).collect()),
);
}
if let Some(h) = fp.h {
d.set("H", Object::Real(h as f64));
}
if let Some(v) = fp.v {
d.set("V", Object::Real(v as f64));
}
d
}
fn emit_sound_stream(
doc: &mut Document,
sampling_rate: f32,
channels: u32,
bits_per_sample: u32,
encoding: SoundEncoding,
sound_samples: Vec<u8>,
) -> ObjectId {
let mut dict = Dict::new()
.with("Type", Object::Name("Sound".into()))
.with("R", Object::Real(sampling_rate as f64));
if channels != 1 {
dict.set("C", Object::Integer(channels as i64));
}
if bits_per_sample != 8 {
dict.set("B", Object::Integer(bits_per_sample as i64));
}
if let Some(name) = encoding.as_name() {
dict.set("E", Object::Name(name.into()));
}
doc.add(Object::Stream(crate::objects::Stream::new(
dict,
sound_samples,
)))
}
fn line_ending_pair(pair: &[String; 2]) -> Object {
Object::Array(vec![
Object::Name(pair[0].clone()),
Object::Name(pair[1].clone()),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_freetext_da_is_helvetica_12pt_black() {
assert_eq!(DEFAULT_FREETEXT_DA, "/Helv 12 Tf 0 g");
}
#[test]
fn quadding_int_values_match_table_174() {
assert_eq!(FreeTextQuadding::Left.as_int(), 0);
assert_eq!(FreeTextQuadding::Center.as_int(), 1);
assert_eq!(FreeTextQuadding::Right.as_int(), 2);
}
#[test]
fn rect_array_emits_four_reals() {
match rect_array([1.0, 2.0, 3.0, 4.0]) {
Object::Array(a) => assert_eq!(a.len(), 4),
_ => panic!("expected array"),
}
}
#[test]
fn flatten_quad_points_concatenates_each_tuple() {
let qp = vec![[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [10.0; 8]];
match flatten_quad_points(&qp) {
Object::Array(a) => assert_eq!(a.len(), 16),
_ => panic!("expected array"),
}
}
#[test]
fn text_string_uses_literal_for_ascii() {
match text_string("hello") {
Object::LiteralString(bytes) => assert_eq!(bytes, b"hello"),
_ => panic!("expected literal string"),
}
}
#[test]
fn text_string_uses_hex_utf16_for_non_ascii() {
match text_string("héllo") {
Object::HexString(bytes) => {
assert!(bytes.len() >= 2);
assert_eq!(&bytes[..2], &[0xFE, 0xFF]);
}
_ => panic!("expected hex string"),
}
}
#[test]
fn line_ending_pair_emits_two_name_objects() {
match line_ending_pair(&["OpenArrow".to_string(), "ClosedArrow".to_string()]) {
Object::Array(items) => {
assert_eq!(items.len(), 2);
assert!(matches!(items[0], Object::Name(ref n) if n == "OpenArrow"));
assert!(matches!(items[1], Object::Name(ref n) if n == "ClosedArrow"));
}
_ => panic!("expected array"),
}
}
#[test]
fn polygon_polyline_validation_rejects_odd_vertex_count() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Polygon {
vertices: vec![10.0, 10.0, 20.0],
interior_colour: None,
intent: None,
},
}];
assert!(validate_annotations(&annots, 1).is_err());
}
#[test]
fn polygon_polyline_validation_rejects_under_two_vertices() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::PolyLine {
vertices: vec![10.0, 10.0],
line_endings: None,
interior_colour: None,
intent: None,
},
}];
assert!(validate_annotations(&annots, 1).is_err());
}
#[test]
fn polygon_polyline_validation_accepts_two_vertex_degenerate_case() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::PolyLine {
vertices: vec![10.0, 10.0, 90.0, 90.0],
line_endings: None,
interior_colour: None,
intent: None,
},
}];
assert!(validate_annotations(&annots, 1).is_ok());
}
#[test]
fn caret_symbol_default_is_none_and_omits_sy_entry() {
assert_eq!(CaretSymbol::default(), CaretSymbol::None);
assert!(CaretSymbol::None.as_name().is_none());
assert_eq!(CaretSymbol::Paragraph.as_name(), Some("P"));
}
#[test]
fn caret_writer_emits_subtype_and_omits_default_fields() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 50.0, 60.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Caret {
rect_diffs: None,
symbol: CaretSymbol::None,
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let subtype = d
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.expect("/Subtype emitted");
assert!(matches!(&subtype.1, Object::Name(n) if n == "Caret"));
assert!(!d.entries().iter().any(|(k, _)| k == "Sy"));
assert!(!d.entries().iter().any(|(k, _)| k == "RD"));
}
#[test]
fn caret_writer_emits_sy_p_when_paragraph_set() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 50.0, 60.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Caret {
rect_diffs: Some([1.0, 2.0, 3.0, 4.0]),
symbol: CaretSymbol::Paragraph,
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let sy = d
.entries()
.iter()
.find(|(k, _)| k == "Sy")
.expect("/Sy emitted");
assert!(matches!(&sy.1, Object::Name(n) if n == "P"));
let rd = d
.entries()
.iter()
.find(|(k, _)| k == "RD")
.expect("/RD emitted");
match &rd.1 {
Object::Array(items) => assert_eq!(items.len(), 4),
_ => panic!("/RD should be a four-real array"),
}
}
#[test]
fn caret_validation_rejects_negative_rd() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Caret {
rect_diffs: Some([-1.0, 0.0, 0.0, 0.0]),
symbol: CaretSymbol::None,
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("/RD"), "error mentions /RD: {msg}");
}
#[test]
fn caret_validation_rejects_inset_exceeding_rect() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 10.0, 10.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Caret {
rect_diffs: Some([6.0, 6.0, 6.0, 6.0]),
symbol: CaretSymbol::None,
},
}];
assert!(validate_annotations(&annots, 1).is_err());
}
#[test]
fn popup_writer_resolves_parent_index_to_pre_allocated_id() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 110.0, 60.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: Some(0),
open: true,
},
};
let pre_allocated = vec![ObjectId::new(41), ObjectId::new(42)];
let d =
build_annotation_dict(&annot, ObjectId::new(3), &pre_allocated, None, None).unwrap();
let parent = d
.entries()
.iter()
.find(|(k, _)| k == "Parent")
.expect("/Parent emitted");
match &parent.1 {
Object::Reference(id) => assert_eq!(id.number, 41),
_ => panic!("/Parent should be an indirect reference"),
}
let open = d
.entries()
.iter()
.find(|(k, _)| k == "Open")
.expect("/Open emitted");
assert!(matches!(&open.1, Object::Bool(true)));
}
#[test]
fn popup_writer_omits_open_when_default_false() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 110.0, 60.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: None,
open: false,
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
assert!(!d.entries().iter().any(|(k, _)| k == "Open"));
assert!(!d.entries().iter().any(|(k, _)| k == "Parent"));
}
#[test]
fn popup_validation_rejects_out_of_range_parent_index() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: Some(42),
open: false,
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("parent_index"), "error mentions index: {msg}");
}
#[test]
fn popup_validation_rejects_self_parent() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: Some(0),
open: false,
},
}];
assert!(validate_annotations(&annots, 1).is_err());
}
#[test]
fn popup_validation_rejects_popup_parent_pointing_at_popup() {
let annots = vec![
Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: None,
open: false,
},
},
Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Popup {
parent_index: Some(0),
open: false,
},
},
];
assert!(validate_annotations(&annots, 1).is_err());
}
#[test]
fn line_writer_emits_l_endpoints_and_omits_cap_when_false() {
let annot = Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Line {
endpoints: [10.0, 20.0, 110.0, 60.0],
line_endings: None,
interior_colour: None,
leader_line: None,
leader_line_extension: None,
leader_line_offset: None,
cap: false,
intent: None,
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let l = d
.entries()
.iter()
.find(|(k, _)| k == "L")
.expect("/L emitted");
match &l.1 {
Object::Array(items) => assert_eq!(items.len(), 4),
_ => panic!("/L should be a four-real array"),
}
assert!(!d.entries().iter().any(|(k, _)| k == "Cap"));
}
#[test]
fn sound_encoding_default_is_raw_and_omits_e_entry() {
assert_eq!(SoundEncoding::default(), SoundEncoding::Raw);
assert!(SoundEncoding::Raw.as_name().is_none());
assert_eq!(SoundEncoding::Signed.as_name(), Some("Signed"));
assert_eq!(SoundEncoding::MuLaw.as_name(), Some("muLaw"));
assert_eq!(SoundEncoding::ALaw.as_name(), Some("ALaw"));
}
#[test]
fn sound_writer_emits_subtype_and_name_default_speaker() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 22050.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: vec![0x80; 64],
},
};
let d = build_annotation_dict(
&annot,
ObjectId::new(3),
&[ObjectId::new(99)],
None,
Some(ObjectId::new(77)),
)
.unwrap();
let subtype = d
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.expect("/Subtype emitted");
assert!(matches!(&subtype.1, Object::Name(n) if n == "Sound"));
let snd = d
.entries()
.iter()
.find(|(k, _)| k == "Sound")
.expect("/Sound emitted");
assert!(matches!(&snd.1, Object::Reference(id) if id.number == 77));
let name = d
.entries()
.iter()
.find(|(k, _)| k == "Name")
.expect("/Name emitted");
assert!(matches!(&name.1, Object::Name(n) if n == "Speaker"));
}
#[test]
fn sound_writer_emits_custom_icon_when_supplied() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: Some("Mic".into()),
sampling_rate: 8000.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::MuLaw,
sound_samples: vec![0xFF; 16],
},
};
let d = build_annotation_dict(
&annot,
ObjectId::new(3),
&[ObjectId::new(99)],
None,
Some(ObjectId::new(42)),
)
.unwrap();
let name = d
.entries()
.iter()
.find(|(k, _)| k == "Name")
.expect("/Name emitted");
assert!(matches!(&name.1, Object::Name(n) if n == "Mic"));
}
#[test]
fn sound_writer_errors_when_sound_stream_id_missing() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 8000.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: vec![0; 4],
},
};
let res = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None);
assert!(res.is_err());
}
#[test]
fn sound_validation_rejects_zero_sampling_rate() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 0.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: vec![0; 4],
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("sampling_rate"), "error mentions rate: {msg}");
}
#[test]
fn sound_validation_rejects_zero_channels() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 8000.0,
channels: 0,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: vec![0; 4],
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("channels"), "error mentions channels: {msg}");
}
#[test]
fn sound_validation_rejects_zero_bits_per_sample() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 8000.0,
channels: 1,
bits_per_sample: 0,
encoding: SoundEncoding::Raw,
sound_samples: vec![0; 4],
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("bits_per_sample"),
"error mentions bits: {msg}"
);
}
#[test]
fn sound_validation_rejects_empty_sample_buffer() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: 8000.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: Vec::new(),
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("sound_samples"),
"error mentions buffer: {msg}"
);
}
#[test]
fn sound_validation_rejects_negative_sampling_rate() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Sound {
icon: None,
sampling_rate: -22050.0,
channels: 1,
bits_per_sample: 8,
encoding: SoundEncoding::Raw,
sound_samples: vec![0; 4],
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("sampling_rate"), "error mentions rate: {msg}");
}
#[test]
fn fixed_print_spec_default_is_all_absent() {
let fp = FixedPrintSpec::default();
assert!(fp.matrix.is_none());
assert!(fp.h.is_none());
assert!(fp.v.is_none());
let d = build_fixed_print_dict(&fp);
assert_eq!(d.entries().len(), 1);
let (k, v) = &d.entries()[0];
assert_eq!(k, "Type");
assert!(matches!(v, Object::Name(n) if n == "FixedPrint"));
}
#[test]
fn watermark_writer_emits_subtype_and_omits_fixed_print_when_none() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark { fixed_print: None },
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let subtype = d
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.expect("/Subtype emitted");
assert!(matches!(&subtype.1, Object::Name(n) if n == "Watermark"));
assert!(
d.entries().iter().all(|(k, _)| k != "FixedPrint"),
"/FixedPrint should be omitted when fixed_print is None",
);
}
#[test]
fn watermark_writer_emits_fixed_print_with_overrides() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec {
matrix: Some([2.0, 0.0, 0.0, 2.0, 36.0, 72.0]),
h: Some(0.5),
v: Some(0.25),
}),
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let fp_obj = d
.entries()
.iter()
.find(|(k, _)| k == "FixedPrint")
.map(|(_, v)| v)
.expect("/FixedPrint emitted");
let Object::Dict(fp) = fp_obj else {
panic!("/FixedPrint should be an inline dict, got {fp_obj:?}");
};
let t = fp
.entries()
.iter()
.find(|(k, _)| k == "Type")
.expect("/Type emitted");
assert!(matches!(&t.1, Object::Name(n) if n == "FixedPrint"));
let m = fp
.entries()
.iter()
.find(|(k, _)| k == "Matrix")
.expect("/Matrix emitted");
let Object::Array(items) = &m.1 else {
panic!("/Matrix should be an array, got {:?}", m.1);
};
assert_eq!(items.len(), 6);
assert!(fp
.entries()
.iter()
.any(|(k, v)| k == "H" && matches!(v, Object::Real(r) if (*r - 0.5).abs() < 1e-6)));
assert!(fp
.entries()
.iter()
.any(|(k, v)| k == "V" && matches!(v, Object::Real(r) if (*r - 0.25).abs() < 1e-6)));
}
#[test]
fn watermark_writer_minimum_fixed_print_emits_type_marker_only() {
let annot = Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec::default()),
},
};
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let fp_obj = d
.entries()
.iter()
.find(|(k, _)| k == "FixedPrint")
.map(|(_, v)| v)
.expect("/FixedPrint emitted");
let Object::Dict(fp) = fp_obj else {
panic!("/FixedPrint should be an inline dict, got {fp_obj:?}");
};
assert_eq!(fp.entries().len(), 1);
assert!(fp
.entries()
.iter()
.all(|(k, _)| !matches!(k.as_str(), "Matrix" | "H" | "V")));
}
#[test]
fn watermark_validation_rejects_negative_h() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec {
matrix: None,
h: Some(-0.1),
v: None,
}),
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("/H") && msg.contains("non-negative"),
"error mentions /H non-negative requirement: {msg}",
);
}
#[test]
fn watermark_validation_rejects_negative_v() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec {
matrix: None,
h: None,
v: Some(-1.0),
}),
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("/V") && msg.contains("non-negative"),
"error mentions /V non-negative requirement: {msg}",
);
}
#[test]
fn watermark_validation_rejects_non_finite_matrix() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec {
matrix: Some([1.0, 0.0, 0.0, f32::NAN, 0.0, 0.0]),
h: None,
v: None,
}),
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("/Matrix") && msg.contains("finite"),
"error mentions /Matrix finite requirement: {msg}",
);
}
#[test]
fn watermark_validation_rejects_non_finite_h() {
let annots = vec![Annotation {
source_page_index: 0,
rect: [0.0, 0.0, 100.0, 100.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::Watermark {
fixed_print: Some(FixedPrintSpec {
matrix: None,
h: Some(f32::INFINITY),
v: None,
}),
},
}];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("/H") && msg.contains("finite"),
"error mentions /H finite requirement: {msg}",
);
}
fn printer_mark_annot(mark_name: Option<&str>) -> Annotation {
Annotation {
source_page_index: 0,
rect: [10.0, 20.0, 30.0, 40.0],
author: None,
modified: None,
flags: None,
colour: None,
border: None,
kind: AnnotationKind::PrinterMark {
mark_name: mark_name.map(str::to_string),
},
}
}
#[test]
fn printer_mark_writer_emits_subtype_and_omits_mn_when_none() {
let annot = printer_mark_annot(None);
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let subtype = d
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.expect("/Subtype emitted");
assert!(matches!(&subtype.1, Object::Name(n) if n == "PrinterMark"));
assert!(
d.entries().iter().all(|(k, _)| k != "MN"),
"/MN should be omitted when mark_name is None",
);
let type_entries: Vec<&Object> = d
.entries()
.iter()
.filter_map(|(k, v)| if k == "Type" { Some(v) } else { None })
.collect();
assert_eq!(type_entries.len(), 1, "exactly one /Type entry");
assert!(matches!(type_entries[0], Object::Name(n) if n == "Annot"));
}
#[test]
fn printer_mark_writer_emits_mn_when_some() {
let annot = printer_mark_annot(Some("ColorBar"));
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let mn = d
.entries()
.iter()
.find(|(k, _)| k == "MN")
.expect("/MN emitted");
assert!(matches!(&mn.1, Object::Name(n) if n == "ColorBar"));
}
#[test]
fn printer_mark_writer_passes_arbitrary_mark_name_through_verbatim() {
let annot = printer_mark_annot(Some("MyProductionTool_CornerCalibrator"));
let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
.unwrap();
let mn = d
.entries()
.iter()
.find(|(k, _)| k == "MN")
.expect("/MN emitted");
assert!(
matches!(&mn.1, Object::Name(n) if n == "MyProductionTool_CornerCalibrator"),
"/MN passes any Name through verbatim",
);
}
#[test]
fn printer_mark_validation_rejects_empty_mark_name() {
let annots = vec![printer_mark_annot(Some(""))];
let err = validate_annotations(&annots, 1).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("/PrinterMark") && msg.contains("/MN"),
"error mentions /PrinterMark /MN: {msg}",
);
assert!(
msg.contains("non-empty"),
"error mentions non-empty requirement: {msg}",
);
}
#[test]
fn printer_mark_validation_accepts_none_and_non_empty_some() {
let annots = vec![
printer_mark_annot(None),
printer_mark_annot(Some("CutMark")),
printer_mark_annot(Some("RegistrationTarget")),
printer_mark_annot(Some("PageInformation")),
];
validate_annotations(&annots, 1).expect("all four PrinterMark variants validate");
}
}