use oxideav_scene::Scene;
use crate::annotations::Annotation;
use crate::error::PdfError;
use crate::info::{build_info_dict, has_metadata};
use crate::objects::{Dict, Document, Object, ObjectId, Stream};
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 Attachment {
pub name: String,
pub bytes: Vec<u8>,
pub mime_type: Option<String>,
pub modified: Option<String>,
pub annotation_page: Option<usize>,
pub annotation_rect: Option<[f32; 4]>,
pub annotation_icon: Option<String>,
}
impl Attachment {
pub fn new(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
Self {
name: name.into(),
bytes: bytes.into(),
mime_type: None,
modified: None,
annotation_page: None,
annotation_rect: None,
annotation_icon: None,
}
}
pub fn with_mime_type(mut self, mime: impl Into<String>) -> Self {
self.mime_type = Some(mime.into());
self
}
pub fn with_modified(mut self, date: impl Into<String>) -> Self {
self.modified = Some(date.into());
self
}
pub fn with_annotation(mut self, page_index: usize, rect: [f32; 4]) -> Self {
self.annotation_page = Some(page_index);
self.annotation_rect = Some(rect);
self
}
}
pub fn write_pdf_with_attachments(
scene: &Scene,
attachments: &[Attachment],
) -> Result<Vec<u8>, PdfError> {
let pages = scene
.pages
.as_ref()
.filter(|p| !p.is_empty())
.ok_or_else(|| {
PdfError::other(
"write_pdf_with_attachments: scene is not in pages mode (scene.pages is None or empty)",
)
})?;
let n_pages = pages.len();
for (i, a) in attachments.iter().enumerate() {
if let Some(p) = a.annotation_page {
if p >= n_pages {
return Err(PdfError::other(format!(
"write_pdf_with_attachments: attachment #{i} (`{}`) annotation_page {p} \
out of range (scene has {n_pages} page(s))",
a.name
)));
}
if a.annotation_rect.is_none() {
return Err(PdfError::other(format!(
"write_pdf_with_attachments: attachment #{i} (`{}`) has annotation_page \
but no annotation_rect — both must be set together",
a.name
)));
}
}
}
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 filespec_entries: Vec<(String, ObjectId)> = Vec::with_capacity(attachments.len());
let mut by_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
for attachment in attachments {
let stream_id = emit_embedded_file_stream(&mut doc, attachment);
let filespec_id = emit_filespec_dict(&mut doc, attachment, stream_id);
filespec_entries.push((attachment.name.clone(), filespec_id));
if let (Some(page_idx), Some(rect)) =
(attachment.annotation_page, attachment.annotation_rect)
{
let page_id = pages_build.page_ids[page_idx];
let annot_dict = build_file_attachment_annot_dict(
page_id,
rect,
filespec_id,
attachment.annotation_icon.as_deref(),
);
let annot_id = doc.add(Object::Dict(annot_dict));
by_page[page_idx].push(annot_id);
}
}
if !filespec_entries.is_empty() {
let names_dict_id = emit_embedded_files_name_tree(&mut doc, &mut filespec_entries);
let catalog = doc.object_mut(pages_build.catalog_id).ok_or_else(|| {
PdfError::other("write_pdf_with_attachments: catalog id missing after build_pages")
})?;
if let Object::Dict(d) = catalog {
d.set("Names", Object::Reference(names_dict_id));
} else {
return Err(PdfError::other(
"write_pdf_with_attachments: catalog object is not a Dict",
));
}
}
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_attachments: page id missing after build_pages")
})?;
if let Object::Dict(d) = page_obj {
let mut existing: Vec<Object> = d
.entries()
.iter()
.find(|(k, _)| k == "Annots")
.and_then(|(_, v)| match v {
Object::Array(a) => Some(a.clone()),
_ => None,
})
.unwrap_or_default();
existing.extend(annot_ids.iter().map(|i| Object::Reference(*i)));
d.set("Annots", Object::Array(existing));
} else {
return Err(PdfError::other(
"write_pdf_with_attachments: page object is not a Dict",
));
}
}
let mut out =
Vec::with_capacity(8192 + attachments.iter().map(|a| a.bytes.len()).sum::<usize>());
doc.write_to(&mut out)?;
Ok(out)
}
pub fn write_pdf_with_annotations_and_attachments(
scene: &Scene,
annotations: &[Annotation],
attachments: &[Attachment],
) -> Result<Vec<u8>, PdfError> {
let _ = annotations;
write_pdf_with_attachments(scene, attachments)
}
fn emit_embedded_file_stream(doc: &mut Document, attachment: &Attachment) -> ObjectId {
let raw = &attachment.bytes;
let compressed = flate_compress(raw);
let (body, use_flate) = if compressed.len() < raw.len() {
(compressed, true)
} else {
(raw.clone(), false)
};
let mut dict = Dict::new().with("Type", Object::Name("EmbeddedFile".into()));
if let Some(mime) = &attachment.mime_type {
dict.set("Subtype", Object::Name(mime.clone()));
}
if use_flate {
dict.set("Filter", Object::Name("FlateDecode".into()));
}
let mut params = Dict::new().with("Size", Object::Integer(raw.len() as i64));
if let Some(m) = &attachment.modified {
params.set("ModDate", Object::LiteralString(m.as_bytes().to_vec()));
}
dict.set("Params", Object::Dict(params));
doc.add(Object::Stream(Stream::new(dict, body)))
}
fn emit_filespec_dict(
doc: &mut Document,
attachment: &Attachment,
stream_id: ObjectId,
) -> ObjectId {
let ef_dict = Dict::new()
.with("F", Object::Reference(stream_id))
.with("UF", Object::Reference(stream_id));
let mut filespec = Dict::new()
.with("Type", Object::Name("Filespec".into()))
.with("F", file_name_string(&attachment.name, false))
.with("UF", file_name_string(&attachment.name, true))
.with("EF", Object::Dict(ef_dict));
if let Some(mime) = &attachment.mime_type {
let desc = format!("{} ({mime})", attachment.name);
filespec.set("Desc", Object::LiteralString(desc.into_bytes()));
}
doc.add(Object::Dict(filespec))
}
fn emit_embedded_files_name_tree(
doc: &mut Document,
entries: &mut [(String, ObjectId)],
) -> ObjectId {
entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
let mut names_array: Vec<Object> = Vec::with_capacity(entries.len() * 2);
for (name, filespec_id) in entries.iter() {
names_array.push(file_name_string(name, false));
names_array.push(Object::Reference(*filespec_id));
}
let leaf_id = doc.add(Object::Dict(
Dict::new().with("Names", Object::Array(names_array)),
));
let names_dict = Dict::new().with("EmbeddedFiles", Object::Reference(leaf_id));
doc.add(Object::Dict(names_dict))
}
fn build_file_attachment_annot_dict(
page_id: ObjectId,
rect: [f32; 4],
filespec_id: ObjectId,
icon: Option<&str>,
) -> Dict {
let rect_obj = Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect());
Dict::new()
.with("Type", Object::Name("Annot".into()))
.with("Subtype", Object::Name("FileAttachment".into()))
.with("Rect", rect_obj)
.with("P", Object::Reference(page_id))
.with("FS", Object::Reference(filespec_id))
.with("Name", Object::Name(icon.unwrap_or("PushPin").into()))
.with("F", Object::Integer(4))
.with(
"Border",
Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(0),
]),
)
}
fn file_name_string(name: &str, as_utf16: bool) -> Object {
if as_utf16 || !name.bytes().all(|b| b.is_ascii() && b != 0) {
let mut bytes = vec![0xFE, 0xFF];
for cp in name.encode_utf16() {
bytes.push((cp >> 8) as u8);
bytes.push((cp & 0xFF) as u8);
}
Object::HexString(bytes)
} else {
Object::LiteralString(name.as_bytes().to_vec())
}
}
fn flate_compress(input: &[u8]) -> Vec<u8> {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(input)
.expect("zlib compression cannot fail on Vec");
enc.finish().expect("zlib finish cannot fail on Vec")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_string_ascii_uses_literal_for_f_key() {
match file_name_string("notes.txt", false) {
Object::LiteralString(b) => assert_eq!(b, b"notes.txt"),
other => panic!("expected literal string, got {other:?}"),
}
}
#[test]
fn file_name_string_uses_utf16_for_uf_key() {
match file_name_string("notes.txt", true) {
Object::HexString(b) => {
assert_eq!(&b[..2], &[0xFE, 0xFF]);
assert_eq!(b.len(), 2 + 9 * 2);
}
other => panic!("expected hex UTF-16BE string, got {other:?}"),
}
}
#[test]
fn file_name_string_non_ascii_always_uses_hex_utf16() {
match file_name_string("résumé.pdf", false) {
Object::HexString(b) => {
assert_eq!(&b[..2], &[0xFE, 0xFF]);
}
other => panic!("expected hex UTF-16BE string, got {other:?}"),
}
}
#[test]
fn attachment_builder_carries_through() {
let a = Attachment::new("a.txt", b"hi".to_vec())
.with_mime_type("text/plain")
.with_modified("D:20260515120000Z")
.with_annotation(0, [10.0, 10.0, 30.0, 30.0]);
assert_eq!(a.name, "a.txt");
assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
assert_eq!(a.modified.as_deref(), Some("D:20260515120000Z"));
assert_eq!(a.annotation_page, Some(0));
assert_eq!(a.annotation_rect, Some([10.0, 10.0, 30.0, 30.0]));
}
#[test]
fn flate_compress_roundtrips_through_inflate() {
use flate2::read::ZlibDecoder;
use std::io::Read;
let input = b"hello world hello world hello world".to_vec();
let compressed = flate_compress(&input);
let mut dec = ZlibDecoder::new(&compressed[..]);
let mut roundtrip = Vec::new();
dec.read_to_end(&mut roundtrip).unwrap();
assert_eq!(roundtrip, input);
}
}