use std::path::Path;
use pdfboss_core::{Dict, Name, ObjRef, Object};
use crate::canvas::{Canvas, CanvasParts};
use crate::content::serialize_ops;
use crate::element::{self, Content};
use crate::error::{Error, Result};
use crate::font::Standard14;
use crate::sink::AsyncByteSink;
use crate::writer::{WriteOptions, Writer};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum PageSize {
A3,
#[default]
A4,
A5,
Letter,
Legal,
Custom {
width: f32,
height: f32,
},
}
impl PageSize {
pub fn dimensions(self) -> (f32, f32) {
match self {
PageSize::A3 => (841.89, 1190.55),
PageSize::A4 => (595.28, 841.89),
PageSize::A5 => (419.53, 595.28),
PageSize::Letter => (612.0, 792.0),
PageSize::Legal => (612.0, 1008.0),
PageSize::Custom { width, height } => (width, height),
}
}
pub fn landscape(self) -> PageSize {
let (width, height) = self.dimensions();
PageSize::Custom {
width: height,
height: width,
}
}
pub fn by_name(name: &str) -> Option<PageSize> {
match name.to_ascii_lowercase().as_str() {
"a3" => Some(PageSize::A3),
"a4" => Some(PageSize::A4),
"a5" => Some(PageSize::A5),
"letter" => Some(PageSize::Letter),
"legal" => Some(PageSize::Legal),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Date {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub utc_offset_minutes: i16,
}
impl Date {
pub fn to_pdf_string(self) -> String {
let Date {
year,
month,
day,
hour,
minute,
second,
utc_offset_minutes,
} = self;
let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
if utc_offset_minutes == 0 {
out.push('Z');
return out;
}
let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
let magnitude = utc_offset_minutes.unsigned_abs();
out.push_str(&format!(
"{sign}{:02}'{:02}",
magnitude / 60,
magnitude % 60
));
out
}
pub(crate) fn to_iso8601(self) -> String {
let Date {
year,
month,
day,
hour,
minute,
second,
utc_offset_minutes,
} = self;
let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
if utc_offset_minutes == 0 {
out.push('Z');
return out;
}
let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
let magnitude = utc_offset_minutes.unsigned_abs();
out.push_str(&format!(
"{sign}{:02}:{:02}",
magnitude / 60,
magnitude % 60
));
out
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Metadata {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub creation_date: Option<Date>,
pub modification_date: Option<Date>,
}
#[derive(Debug, Default)]
pub struct Page {
pub size: PageSize,
pub rotation: i32,
pub canvas: Canvas,
pub content: Vec<Content>,
pub links: Vec<LinkAnnotation>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinkAnnotation {
pub rect: [f32; 4],
pub target: LinkTarget,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LinkTarget {
Uri(String),
Page(usize),
}
impl Page {
pub fn new(size: PageSize) -> Page {
Page {
size,
..Page::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Outline {
pub bookmarks: Vec<Bookmark>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Bookmark {
pub title: String,
pub page: usize,
pub children: Vec<Bookmark>,
}
impl Bookmark {
pub fn new(title: impl Into<String>, page: usize) -> Bookmark {
Bookmark {
title: title.into(),
page,
children: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
pub name: String,
pub data: Vec<u8>,
pub mime: Option<String>,
pub modified: Option<Date>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelStyle {
Decimal,
RomanUpper,
RomanLower,
LettersUpper,
LettersLower,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PageLabel {
pub first_page: usize,
pub style: Option<LabelStyle>,
pub prefix: Option<String>,
pub start_at: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PageLayout {
SinglePage,
OneColumn,
TwoColumnLeft,
TwoColumnRight,
TwoPageLeft,
TwoPageRight,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PageMode {
UseNone,
UseOutlines,
UseThumbs,
FullScreen,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Viewer {
pub layout: Option<PageLayout>,
pub mode: Option<PageMode>,
pub open_to: Option<usize>,
}
#[derive(Debug, Default)]
pub struct Pdf {
pub metadata: Option<Metadata>,
pub pages: Vec<Page>,
pub outline: Option<Outline>,
pub attachments: Vec<Attachment>,
pub page_labels: Vec<PageLabel>,
pub viewer: Option<Viewer>,
pub options: WriteOptions,
}
impl Pdf {
pub fn to_bytes(self) -> Result<Vec<u8>> {
let (w, root) = self.assemble()?;
w.finish(root)
}
pub fn write_into(self, out: impl std::io::Write) -> Result<()> {
let (w, root) = self.assemble()?;
w.finish_into(root, out)
}
pub async fn write_into_with<S: AsyncByteSink>(self, sink: S) -> Result<S> {
let (w, root) = self.assemble()?;
w.finish_into_with(root, sink).await
}
fn assemble(self) -> Result<(Writer, ObjRef)> {
let Pdf {
metadata,
pages,
outline,
attachments,
page_labels,
viewer,
options,
} = self;
if pages.is_empty() {
return Err(Error::Other(
"a document needs at least one page".to_string(),
));
}
let mut w = Writer::new(options);
let pages_root = w.reserve();
let page_count = pages.len();
let page_refs: Vec<ObjRef> = pages.iter().map(|_| w.reserve()).collect();
let mut font_cache: Vec<(Standard14, ObjRef)> = Vec::new();
for (index, page) in pages.into_iter().enumerate() {
let Page {
size,
rotation,
mut canvas,
content,
mut links,
} = page;
if rotation % 90 != 0 {
return Err(Error::Other(format!(
"page rotation {rotation} is not a multiple of 90"
)));
}
element::lower(content, &mut canvas, &mut links)?;
let (width, height) = size.dimensions();
let parts = canvas.into_parts();
let content_ref = w.put_stream(Dict::new(), serialize_ops(&parts.ops));
let mut fonts = Dict::new();
for (index, face) in parts.fonts.iter().enumerate() {
let font_ref = cached_font(&mut w, &mut font_cache, face);
fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
}
let mut xobjects = Dict::new();
for (index, image) in parts.images.iter().enumerate() {
let image_ref = image.build_xobject(&mut w);
xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
}
for (index, (group_parts, bbox)) in parts.groups.into_iter().enumerate() {
let group_ref = build_form(&mut w, group_parts, bbox, &mut font_cache)?;
xobjects.insert(Name(format!("Gp{}", index + 1)), Object::Ref(group_ref));
}
let mut ext_gstates = Dict::new();
for (index, state) in parts.gstates.iter().enumerate() {
let gstate_ref = w.put(Object::Dict(state.ext_gstate_dict()));
ext_gstates.insert(Name(format!("Gs{}", index + 1)), Object::Ref(gstate_ref));
}
let mut resources = Dict::new();
if !fonts.is_empty() {
resources.insert(name("Font"), Object::Dict(fonts));
}
if !xobjects.is_empty() {
resources.insert(name("XObject"), Object::Dict(xobjects));
}
if !ext_gstates.is_empty() {
resources.insert(name("ExtGState"), Object::Dict(ext_gstates));
}
let mut dict = Dict::new();
dict.insert(name("Type"), Object::Name(name("Page")));
dict.insert(name("Parent"), Object::Ref(pages_root));
dict.insert(
name("MediaBox"),
Object::Array(vec![
Object::Int(0),
Object::Int(0),
Object::Real(f64::from(width)),
Object::Real(f64::from(height)),
]),
);
dict.insert(name("Contents"), Object::Ref(content_ref));
dict.insert(name("Resources"), Object::Dict(resources));
if !links.is_empty() {
let mut annots = Vec::with_capacity(links.len());
for link in links {
let action = match link.target {
LinkTarget::Uri(uri) => {
let mut action = Dict::new();
action.insert(name("S"), Object::Name(name("URI")));
action.insert(name("URI"), text_string(&uri));
action
}
LinkTarget::Page(target_index) => {
let target = page_refs.get(target_index).copied().ok_or_else(|| {
Error::Other(format!(
"link target page {target_index} is out of range: the document has {page_count} pages"
))
})?;
let mut action = Dict::new();
action.insert(name("S"), Object::Name(name("GoTo")));
action.insert(
name("D"),
Object::Array(vec![
Object::Ref(target),
Object::Name(name("XYZ")),
Object::Null,
Object::Null,
Object::Null,
]),
);
action
}
};
let mut annot = Dict::new();
annot.insert(name("Type"), Object::Name(name("Annot")));
annot.insert(name("Subtype"), Object::Name(name("Link")));
annot.insert(
name("Rect"),
Object::Array(
link.rect
.iter()
.map(|v| Object::Real(f64::from(*v)))
.collect(),
),
);
annot.insert(
name("Border"),
Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)]),
);
annot.insert(name("A"), Object::Dict(action));
annots.push(Object::Ref(w.put(Object::Dict(annot))));
}
dict.insert(name("Annots"), Object::Array(annots));
}
if rotation != 0 {
dict.insert(name("Rotate"), Object::Int(i64::from(rotation)));
}
w.fill(page_refs[index], Object::Dict(dict))?;
}
let kids: Vec<Object> = page_refs.iter().copied().map(Object::Ref).collect();
let mut tree = Dict::new();
tree.insert(name("Type"), Object::Name(name("Pages")));
tree.insert(name("Count"), Object::Int(kids.len() as i64));
tree.insert(name("Kids"), Object::Array(kids));
w.fill(pages_root, Object::Dict(tree))?;
let xmp_ref = match metadata {
Some(meta) => {
let packet = crate::xmp::packet(&meta);
if let Some(info) = info_dict(meta) {
let info_ref = w.put(Object::Dict(info));
w.set_info(info_ref);
}
let mut xmp_dict = Dict::new();
xmp_dict.insert(name("Type"), Object::Name(name("Metadata")));
xmp_dict.insert(name("Subtype"), Object::Name(name("XML")));
Some(w.put_stream_raw(xmp_dict, packet))
}
None => None,
};
let outline_ref = match outline {
Some(outline) if !outline.bookmarks.is_empty() => {
let root_ref = w.reserve();
let refs = reserve_bookmarks(&mut w, &outline.bookmarks);
let (first, last, count) = fill_bookmarks(
&mut w,
outline.bookmarks,
&refs,
root_ref,
&page_refs,
page_count,
)?;
let mut dict = Dict::new();
dict.insert(name("Type"), Object::Name(name("Outlines")));
dict.insert(name("First"), Object::Ref(first));
dict.insert(name("Last"), Object::Ref(last));
dict.insert(name("Count"), Object::Int(count));
w.fill(root_ref, Object::Dict(dict))?;
Some(root_ref)
}
_ => None,
};
let names = embedded_files_dict(&mut w, attachments)?;
let page_labels_entry = page_labels_dict(page_labels)?;
let mut catalog = Dict::new();
catalog.insert(name("Type"), Object::Name(name("Catalog")));
catalog.insert(name("Pages"), Object::Ref(pages_root));
if let Some(outline_ref) = outline_ref {
catalog.insert(name("Outlines"), Object::Ref(outline_ref));
}
if let Some(xmp_ref) = xmp_ref {
catalog.insert(name("Metadata"), Object::Ref(xmp_ref));
}
if let Some(names) = names {
catalog.insert(name("Names"), Object::Dict(names));
}
if let Some(page_labels_entry) = page_labels_entry {
catalog.insert(name("PageLabels"), Object::Dict(page_labels_entry));
}
if let Some(viewer) = viewer {
let Viewer {
layout,
mode,
open_to,
} = viewer;
if let Some(layout) = layout {
catalog.insert(
name("PageLayout"),
Object::Name(name(page_layout_name(layout))),
);
}
if let Some(mode) = mode {
catalog.insert(name("PageMode"), Object::Name(name(page_mode_name(mode))));
}
if let Some(open_to) = open_to {
let target = page_refs.get(open_to).copied().ok_or_else(|| {
Error::Other(format!(
"open_to target page {open_to} is out of range: the document has {page_count} pages"
))
})?;
catalog.insert(
name("OpenAction"),
Object::Array(vec![
Object::Ref(target),
Object::Name(name("XYZ")),
Object::Null,
Object::Null,
Object::Null,
]),
);
}
}
let root = w.put(Object::Dict(catalog));
Ok((w, root))
}
pub fn save(self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
let bytes = self.to_bytes()?;
std::fs::write(path, bytes)?;
Ok(())
}
}
fn name(text: &str) -> Name {
Name(text.to_string())
}
fn info_dict(meta: Metadata) -> Option<Dict> {
let mut dict = Dict::new();
let texts = [
("Title", meta.title),
("Author", meta.author),
("Subject", meta.subject),
("Keywords", meta.keywords),
("Creator", meta.creator),
("Producer", meta.producer),
];
for (key, value) in texts {
if let Some(value) = value {
dict.insert(name(key), text_string(&value));
}
}
let dates = [
("CreationDate", meta.creation_date),
("ModDate", meta.modification_date),
];
for (key, value) in dates {
if let Some(date) = value {
dict.insert(name(key), Object::String(date.to_pdf_string().into_bytes()));
}
}
if dict.is_empty() {
return None;
}
Some(dict)
}
const DEFAULT_ATTACHMENT_MIME: &str = "application/octet-stream";
fn embedded_files_dict(w: &mut Writer, mut attachments: Vec<Attachment>) -> Result<Option<Dict>> {
if attachments.is_empty() {
return Ok(None);
}
attachments.sort_by(|a, b| a.name.cmp(&b.name));
for pair in attachments.windows(2) {
if pair[0].name == pair[1].name {
return Err(Error::Other(format!(
"duplicate attachment name: {:?}",
pair[0].name
)));
}
}
let mut entries = Vec::with_capacity(attachments.len() * 2);
for attachment in attachments {
let Attachment {
name: file_name,
data,
mime,
modified,
description,
} = attachment;
let mime = mime.unwrap_or_else(|| DEFAULT_ATTACHMENT_MIME.to_string());
let mut params = Dict::new();
params.insert(name("Size"), Object::Int(data.len() as i64));
if let Some(modified) = modified {
params.insert(
name("ModDate"),
Object::String(modified.to_pdf_string().into_bytes()),
);
}
let mut stream_dict = Dict::new();
stream_dict.insert(name("Type"), Object::Name(name("EmbeddedFile")));
stream_dict.insert(name("Subtype"), Object::Name(Name(mime)));
stream_dict.insert(name("Params"), Object::Dict(params));
let stream_ref = w.put_stream(stream_dict, data);
let mut ef = Dict::new();
ef.insert(name("F"), Object::Ref(stream_ref));
let mut filespec = Dict::new();
filespec.insert(name("Type"), Object::Name(name("Filespec")));
filespec.insert(name("F"), text_string(&file_name));
filespec.insert(name("UF"), text_string(&file_name));
if let Some(description) = description {
filespec.insert(name("Desc"), text_string(&description));
}
filespec.insert(name("EF"), Object::Dict(ef));
let filespec_ref = w.put(Object::Dict(filespec));
entries.push(text_string(&file_name));
entries.push(Object::Ref(filespec_ref));
}
let mut name_tree = Dict::new();
name_tree.insert(name("Names"), Object::Array(entries));
let mut embedded_files = Dict::new();
embedded_files.insert(name("EmbeddedFiles"), Object::Dict(name_tree));
Ok(Some(embedded_files))
}
fn page_labels_dict(mut labels: Vec<PageLabel>) -> Result<Option<Dict>> {
if labels.is_empty() {
return Ok(None);
}
for label in &labels {
if label.start_at == 0 {
return Err(Error::Other(format!(
"page label at page {} has start_at 0: numbering starts at 1",
label.first_page
)));
}
}
labels.sort_by_key(|label| label.first_page);
if labels[0].first_page != 0 {
return Err(Error::Other("page labels must start at page 0".to_string()));
}
for pair in labels.windows(2) {
if pair[0].first_page == pair[1].first_page {
return Err(Error::Other(format!(
"duplicate page label at page {}",
pair[0].first_page
)));
}
}
let mut nums = Vec::with_capacity(labels.len() * 2);
for label in labels {
let PageLabel {
first_page,
style,
prefix,
start_at,
} = label;
let mut range = Dict::new();
if let Some(style) = style {
range.insert(name("S"), Object::Name(name(label_style_name(style))));
}
if let Some(prefix) = prefix {
range.insert(name("P"), text_string(&prefix));
}
if start_at != 1 {
range.insert(name("St"), Object::Int(i64::from(start_at)));
}
nums.push(Object::Int(first_page as i64));
nums.push(Object::Dict(range));
}
let mut dict = Dict::new();
dict.insert(name("Nums"), Object::Array(nums));
Ok(Some(dict))
}
fn label_style_name(style: LabelStyle) -> &'static str {
match style {
LabelStyle::Decimal => "D",
LabelStyle::RomanUpper => "R",
LabelStyle::RomanLower => "r",
LabelStyle::LettersUpper => "A",
LabelStyle::LettersLower => "a",
}
}
fn page_layout_name(layout: PageLayout) -> &'static str {
match layout {
PageLayout::SinglePage => "SinglePage",
PageLayout::OneColumn => "OneColumn",
PageLayout::TwoColumnLeft => "TwoColumnLeft",
PageLayout::TwoColumnRight => "TwoColumnRight",
PageLayout::TwoPageLeft => "TwoPageLeft",
PageLayout::TwoPageRight => "TwoPageRight",
}
}
fn page_mode_name(mode: PageMode) -> &'static str {
match mode {
PageMode::UseNone => "UseNone",
PageMode::UseOutlines => "UseOutlines",
PageMode::UseThumbs => "UseThumbs",
PageMode::FullScreen => "FullScreen",
}
}
struct BookmarkRef {
r: ObjRef,
children: Vec<BookmarkRef>,
}
fn reserve_bookmarks(w: &mut Writer, bookmarks: &[Bookmark]) -> Vec<BookmarkRef> {
bookmarks
.iter()
.map(|bookmark| BookmarkRef {
r: w.reserve(),
children: reserve_bookmarks(w, &bookmark.children),
})
.collect()
}
fn fill_bookmarks(
w: &mut Writer,
bookmarks: Vec<Bookmark>,
refs: &[BookmarkRef],
parent: ObjRef,
page_refs: &[ObjRef],
page_count: usize,
) -> Result<(ObjRef, ObjRef, i64)> {
let last_index = bookmarks.len() - 1;
let mut total = 0i64;
for (index, bookmark) in bookmarks.into_iter().enumerate() {
let Bookmark {
title,
page,
children,
} = bookmark;
let dest = page_refs.get(page).copied().ok_or_else(|| {
Error::Other(format!(
"bookmark target page {page} is out of range: the document has {page_count} pages"
))
})?;
let mut dict = Dict::new();
dict.insert(name("Title"), text_string(&title));
dict.insert(name("Parent"), Object::Ref(parent));
if index > 0 {
dict.insert(name("Prev"), Object::Ref(refs[index - 1].r));
}
if index < last_index {
dict.insert(name("Next"), Object::Ref(refs[index + 1].r));
}
dict.insert(
name("Dest"),
Object::Array(vec![
Object::Ref(dest),
Object::Name(name("XYZ")),
Object::Null,
Object::Null,
Object::Null,
]),
);
let mut subtree_count = 0i64;
if !children.is_empty() {
let (first, last, count) = fill_bookmarks(
w,
children,
&refs[index].children,
refs[index].r,
page_refs,
page_count,
)?;
dict.insert(name("First"), Object::Ref(first));
dict.insert(name("Last"), Object::Ref(last));
dict.insert(name("Count"), Object::Int(count));
subtree_count = count;
}
w.fill(refs[index].r, Object::Dict(dict))?;
total += 1 + subtree_count;
}
Ok((refs[0].r, refs[last_index].r, total))
}
fn cached_font(
w: &mut Writer,
font_cache: &mut Vec<(Standard14, ObjRef)>,
face: &Standard14,
) -> ObjRef {
if let Some((_, r)) = font_cache.iter().find(|(seen, _)| seen == face) {
return *r;
}
let r = w.put(Object::Dict(face.font_dict()));
font_cache.push((*face, r));
r
}
fn build_form(
w: &mut Writer,
parts: CanvasParts,
bbox: [f32; 4],
font_cache: &mut Vec<(Standard14, ObjRef)>,
) -> Result<ObjRef> {
let content = serialize_ops(&parts.ops);
let mut fonts = Dict::new();
for (index, face) in parts.fonts.iter().enumerate() {
let font_ref = cached_font(w, font_cache, face);
fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
}
let mut xobjects = Dict::new();
for (index, image) in parts.images.iter().enumerate() {
let image_ref = image.build_xobject(w);
xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
}
for (index, (group_parts, group_bbox)) in parts.groups.into_iter().enumerate() {
let group_ref = build_form(w, group_parts, group_bbox, font_cache)?;
xobjects.insert(Name(format!("Gp{}", index + 1)), Object::Ref(group_ref));
}
let mut ext_gstates = Dict::new();
for (index, state) in parts.gstates.iter().enumerate() {
let gstate_ref = w.put(Object::Dict(state.ext_gstate_dict()));
ext_gstates.insert(Name(format!("Gs{}", index + 1)), Object::Ref(gstate_ref));
}
let mut resources = Dict::new();
if !fonts.is_empty() {
resources.insert(name("Font"), Object::Dict(fonts));
}
if !xobjects.is_empty() {
resources.insert(name("XObject"), Object::Dict(xobjects));
}
if !ext_gstates.is_empty() {
resources.insert(name("ExtGState"), Object::Dict(ext_gstates));
}
let mut dict = Dict::new();
dict.insert(name("Type"), Object::Name(name("XObject")));
dict.insert(name("Subtype"), Object::Name(name("Form")));
dict.insert(
name("BBox"),
Object::Array(bbox.iter().map(|v| Object::Real(f64::from(*v))).collect()),
);
dict.insert(name("Resources"), Object::Dict(resources));
Ok(w.put_stream(dict, content))
}
fn text_string(value: &str) -> Object {
if value.is_ascii() {
return Object::String(value.as_bytes().to_vec());
}
let mut bytes = vec![0xFE, 0xFF];
for unit in value.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
Object::String(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dimensions_match_the_contract() {
assert_eq!(PageSize::A3.dimensions(), (841.89, 1190.55));
assert_eq!(PageSize::A4.dimensions(), (595.28, 841.89));
assert_eq!(PageSize::A5.dimensions(), (419.53, 595.28));
assert_eq!(PageSize::Letter.dimensions(), (612.0, 792.0));
assert_eq!(PageSize::Legal.dimensions(), (612.0, 1008.0));
assert_eq!(
PageSize::Custom {
width: 10.0,
height: 20.0
}
.dimensions(),
(10.0, 20.0)
);
}
#[test]
fn by_name_parses_the_five_named_sizes_case_insensitively() {
for (name, expected) in [
("a3", PageSize::A3),
("A4", PageSize::A4),
("a5", PageSize::A5),
("Letter", PageSize::Letter),
("LEGAL", PageSize::Legal),
] {
assert_eq!(PageSize::by_name(name), Some(expected), "{name}");
}
}
#[test]
fn by_name_rejects_anything_else() {
assert_eq!(PageSize::by_name("tabloid"), None);
assert_eq!(PageSize::by_name(""), None);
}
#[test]
fn landscape_swaps_into_custom() {
assert_eq!(
PageSize::A4.landscape(),
PageSize::Custom {
width: 841.89,
height: 595.28
}
);
assert_eq!(
PageSize::Custom {
width: 1.0,
height: 2.0
}
.landscape(),
PageSize::Custom {
width: 2.0,
height: 1.0
}
);
assert_eq!(PageSize::Letter.landscape().dimensions(), (792.0, 612.0));
}
#[test]
fn date_utc_formats_with_z() {
let date = Date {
year: 2026,
month: 8,
day: 27,
hour: 12,
minute: 30,
second: 15,
utc_offset_minutes: 0,
};
assert_eq!(date.to_pdf_string(), "D:20260827123015Z");
}
#[test]
fn date_positive_offset_pads_single_digits() {
let date = Date {
year: 987,
month: 1,
day: 2,
hour: 3,
minute: 4,
second: 5,
utc_offset_minutes: 120,
};
assert_eq!(date.to_pdf_string(), "D:09870102030405+02'00");
}
#[test]
fn date_negative_offset_keeps_minutes() {
let date = Date {
year: 1999,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 58,
utc_offset_minutes: -330,
};
assert_eq!(date.to_pdf_string(), "D:19991231235958-05'30");
}
#[test]
fn iso8601_utc_formats_with_z() {
let date = Date {
year: 2026,
month: 8,
day: 27,
hour: 12,
minute: 30,
second: 15,
utc_offset_minutes: 0,
};
assert_eq!(date.to_iso8601(), "2026-08-27T12:30:15Z");
}
#[test]
fn iso8601_positive_offset_pads_single_digits() {
let date = Date {
year: 987,
month: 1,
day: 2,
hour: 3,
minute: 4,
second: 5,
utc_offset_minutes: 120,
};
assert_eq!(date.to_iso8601(), "0987-01-02T03:04:05+02:00");
}
#[test]
fn iso8601_negative_offset_keeps_minutes() {
let date = Date {
year: 1999,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 58,
utc_offset_minutes: -330,
};
assert_eq!(date.to_iso8601(), "1999-12-31T23:59:58-05:30");
}
fn two_page_doc() -> Pdf {
let mut first = Page::new(PageSize::A4);
first
.canvas
.text("Streamed parity", 72.0, 720.0, Standard14::Helvetica, 14.0)
.expect("ASCII encodes");
let image = crate::image::ImageData::gray8(2, 2, vec![0, 85, 170, 255])
.expect("2x2 grayscale builds");
let handle = first.canvas.add_image(image);
first.canvas.draw_image(handle, 72.0, 400.0, 144.0, 144.0);
let mut second = Page::new(PageSize::Letter);
second
.canvas
.text("Page two", 72.0, 700.0, Standard14::TimesRoman, 12.0)
.expect("ASCII encodes");
Pdf {
pages: vec![first, second],
..Pdf::default()
}
}
#[test]
fn write_into_and_write_into_with_match_to_bytes() {
let bytes = two_page_doc().to_bytes().expect("to_bytes succeeds");
let mut via_io = Vec::new();
two_page_doc()
.write_into(&mut via_io)
.expect("write_into succeeds");
assert_eq!(via_io, bytes);
let via_sink = pdfboss_core::block_on(two_page_doc().write_into_with(Vec::new()))
.expect("write_into_with succeeds");
assert_eq!(via_sink, bytes);
}
#[test]
fn zero_page_document_is_an_error() {
let err = Pdf::default()
.to_bytes()
.expect_err("a page-less document must not serialize");
assert!(err.to_string().contains("at least one page"), "{err}");
}
}