use oxideav_scene::Scene;
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>>,
},
}
#[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 mut by_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
for annot in annotations {
let dict = build_annotation_dict(annot, pages_build.page_ids[annot.source_page_index])?;
let id = doc.add(Object::Dict(dict));
by_page[annot.source_page_index].push(id);
}
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> {
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",
)));
}
_ => {}
}
}
Ok(())
}
fn rect_array(rect: [f32; 4]) -> Object {
Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect())
}
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) -> 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));
}
}
Ok(d)
}
#[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"),
}
}
}