mod font_cache;
mod outline;
mod render_context;
mod subset;
mod tagging;
mod ttf;
use std::collections::HashMap;
use pdf_writer::types::{ActionType, AnnotationType, CidFontType, FontFlags, SystemInfo};
use pdf_writer::{Content, Date, Filter, Name, Pdf, Rect as PdfRect, Ref, Str, TextStr};
use crate::ExportError;
use outline::write_outline_tree;
use subset::{build_font_data, FontData, ADOBE_IDENTITY_UCS};
use tagging::{write_struct_tree, PageTagState, StructElemSpec};
pub use font_cache::PdfFontCache;
pub use render_context::{PdfContentStream, PdfRenderContext};
pub use tagging::{PdfTagRole, StructElemId};
use render_context::{emit_ops, PdfOp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FontId(u32);
pub struct PdfFont {
pub id: FontId,
pub ttf_bytes: Vec<u8>,
}
pub struct PdfTextRun<'a> {
pub font: FontId,
pub size_pt: f64,
pub x_pt: f64,
pub y_pt: f64,
pub rgb: u32,
pub text: &'a str,
pub glyph_advances_pt: Option<Vec<f64>>,
pub struct_tag: Option<StructElemId>,
}
pub struct PdfPageSpec<'a> {
pub width_pt: f64,
pub height_pt: f64,
pub raster: Option<&'a [u8]>,
pub raster_px: (u32, u32),
pub text_runs: Vec<PdfTextRun<'a>>,
pub links: Vec<PdfLink>,
pub content: PdfContentStream,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PdfOutlineEntry {
pub level: u8,
pub title: String,
pub page_index: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PdfLink {
pub x_pt: f64,
pub y_pt: f64,
pub width_pt: f64,
pub height_pt: f64,
pub target_page: u32,
}
#[derive(Debug, Clone, Default)]
pub struct PdfMeta {
pub title: Option<String>,
pub producer: Option<String>,
pub creation_date: Option<PdfDate>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub lang: Option<String>,
pub write_xmp: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdfDate {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
impl PdfDate {
fn to_pdf_writer_date(self) -> Date {
Date::new(self.year).month(self.month).day(self.day).hour(self.hour).minute(self.minute).second(self.second)
}
}
struct FontEntry {
font: PdfFont,
metrics: ttf::TtfMetrics,
}
struct PageTextRun {
font_id: FontId,
size_pt: f64,
x_pt: f64,
y_pt: f64,
rgb: u32,
glyphs: Vec<(u16, char)>,
real_advances_pt: Option<Vec<f64>>,
struct_tag: Option<StructElemId>,
}
struct PageRecord {
width_pt: f64,
height_pt: f64,
raster_rgb: Option<(Vec<u8>, u32, u32)>,
runs: Vec<PageTextRun>,
links: Vec<PdfLink>,
content_ops: Vec<PdfOp>,
}
pub struct PdfBuilder {
fonts: Vec<FontEntry>,
pages: Vec<PageRecord>,
meta: Option<PdfMeta>,
outline: Vec<PdfOutlineEntry>,
tagged: bool,
struct_elems: Vec<StructElemSpec>,
}
impl Default for PdfBuilder {
fn default() -> Self {
Self::new()
}
}
impl PdfBuilder {
pub fn new() -> Self {
Self { fonts: Vec::new(), pages: Vec::new(), meta: None, outline: Vec::new(), tagged: true, struct_elems: Vec::new() }
}
pub fn register_font(&mut self, ttf_bytes: &[u8]) -> FontId {
let id = FontId(self.fonts.len() as u32);
let metrics = ttf::TtfMetrics::parse(ttf_bytes);
self.fonts.push(FontEntry { font: PdfFont { id, ttf_bytes: ttf_bytes.to_vec() }, metrics });
id
}
pub fn set_meta(&mut self, meta: PdfMeta) {
self.meta = Some(meta);
}
pub fn set_outline(&mut self, entries: Vec<PdfOutlineEntry>) {
self.outline = entries;
}
pub fn set_tagged(&mut self, tagged: bool) {
self.tagged = tagged;
}
pub(crate) fn is_tagged(&self) -> bool {
self.tagged
}
pub fn register_struct_elem(&mut self, role: PdfTagRole, parent: Option<StructElemId>, alt: Option<String>) -> StructElemId {
let id = StructElemId(self.struct_elems.len() as u32);
self.struct_elems.push(StructElemSpec { role, parent, alt });
id
}
pub fn add_page(&mut self, spec: PdfPageSpec<'_>) -> Result<(), ExportError> {
let raster_rgb = match spec.raster {
Some(png_bytes) => Some(decode_opaque_rgb(png_bytes, spec.raster_px)?),
None => None,
};
let runs = spec
.text_runs
.into_iter()
.map(|run| {
let metrics = &self.fonts[run.font.0 as usize].metrics;
let glyphs = run.text.chars().map(|ch| (metrics.gid_for_char(ch).unwrap_or(0), ch)).collect();
PageTextRun {
font_id: run.font,
size_pt: run.size_pt,
x_pt: run.x_pt,
y_pt: run.y_pt,
rgb: run.rgb,
glyphs,
real_advances_pt: run.glyph_advances_pt,
struct_tag: run.struct_tag,
}
})
.collect();
self.pages.push(PageRecord { width_pt: spec.width_pt, height_pt: spec.height_pt, raster_rgb, runs, links: spec.links, content_ops: spec.content.0 });
Ok(())
}
fn font_metrics(&self, id: FontId) -> &ttf::TtfMetrics {
&self.fonts[id.0 as usize].metrics
}
pub fn finish(self) -> Vec<u8> {
let mut pdf = Pdf::new();
let mut refs = RefAllocator::new();
let catalog_id = refs.next();
let page_tree_id = refs.next();
let font_data: Vec<FontData> =
self.fonts.iter().enumerate().map(|(i, entry)| build_font_data(i as u32, &entry.font.ttf_bytes, &entry.metrics, &self.pages)).collect();
let font_refs: Vec<FontRefs> = self
.fonts
.iter()
.map(|_| FontRefs { type0: refs.next(), cid: refs.next(), descriptor: refs.next(), file: refs.next(), to_unicode: refs.next() })
.collect();
let page_refs: Vec<PageRefs> = self
.pages
.iter()
.map(|page| PageRefs {
page: refs.next(),
content: refs.next(),
image: page.raster_rgb.as_ref().map(|_| refs.next()),
annotations: page.links.iter().map(|_| refs.next()).collect(),
})
.collect();
let info_ref = self.meta.as_ref().map(|_| refs.next());
let page_only_refs: Vec<Ref> = page_refs.iter().map(|r| r.page).collect();
let page_heights_pt: Vec<f64> = self.pages.iter().map(|p| p.height_pt).collect();
let outline_root_ref = write_outline_tree(&mut pdf, &mut refs, &self.outline, &page_only_refs, &page_heights_pt);
let font_names: Vec<String> = (0..self.fonts.len()).map(|i| format!("F{i}")).collect();
let base_font_names: Vec<String> = font_data
.iter()
.enumerate()
.map(|(i, data)| match &data.subset_tag {
Some(tag) => format!("{tag}+EmbeddedFont{i}"),
None => format!("EmbeddedFont{i}"),
})
.collect();
let image_names: Vec<String> = (0..self.pages.len()).map(|i| format!("Im{i}")).collect();
pdf.pages(page_tree_id).kids(page_refs.iter().map(|r| r.page)).count(page_refs.len() as i32);
for (entry, refs, data, base_font_name) in izip(&self.fonts, &font_refs, &font_data, &base_font_names) {
write_font(&mut pdf, refs, base_font_name, entry, data);
}
let struct_roles: Vec<PdfTagRole> = self.struct_elems.iter().map(|e| e.role).collect();
let mut page_struct_occurrences: Vec<Vec<(u32, i32)>> = Vec::with_capacity(self.pages.len());
for (i, page) in self.pages.iter().enumerate() {
let occurrences = write_page(
&mut pdf,
&mut refs,
page_tree_id,
&page_refs[i],
&font_names,
&font_refs,
page,
image_names[i].as_str(),
&font_data,
&page_only_refs,
&page_heights_pt,
&self.fonts,
i,
self.tagged,
&struct_roles,
);
page_struct_occurrences.push(occurrences);
}
let struct_tree_root_ref =
if self.tagged { Some(write_struct_tree(&mut pdf, &mut refs, &self.struct_elems, &page_only_refs, &page_struct_occurrences)) } else { None };
let metadata_ref = if self.meta.as_ref().is_some_and(|m| m.write_xmp) { Some(refs.next()) } else { None };
if let (Some(meta), Some(id)) = (&self.meta, metadata_ref) {
let xmp = write_xmp_packet(meta);
pdf.metadata(id, &xmp);
}
{
let mut catalog = pdf.catalog(catalog_id);
catalog.pages(page_tree_id);
if let Some(root) = outline_root_ref {
catalog.outlines(root);
}
if self.tagged {
catalog.mark_info().marked(true);
}
if let Some(root) = struct_tree_root_ref {
catalog.pair(Name(b"StructTreeRoot"), root);
}
if let Some(lang) = self.meta.as_ref().and_then(|m| m.lang.as_deref()) {
catalog.lang(TextStr(lang));
}
if let Some(id) = metadata_ref {
catalog.metadata(id);
}
}
if let (Some(meta), Some(info_id)) = (&self.meta, info_ref) {
write_info(&mut pdf, info_id, meta);
}
pdf.finish()
}
}
fn izip<'a, A, B, C, D>(a: &'a [A], b: &'a [B], c: &'a [C], d: &'a [D]) -> impl Iterator<Item = (&'a A, &'a B, &'a C, &'a D)> {
a.iter().zip(b.iter()).zip(c.iter()).zip(d.iter()).map(|(((a, b), c), d)| (a, b, c, d))
}
struct RefAllocator(i32);
impl RefAllocator {
fn new() -> Self {
Self(1)
}
fn next(&mut self) -> Ref {
let r = Ref::new(self.0);
self.0 += 1;
r
}
}
struct FontRefs {
type0: Ref,
cid: Ref,
descriptor: Ref,
file: Ref,
to_unicode: Ref,
}
struct PageRefs {
page: Ref,
content: Ref,
image: Option<Ref>,
annotations: Vec<Ref>,
}
const ADOBE_IDENTITY_0: SystemInfo<'static> = SystemInfo { registry: Str(b"Adobe"), ordering: Str(b"Identity"), supplement: 0 };
fn flate_compress(data: &[u8]) -> Vec<u8> {
miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
}
fn write_font(pdf: &mut Pdf, refs: &FontRefs, base_font_name: &str, entry: &FontEntry, data: &FontData) {
let bbox = entry.metrics.bbox_1000();
{
let mut descriptor = pdf.font_descriptor(refs.descriptor);
descriptor.name(Name(base_font_name.as_bytes()));
descriptor.flags(FontFlags::SYMBOLIC);
descriptor.bbox(PdfRect::new(bbox[0] as f32, bbox[1] as f32, bbox[2] as f32, bbox[3] as f32));
descriptor.italic_angle(0.0);
descriptor.ascent(entry.metrics.ascent_1000() as f32);
descriptor.descent(entry.metrics.descent_1000() as f32);
descriptor.cap_height(entry.metrics.cap_height_1000() as f32);
descriptor.stem_v(80.0);
descriptor.font_file2(refs.file);
}
{
let compressed = flate_compress(&data.subset_bytes);
let mut file = pdf.stream(refs.file, &compressed);
file.filter(Filter::FlateDecode);
file.pair(Name(b"Length1"), data.subset_bytes.len() as i32);
}
{
let mut cid = pdf.cid_font(refs.cid);
cid.subtype(CidFontType::Type2);
cid.base_font(Name(base_font_name.as_bytes()));
cid.system_info(ADOBE_IDENTITY_0);
cid.font_descriptor(refs.descriptor);
{
let mut widths = cid.widths();
for &(cid_value, width) in &data.widths {
widths.consecutive(cid_value, [width]);
}
}
cid.cid_to_gid_map_predefined(Name(b"Identity"));
}
{
let mut type0 = pdf.type0_font(refs.type0);
type0.base_font(Name(base_font_name.as_bytes()));
type0.encoding_predefined(Name(b"Identity-H"));
type0.descendant_font(refs.cid);
type0.to_unicode(refs.to_unicode);
}
{
let compressed = flate_compress(&data.to_unicode);
let mut cmap = pdf.cmap(refs.to_unicode, &compressed);
cmap.filter(Filter::FlateDecode);
cmap.name(Name(b"Adobe-Identity-UCS"));
cmap.system_info(ADOBE_IDENTITY_UCS);
}
}
#[allow(clippy::too_many_arguments)]
fn write_page(
pdf: &mut Pdf,
ref_alloc: &mut RefAllocator,
page_tree_id: Ref,
refs: &PageRefs,
font_names: &[String],
font_refs: &[FontRefs],
page: &PageRecord,
image_name: &str,
font_data: &[FontData],
page_refs_by_index: &[Ref],
page_heights_pt: &[f64],
fonts: &[FontEntry],
page_index: usize,
tagged: bool,
struct_roles: &[PdfTagRole],
) -> Vec<(u32, i32)> {
if let (Some(image_ref), Some((rgb, width, height))) = (refs.image, page.raster_rgb.as_ref()) {
let compressed = flate_compress(rgb);
let mut image = pdf.image_xobject(image_ref, &compressed);
image.filter(Filter::FlateDecode);
image.width(*width as i32);
image.height(*height as i32);
image.color_space().device_rgb();
image.bits_per_component(8);
}
let mut tag_state = PageTagState::default();
let op_resources = {
let mut content = Content::new();
if refs.image.is_some() {
content.save_state();
content.transform([page.width_pt as f32, 0.0, 0.0, page.height_pt as f32, 0.0, 0.0]);
content.x_object(Name(image_name.as_bytes()));
content.restore_state();
}
let op_resources = emit_ops(pdf, ref_alloc, &mut content, &page.content_ops, page.height_pt, font_data, font_names, tagged, struct_roles, &mut tag_state);
if !page.runs.is_empty() {
content.begin_text();
let mut current_tag: Option<u32> = None;
for run in &page.runs {
if tagged {
let run_tag = run.struct_tag.map(|t| t.0);
if run_tag != current_tag {
if current_tag.is_some() {
content.end_marked_content();
}
if let Some(id) = run.struct_tag {
let mcid = tag_state.open(id);
let mut mc = content.begin_marked_content_with_properties(Name(struct_roles[id.0 as usize].bdc_tag_name()));
mc.properties().identify(mcid);
}
current_tag = run_tag;
}
}
let (r, g, b) = rgb_components(run.rgb);
content.set_fill_rgb(r, g, b);
content.set_font(Name(font_names[run.font_id.0 as usize].as_bytes()), run.size_pt as f32);
content.set_text_matrix([1.0, 0.0, 0.0, 1.0, run.x_pt as f32, (page.height_pt - run.y_pt) as f32]);
let orig_to_new = &font_data[run.font_id.0 as usize].orig_to_new;
let metrics = &fonts[run.font_id.0 as usize].metrics;
show_run(&mut content, run, orig_to_new, metrics);
}
if tagged && current_tag.is_some() {
content.end_marked_content();
}
content.end_text();
}
let raw = content.finish();
let compressed = flate_compress(&raw);
let mut stream = pdf.stream(refs.content, &compressed);
stream.filter(Filter::FlateDecode);
op_resources
};
for (link, &annot_ref) in page.links.iter().zip(refs.annotations.iter()) {
let (Some(&target_page_ref), Some(&target_height)) =
(page_refs_by_index.get(link.target_page as usize), page_heights_pt.get(link.target_page as usize))
else {
continue;
};
let x1 = link.x_pt as f32;
let x2 = (link.x_pt + link.width_pt) as f32;
let y1 = (page.height_pt - (link.y_pt + link.height_pt)) as f32;
let y2 = (page.height_pt - link.y_pt) as f32;
let mut annot = pdf.annotation(annot_ref);
annot.subtype(AnnotationType::Link);
annot.rect(PdfRect::new(x1, y1, x2, y2));
annot.border_style().width(0.0);
annot.action().action_type(ActionType::GoTo).destination().page(target_page_ref).xyz(0.0, target_height as f32, None);
}
let has_tagged_content = tagged && !tag_state.occurrences_is_empty();
{
let mut page_writer = pdf.page(refs.page);
page_writer.parent(page_tree_id);
page_writer.media_box(PdfRect::new(0.0, 0.0, page.width_pt as f32, page.height_pt as f32));
page_writer.contents(refs.content);
if !refs.annotations.is_empty() {
page_writer.annotations(refs.annotations.iter().copied());
}
if has_tagged_content {
page_writer.struct_parents(page_index as i32);
}
let mut resources = page_writer.resources();
{
let mut fonts_dict = resources.fonts();
for (name, refs) in font_names.iter().zip(font_refs.iter()) {
fonts_dict.pair(Name(name.as_bytes()), refs.type0);
}
}
if refs.image.is_some() || !op_resources.images.is_empty() {
let mut x_objects = resources.x_objects();
if let Some(image_ref) = refs.image {
x_objects.pair(Name(image_name.as_bytes()), image_ref);
}
for (name, image_ref) in &op_resources.images {
x_objects.pair(Name(name.as_bytes()), *image_ref);
}
}
if !op_resources.patterns.is_empty() {
let mut patterns = resources.patterns();
for (name, pattern_ref) in &op_resources.patterns {
patterns.pair(Name(name.as_bytes()), *pattern_ref);
}
}
if !op_resources.ext_gstates.is_empty() {
let mut ext_g_states = resources.ext_g_states();
for (name, gstate_ref) in &op_resources.ext_gstates {
ext_g_states.pair(Name(name.as_bytes()), *gstate_ref);
}
}
}
tag_state.into_occurrences()
}
fn cid_bytes(glyphs: &[(u16, char)], orig_to_new: &HashMap<u16, u16>) -> Vec<u8> {
let mut bytes = Vec::with_capacity(glyphs.len() * 2);
for &(gid, _) in glyphs {
let cid = orig_to_new.get(&gid).copied().unwrap_or(0);
bytes.extend_from_slice(&cid.to_be_bytes());
}
bytes
}
const MIN_TJ_ADJUST_UNITS: f32 = 1.0;
fn show_run(content: &mut Content, run: &PageTextRun, orig_to_new: &HashMap<u16, u16>, metrics: &ttf::TtfMetrics) {
let Some(real) = run.real_advances_pt.as_deref() else {
content.show(Str(&cid_bytes(&run.glyphs, orig_to_new)));
return;
};
let glyph_count = run.glyphs.len();
let mut adjustments: Vec<f32> = Vec::with_capacity(glyph_count.saturating_sub(1));
for (i, &(gid, _)) in run.glyphs.iter().enumerate() {
if i + 1 >= glyph_count {
break;
}
let static_width_pt = metrics.advance_1000_for_gid(gid) / 1000.0 * run.size_pt;
let target_pt = real.get(i).copied().unwrap_or(static_width_pt);
let adjust_pt = static_width_pt - target_pt;
adjustments.push((adjust_pt * 1000.0 / run.size_pt) as f32);
}
if adjustments.iter().all(|a| a.abs() < MIN_TJ_ADJUST_UNITS) {
content.show(Str(&cid_bytes(&run.glyphs, orig_to_new)));
return;
}
let mut positioned = content.show_positioned();
let mut items = positioned.items();
let mut segment: Vec<u8> = Vec::new();
for (i, &(gid, _)) in run.glyphs.iter().enumerate() {
let cid = orig_to_new.get(&gid).copied().unwrap_or(0);
segment.extend_from_slice(&cid.to_be_bytes());
if let Some(&adjust_units) = adjustments.get(i) {
if adjust_units.abs() >= MIN_TJ_ADJUST_UNITS {
items.show(Str(&segment));
segment.clear();
items.adjust(adjust_units);
}
}
}
if !segment.is_empty() {
items.show(Str(&segment));
}
}
fn write_info(pdf: &mut Pdf, info_id: Ref, meta: &PdfMeta) {
let mut info = pdf.document_info(info_id);
if let Some(title) = &meta.title {
info.title(TextStr(title));
}
if let Some(producer) = &meta.producer {
info.producer(TextStr(producer));
}
if let Some(date) = meta.creation_date {
info.creation_date(date.to_pdf_writer_date());
}
if let Some(author) = &meta.author {
info.author(TextStr(author));
}
if let Some(subject) = &meta.subject {
info.subject(TextStr(subject));
}
if let Some(keywords) = &meta.keywords {
info.keywords(TextStr(keywords));
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
}
fn write_xmp_packet(meta: &PdfMeta) -> Vec<u8> {
let mut body = String::new();
if let Some(title) = &meta.title {
body.push_str(&format!("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>", xml_escape(title)));
}
if let Some(author) = &meta.author {
body.push_str(&format!("<dc:creator><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></dc:creator>", xml_escape(author)));
}
if let Some(subject) = &meta.subject {
body.push_str(&format!(
"<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:description>",
xml_escape(subject)
));
}
if let Some(keywords) = &meta.keywords {
body.push_str(&format!("<pdf:Keywords>{}</pdf:Keywords>", xml_escape(keywords)));
}
if let Some(producer) = &meta.producer {
body.push_str(&format!("<pdf:Producer>{}</pdf:Producer>", xml_escape(producer)));
}
if let Some(date) = meta.creation_date {
body.push_str(&format!(
"<xmp:CreateDate>{:04}-{:02}-{:02}T{:02}:{:02}:{:02}</xmp:CreateDate>",
date.year, date.month, date.day, date.hour, date.minute, date.second
));
}
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" \
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\
{body}\
</rdf:Description>\
</rdf:RDF>\
</x:xmpmeta>\
<?xpacket end=\"w\"?>"
)
.into_bytes()
}
fn rgb_components(rgb: u32) -> (f32, f32, f32) {
let r = ((rgb >> 16) & 0xFF) as f32 / 255.0;
let g = ((rgb >> 8) & 0xFF) as f32 / 255.0;
let b = (rgb & 0xFF) as f32 / 255.0;
(r, g, b)
}
fn decode_opaque_rgb(png_bytes: &[u8], expected_px: (u32, u32)) -> Result<(Vec<u8>, u32, u32), ExportError> {
let mut decoder = png::Decoder::new(png_bytes);
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().map_err(|e| ExportError::RasterDecode(e.to_string()))?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).map_err(|e| ExportError::RasterDecode(e.to_string()))?;
let actual = (info.width, info.height);
if actual != expected_px {
return Err(ExportError::RasterDimensionMismatch { expected: expected_px, actual });
}
let rgb = to_opaque_rgb8(&buf[..info.buffer_size()], info.color_type, info.width, info.height);
Ok((rgb, info.width, info.height))
}
fn to_opaque_rgb8(buf: &[u8], color_type: png::ColorType, width: u32, height: u32) -> Vec<u8> {
let pixel_count = width as usize * height as usize;
let mut out = Vec::with_capacity(pixel_count * 3);
match color_type {
png::ColorType::Rgb => out.extend_from_slice(&buf[..pixel_count * 3]),
png::ColorType::Rgba => {
for px in buf.chunks_exact(4).take(pixel_count) {
out.extend_from_slice(&px[..3]);
}
}
png::ColorType::Grayscale => {
for &g in buf.iter().take(pixel_count) {
out.extend_from_slice(&[g, g, g]);
}
}
png::ColorType::GrayscaleAlpha => {
for px in buf.chunks_exact(2).take(pixel_count) {
out.extend_from_slice(&[px[0], px[0], px[0]]);
}
}
png::ColorType::Indexed => out.resize(pixel_count * 3, 0),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use uzor::render::{Painter, ShapeHelpers};
const ROBOTO_REGULAR: &[u8] = include_bytes!("../../../uzor-fonts/fonts/Roboto-Regular.ttf");
fn one_pixel_rgba_png(rgba: [u8; 4]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("write PNG header");
writer.write_image_data(&rgba).expect("write PNG pixel");
}
buf
}
fn solid_rgba_png(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("write PNG header");
let pixel_count = (width * height) as usize;
let mut data = Vec::with_capacity(pixel_count * 4);
for _ in 0..pixel_count {
data.extend_from_slice(&rgba);
}
writer.write_image_data(&data).expect("write PNG pixels");
}
buf
}
fn find_stream_with_key<'a>(doc: &'a lopdf::Document, key: &[u8]) -> Option<&'a lopdf::Stream> {
doc.objects.values().find_map(|obj| match obj {
lopdf::Object::Stream(stream) if stream.dict.has(key) => Some(stream),
_ => None,
})
}
#[test]
fn finished_pdf_starts_with_the_pdf_magic_bytes() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Hello PDF" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
assert!(bytes.starts_with(b"%PDF-"), "output must start with the PDF header");
}
#[test]
fn one_page_one_run_pdf_contains_a_page_object_and_a_type0_font_object() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Hello PDF" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Type /Page"), "must contain a page object");
assert!(text.contains("/Subtype /Type0"), "font must be a composite Type0 font");
assert!(text.contains("/CIDFontType2"), "the descendant font must be a CIDFontType2 (TrueType outlines)");
assert!(text.contains("/Encoding /Identity-H"), "the Type0 font must use Identity-H encoding");
assert!(!text.contains("WinAnsiEncoding"), "the old simple-font WinAnsi path must be fully gone");
}
#[test]
fn a_raster_page_contains_a_flate_compressed_image_xobject() {
let mut builder = PdfBuilder::new();
let png_bytes = one_pixel_rgba_png([255, 0, 0, 255]);
builder
.add_page(PdfPageSpec { width_pt: 50.0, height_pt: 50.0, raster: Some(&png_bytes), raster_px: (1, 1), text_runs: Vec::new(), links: Vec::new(), content: PdfContentStream::empty() })
.expect("add_page with a raster should succeed");
let bytes = builder.finish();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Subtype /Image"), "must contain an image XObject");
assert!(text.contains("/DeviceRGB"), "the raster must embed as DeviceRGB");
assert!(text.contains("/FlateDecode"), "the raster must be Flate-compressed");
}
#[test]
fn raster_dimension_mismatch_is_rejected_not_silently_trusted() {
let mut builder = PdfBuilder::new();
let png_bytes = one_pixel_rgba_png([0, 255, 0, 255]);
let err = builder
.add_page(PdfPageSpec { width_pt: 50.0, height_pt: 50.0, raster: Some(&png_bytes), raster_px: (2, 2), text_runs: Vec::new(), links: Vec::new(), content: PdfContentStream::empty() })
.expect_err("a wrong raster_px must be rejected");
assert!(matches!(err, ExportError::RasterDimensionMismatch { expected: (2, 2), actual: (1, 1) }));
}
#[test]
fn an_unmapped_character_falls_back_to_notdef_rather_than_a_question_mark() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "A\u{F8FF}B" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
assert!(bytes.starts_with(b"%PDF-"));
lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output even with a .notdef glyph present");
}
#[test]
fn cyrillic_text_round_trips_through_lopdf_extraction() {
let cyrillic = "Отчёт о переводах";
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 300.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 14.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: cyrillic }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
let page_numbers: Vec<u32> = pages.keys().copied().collect();
let extracted = doc.extract_text(&page_numbers).expect("lopdf text extraction must succeed");
assert_eq!(extracted.trim(), cyrillic, "Cyrillic text must round-trip verbatim via ToUnicode, got {extracted:?}");
let page_id = *pages.values().next().expect("one page");
let content = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content);
assert!(content_str.contains("Tj"), "content stream must contain a Tj text-showing operator, got {content_str:?}");
}
#[test]
fn a_run_with_no_real_advances_still_emits_a_plain_tj() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 24.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Kerning" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
let page_id = *pages.values().next().expect("one page");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert!(content_str.contains("Tj"), "no real advances -> plain Tj, got {content_str:?}");
assert!(!content_str.contains("TJ"), "no real advances must never produce a TJ array, got {content_str:?}");
}
#[test]
fn a_run_whose_real_advances_differ_from_static_widths_emits_a_tj_array_with_adjustments() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let text = "Kerning";
let size_pt = 24.0;
let fake_advances: Vec<f64> = vec![40.0; text.chars().count()];
builder
.add_page(PdfPageSpec {
width_pt: 400.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun {
font,
size_pt,
x_pt: 10.0,
y_pt: 20.0,
rgb: 0x111111,
glyph_advances_pt: Some(fake_advances),
struct_tag: None,
text,
}],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
let page_id = *pages.values().next().expect("one page");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert!(content_str.contains("TJ"), "wildly different real advances must produce a real TJ array, got {content_str:?}");
let page_numbers: Vec<u32> = pages.keys().copied().collect();
let extracted = doc.extract_text(&page_numbers).expect("lopdf text extraction must succeed");
assert!(extracted.contains(text), "TJ-positioned text must still extract verbatim via ToUnicode, got {extracted:?}");
}
#[test]
fn a_run_whose_real_advances_match_static_widths_still_emits_a_plain_tj() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let text = "Hi";
let size_pt = 12.0;
let metrics = ttf::TtfMetrics::parse(ROBOTO_REGULAR);
let matching_advances: Vec<f64> =
text.chars().map(|ch| metrics.advance_1000_for_gid(metrics.gid_for_char(ch).unwrap_or(0)) / 1000.0 * size_pt).collect();
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun {
font,
size_pt,
x_pt: 10.0,
y_pt: 20.0,
rgb: 0x111111,
glyph_advances_pt: Some(matching_advances),
struct_tag: None,
text,
}],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
let page_id = *pages.values().next().expect("one page");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert!(!content_str.contains("TJ"), "advances matching the static widths must never force a TJ array, got {content_str:?}");
}
#[test]
fn embedded_font_subset_is_smaller_than_the_full_ttf_and_still_extracts() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Hi" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let font_file = find_stream_with_key(&doc, b"Length1").expect("a subset FontFile2 stream must be present");
let subset_decompressed = font_file.decompressed_content().expect("FontFile2 must decompress cleanly");
assert!(
subset_decompressed.len() < ROBOTO_REGULAR.len(),
"a 2-glyph subset ({} bytes decompressed) must be far smaller than the full font ({} bytes)",
subset_decompressed.len(),
ROBOTO_REGULAR.len()
);
assert!(font_file.content.len() < ROBOTO_REGULAR.len());
let pages = doc.get_pages();
let page_numbers: Vec<u32> = pages.keys().copied().collect();
let extracted = doc.extract_text(&page_numbers).expect("lopdf text extraction must succeed");
assert!(extracted.contains("Hi"), "ToUnicode must recover the original text verbatim against the SUBSET font, got {extracted:?}");
}
#[test]
fn raster_image_xobject_is_flate_compressed_at_least_5x_smaller_than_raw_rgb() {
let (width, height) = (64u32, 64u32);
let png_bytes = solid_rgba_png(width, height, [30, 60, 90, 255]);
let mut builder = PdfBuilder::new();
builder
.add_page(PdfPageSpec { width_pt: width as f64, height_pt: height as f64, raster: Some(&png_bytes), raster_px: (width, height), text_runs: Vec::new(), links: Vec::new(), content: PdfContentStream::empty() })
.expect("add_page with a raster should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let image_stream = find_stream_with_key(&doc, b"Width").expect("the raster page must embed exactly one Image XObject stream");
let filter = image_stream.dict.get(b"Filter").and_then(|f| f.as_name()).expect("the image XObject must declare a /Filter");
assert_eq!(filter, b"FlateDecode");
let raw_rgb_len = width as usize * height as usize * 3;
let compressed_len = image_stream.content.len();
assert!(
raw_rgb_len as f64 / compressed_len as f64 >= 5.0,
"expected >=5x reduction on a flat-color raster (raw {raw_rgb_len} bytes vs compressed {compressed_len} bytes)"
);
}
#[test]
fn info_dict_is_absent_unless_meta_is_set_then_present_with_the_given_fields() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes_without_meta = builder.finish();
assert!(!String::from_utf8_lossy(&bytes_without_meta).contains("/Producer"), "no /Info dict must be written when set_meta was never called");
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.set_meta(PdfMeta {
title: Some("Case Report".to_owned()),
producer: Some("uzor-export".to_owned()),
creation_date: Some(PdfDate { year: 2026, month: 7, day: 17, hour: 12, minute: 0, second: 0 }),
..Default::default()
});
let bytes_with_meta = builder.finish();
let doc = lopdf::Document::load_mem(&bytes_with_meta).expect("lopdf must parse this crate's own PDF output");
let info_dict = doc.trailer.get(b"Info").ok().and_then(|obj| doc.get_dictionary(obj.as_reference().ok()?).ok()).expect("trailer must reference an /Info dict");
assert_eq!(info_dict.get(b"Title").ok().and_then(|o| o.as_str().ok()), Some(b"Case Report".as_slice()));
assert_eq!(info_dict.get(b"Producer").ok().and_then(|o| o.as_str().ok()), Some(b"uzor-export".as_slice()));
assert!(info_dict.has(b"CreationDate"));
}
#[test]
fn lopdf_parses_page_count_and_a_non_empty_fonts_dict_per_page() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
for i in 0..2 {
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Hello lopdf" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.unwrap_or_else(|e| panic!("add_page {i} should succeed: {e}"));
}
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
assert_eq!(pages.len(), 2, "lopdf must see exactly the 2 pages this builder added");
for (_, page_id) in &pages {
let fonts = doc.get_page_fonts(*page_id).expect("get_page_fonts must succeed");
assert!(!fonts.is_empty(), "every page's own font resource dict must be non-empty");
}
}
#[test]
fn lopdf_sees_the_image_xobject_in_a_raster_pages_own_resources() {
let mut builder = PdfBuilder::new();
let png_bytes = one_pixel_rgba_png([10, 20, 30, 255]);
builder
.add_page(PdfPageSpec { width_pt: 50.0, height_pt: 50.0, raster: Some(&png_bytes), raster_px: (1, 1), text_runs: Vec::new(), links: Vec::new(), content: PdfContentStream::empty() })
.expect("add_page with a raster should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
assert_eq!(pages.len(), 1);
let page_id = *pages.values().next().expect("one page");
let (resources, _) = doc.get_page_resources(page_id).expect("get_page_resources must succeed");
let resources = resources.expect("this page's Resources dict is written inline, not indirect");
let x_objects = resources.get(b"XObject").and_then(lopdf::Object::as_dict).expect("XObject dict must be present");
assert!(!x_objects.is_empty(), "the raster page's XObject dict must carry the embedded image");
}
fn decode_pdf_text_string(bytes: &[u8]) -> String {
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let units: Vec<u16> = bytes[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(bytes).into_owned()
}
}
#[test]
fn outline_tree_has_the_right_structure_and_a_cyrillic_title_round_trips() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
for _ in 0..2 {
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 300.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Page" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
}
builder.set_outline(vec![
PdfOutlineEntry { level: 1, title: "Introduction".to_owned(), page_index: 0 },
PdfOutlineEntry { level: 1, title: "Отчёт".to_owned(), page_index: 1 },
PdfOutlineEntry { level: 2, title: "Отчёт — подраздел".to_owned(), page_index: 1 },
]);
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let outlines_ref = catalog.get(b"Outlines").and_then(lopdf::Object::as_reference).expect("catalog must reference an /Outlines dict");
let outlines = doc.get_dictionary(outlines_ref).expect("must resolve the /Outlines dict");
assert_eq!(outlines.get(b"Type").and_then(|o| o.as_name()).expect("/Type must be present"), b"Outlines".as_slice());
let first_ref = outlines.get(b"First").and_then(lopdf::Object::as_reference).expect("/Outlines must have a /First top-level item");
let first_item = doc.get_dictionary(first_ref).expect("must resolve the first top-level item");
assert_eq!(decode_pdf_text_string(first_item.get(b"Title").and_then(|o| o.as_str()).expect("first item must have a /Title")), "Introduction");
assert!(first_item.get(b"First").is_err(), "the Introduction item has no children");
let second_ref = first_item.get(b"Next").and_then(lopdf::Object::as_reference).expect("the first item must link to a second top-level sibling via /Next");
let second_item = doc.get_dictionary(second_ref).expect("must resolve the second top-level item");
assert_eq!(
decode_pdf_text_string(second_item.get(b"Title").and_then(|o| o.as_str()).expect("second item must have a /Title")),
"Отчёт",
"a Cyrillic outline title must round-trip verbatim through /Title"
);
assert!(second_item.get(b"Next").is_err(), "there must be exactly 2 top-level items");
let child_ref = second_item.get(b"First").and_then(lopdf::Object::as_reference).expect("the second item must have a nested child");
let child_item = doc.get_dictionary(child_ref).expect("must resolve the nested child item");
assert_eq!(
decode_pdf_text_string(child_item.get(b"Title").and_then(|o| o.as_str()).expect("child item must have a /Title")),
"Отчёт — подраздел"
);
assert_eq!(child_item.get(b"Parent").and_then(lopdf::Object::as_reference).expect("child must reference its own /Parent"), second_ref);
let second_count = second_item.get(b"Count").and_then(lopdf::Object::as_i64).expect("an item with children must declare /Count");
assert!(second_count > 0, "a level-1 item with children must default OPEN (positive /Count), got {second_count}");
assert_eq!(outlines.get(b"Count").and_then(lopdf::Object::as_i64).expect("/Count must be present"), 3);
}
#[test]
fn link_annotation_is_present_with_a_goto_dest_to_the_correct_target_page() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 300.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "See page 2" }],
links: vec![PdfLink { x_pt: 10.0, y_pt: 20.0, width_pt: 80.0, height_pt: 16.0, target_page: 1 }],
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder
.add_page(PdfPageSpec { width_pt: 200.0, height_pt: 300.0, raster: None, raster_px: (0, 0), text_runs: Vec::new(), links: Vec::new(), content: PdfContentStream::empty() })
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let pages = doc.get_pages();
assert_eq!(pages.len(), 2);
let page1_id = *pages.get(&1).expect("page 1 must exist");
let page2_id = *pages.get(&2).expect("page 2 must exist");
let page1 = doc.get_dictionary(page1_id).expect("must resolve page 1");
let annots = page1.get(b"Annots").and_then(lopdf::Object::as_array).expect("page 1 must carry an /Annots array");
assert_eq!(annots.len(), 1, "page 1 must carry exactly the one link this test added");
let annot_ref = annots[0].as_reference().expect("annotation must be an indirect reference");
let annot = doc.get_dictionary(annot_ref).expect("must resolve the annotation dict");
assert_eq!(annot.get(b"Subtype").and_then(|o| o.as_name()).expect("/Subtype must be present"), b"Link".as_slice());
let action = annot.get(b"A").and_then(lopdf::Object::as_dict).expect("a Link annotation must carry an /A action dict");
assert_eq!(action.get(b"S").and_then(|o| o.as_name()).expect("/S must be present"), b"GoTo".as_slice());
let dest = action.get(b"D").and_then(lopdf::Object::as_array).expect("a GoTo action must carry a /D destination array");
let dest_page_ref = dest[0].as_reference().expect("the destination's first item must be the target page reference");
assert_eq!(dest_page_ref, page2_id, "the link's own GoTo destination must resolve to the TARGET page object (page 2), not page 1 or any other page");
}
#[test]
fn tagged_default_writes_mark_info_struct_tree_root_and_a_consistent_parent_tree() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let p_id = builder.register_struct_elem(PdfTagRole::P, None, None);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(p_id), text: "Tagged paragraph" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let mark_info = catalog.get(b"MarkInfo").and_then(lopdf::Object::as_dict).expect("catalog must carry a /MarkInfo dict by default (tagged defaults true)");
assert!(mark_info.get(b"Marked").and_then(lopdf::Object::as_bool).expect("/Marked must be a bool"), "/MarkInfo's own /Marked must be true");
let struct_tree_root_ref = catalog.get(b"StructTreeRoot").and_then(lopdf::Object::as_reference).expect("catalog must reference /StructTreeRoot");
let struct_tree_root = doc.get_dictionary(struct_tree_root_ref).expect("must resolve /StructTreeRoot");
assert_eq!(struct_tree_root.get(b"Type").and_then(lopdf::Object::as_name).expect("/Type must be present"), b"StructTreeRoot".as_slice());
let document_ref = struct_tree_root.get(b"K").and_then(lopdf::Object::as_reference).expect("/StructTreeRoot must reference its own Document child");
let document_elem = doc.get_dictionary(document_ref).expect("must resolve the Document struct element");
assert_eq!(document_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"Document".as_slice());
let document_children = document_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Document must reference its own children array");
assert_eq!(document_children.len(), 1, "the one registered P element is the Document's own only child");
let page_id = *doc.get_pages().values().next().expect("one page");
let page_dict = doc.get_dictionary(page_id).expect("must resolve the page dict");
let struct_parents = page_dict.get(b"StructParents").and_then(lopdf::Object::as_i64).expect("a page with tagged content must carry /StructParents");
assert_eq!(struct_parents, 0);
let parent_tree = struct_tree_root.get(b"ParentTree").and_then(lopdf::Object::as_dict).expect("/StructTreeRoot must carry a /ParentTree dict");
let nums = parent_tree.get(b"Nums").and_then(lopdf::Object::as_array).expect("/ParentTree must carry /Nums");
assert_eq!(nums.len(), 2, "one (key, value) pair for the one tagged page");
assert_eq!(nums[0].as_i64().expect("Nums key must be an integer"), 0, "the Nums key must equal this page's own /StructParents value");
let mcid_array_ref = nums[1].as_reference().expect("the Nums value must be an indirect array reference");
let mcid_array = doc.get_object(mcid_array_ref).and_then(lopdf::Object::as_array).expect("must resolve the per-page MCID array");
assert_eq!(mcid_array.len(), 1, "exactly one tagged run on this page -> exactly one mcid slot");
let p_elem_ref = mcid_array[0].as_reference().expect("mcid 0 must reference a real struct element");
assert_eq!(p_elem_ref, document_children[0].as_reference().expect("Document's own child ref"), "the ParentTree's own mcid-0 element must be the SAME element the Document tree references");
let p_elem = doc.get_dictionary(p_elem_ref).expect("must resolve the P struct element");
assert_eq!(p_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"P".as_slice());
assert!(p_elem.get(b"Pg").is_ok(), "a leaf struct element must reference its own page via /Pg");
assert_eq!(p_elem.get(b"K").and_then(lopdf::Object::as_i64).expect("/K must be an integer MCID"), 0, "the leaf's own /K must be the bare MCID integer (not a child array)");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert_eq!(content_str.matches("BDC").count(), 1, "exactly one tagged run must open exactly one marked-content sequence");
assert_eq!(content_str.matches("EMC").count(), 1, "every BDC must be closed by its own EMC");
}
#[test]
fn tagged_false_emits_no_tagging_markers_and_is_self_deterministic() {
fn build() -> Vec<u8> {
let mut builder = PdfBuilder::new();
builder.set_tagged(false);
let font = builder.register_font(ROBOTO_REGULAR);
let p_id = builder.register_struct_elem(PdfTagRole::P, None, None);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(p_id), text: "Hello" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.finish()
}
let bytes_a = build();
let bytes_b = build();
assert_eq!(bytes_a, bytes_b, "tagged:false output must be deterministic across two identical builds");
let text = String::from_utf8_lossy(&bytes_a);
assert!(!text.contains("/MarkInfo"), "tagged:false must never write /MarkInfo");
assert!(!text.contains("/StructTreeRoot"), "tagged:false must never write /StructTreeRoot");
assert!(!text.contains("/StructParents"), "tagged:false must never write /StructParents");
assert!(!text.contains("BDC"), "tagged:false must never emit a BDC marked-content operator");
assert!(!text.contains("EMC"), "tagged:false must never emit an EMC marked-content operator");
}
#[test]
fn tagged_true_output_differs_from_tagged_false_only_by_the_tagging_additions() {
fn build(tagged: bool) -> Vec<u8> {
let mut builder = PdfBuilder::new();
builder.set_tagged(tagged);
let font = builder.register_font(ROBOTO_REGULAR);
let p_id = builder.register_struct_elem(PdfTagRole::P, None, None);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(p_id), text: "Parity" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.finish()
}
let untagged = build(false);
let tagged = build(true);
assert_ne!(untagged, tagged, "tagging must add real bytes, not be a no-op");
let untagged_text = String::from_utf8_lossy(&untagged);
let tagged_text = String::from_utf8_lossy(&tagged);
assert!(!untagged_text.contains("/MarkInfo") && !untagged_text.contains("/StructTreeRoot"), "the untagged build must carry none of the additions");
assert!(tagged_text.contains("/MarkInfo") && tagged_text.contains("/StructTreeRoot"), "the tagged build must carry every expected addition");
let doc_untagged = lopdf::Document::load_mem(&untagged).expect("lopdf must parse the untagged PDF");
let doc_tagged = lopdf::Document::load_mem(&tagged).expect("lopdf must parse the tagged PDF");
assert_eq!(doc_untagged.get_pages().len(), doc_tagged.get_pages().len(), "tagging must never change the page count");
let untagged_page_id = *doc_untagged.get_pages().values().next().expect("one page");
let tagged_page_id = *doc_tagged.get_pages().values().next().expect("one page");
let untagged_content = String::from_utf8_lossy(&doc_untagged.get_page_content(untagged_page_id)).into_owned();
let tagged_content = String::from_utf8_lossy(&doc_tagged.get_page_content(tagged_page_id)).into_owned();
assert!(!untagged_content.contains("BDC"), "the untagged content stream must never carry a BDC operator");
assert!(tagged_content.contains("BDC"), "the tagged content stream must carry a real BDC operator");
let pages_untagged: Vec<u32> = doc_untagged.get_pages().keys().copied().collect();
let pages_tagged: Vec<u32> = doc_tagged.get_pages().keys().copied().collect();
assert_eq!(
doc_untagged.extract_text(&pages_untagged).expect("extract untagged"),
doc_tagged.extract_text(&pages_tagged).expect("extract tagged"),
"tagging must never change the extracted text"
);
}
#[test]
fn mixed_tagged_and_untagged_runs_produce_balanced_bdc_emc_pairs() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let h1_id = builder.register_struct_elem(PdfTagRole::H1, None, None);
let p_id = builder.register_struct_elem(PdfTagRole::P, None, None);
builder
.add_page(PdfPageSpec {
width_pt: 300.0,
height_pt: 200.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![
PdfTextRun { font, size_pt: 18.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(h1_id), text: "Heading" },
PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 40.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "Untagged chrome" },
PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 60.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(p_id), text: "Body" },
],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let page_id = *doc.get_pages().values().next().expect("one page");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert_eq!(content_str.matches("BDC").count(), 2, "2 tagged runs -> 2 BDC opens; the untagged run in between opens none");
assert_eq!(content_str.matches("EMC").count(), 2, "BDC/EMC must be balanced");
let mut positions: Vec<(usize, bool)> = content_str.match_indices("BDC").map(|(i, _)| (i, true)).collect();
positions.extend(content_str.match_indices("EMC").map(|(i, _)| (i, false)));
positions.sort_by_key(|&(i, _)| i);
let mut depth = 0i32;
for (_, is_begin) in positions {
if is_begin {
depth += 1;
assert!(depth <= 1, "a new BDC must never open while a previous one is still open (never nested/overlapping)");
} else {
depth -= 1;
assert!(depth >= 0, "an EMC must never appear without a matching open BDC");
}
}
assert_eq!(depth, 0, "every BDC must be closed by its own EMC by the end of the content stream");
}
#[test]
fn figure_struct_elem_alt_text_round_trips_and_wraps_its_own_ops() {
let mut builder = PdfBuilder::new();
let mut fonts = PdfFontCache::new();
let fig_id = builder.register_struct_elem(PdfTagRole::Figure, None, Some("A bar chart of observed amounts".to_owned()));
let content = {
let mut ctx = PdfRenderContext::new(1.0, &mut builder, &mut fonts);
ctx.begin_tag(fig_id);
ctx.set_fill_color("#3366ff");
ctx.fill_rect(10.0, 10.0, 50.0, 20.0);
ctx.end_tag();
ctx.finish()
};
builder
.add_page(PdfPageSpec { width_pt: 200.0, height_pt: 100.0, raster: None, raster_px: (0, 0), text_runs: Vec::new(), links: Vec::new(), content })
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let page_id = *doc.get_pages().values().next().expect("one page");
let content_bytes = doc.get_page_content(page_id);
let content_str = String::from_utf8_lossy(&content_bytes);
assert_eq!(content_str.matches("BDC").count(), 1);
assert_eq!(content_str.matches("EMC").count(), 1);
assert!(content_str.contains("/Figure"), "the BDC operator's own tag name must be /Figure, got: {content_str}");
let catalog = doc.catalog().expect("catalog must be present");
let struct_tree_root_ref = catalog.get(b"StructTreeRoot").and_then(lopdf::Object::as_reference).expect("catalog must reference /StructTreeRoot");
let struct_tree_root = doc.get_dictionary(struct_tree_root_ref).expect("must resolve /StructTreeRoot");
let document_ref = struct_tree_root.get(b"K").and_then(lopdf::Object::as_reference).expect("Document child ref");
let document_elem = doc.get_dictionary(document_ref).expect("must resolve Document elem");
let children = document_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Document must have children");
assert_eq!(children.len(), 1);
let fig_ref = children[0].as_reference().expect("child must be a reference");
let fig_elem = doc.get_dictionary(fig_ref).expect("must resolve the Figure struct element");
assert_eq!(fig_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"Figure".as_slice());
let alt_bytes = fig_elem.get(b"Alt").and_then(lopdf::Object::as_str).expect("the Figure element must carry /Alt");
assert_eq!(decode_pdf_text_string(alt_bytes), "A bar chart of observed amounts");
}
#[test]
fn a_figure_with_no_alt_writes_no_alt_key_at_all() {
let mut builder = PdfBuilder::new();
let mut fonts = PdfFontCache::new();
let fig_id = builder.register_struct_elem(PdfTagRole::Figure, None, None);
let content = {
let mut ctx = PdfRenderContext::new(1.0, &mut builder, &mut fonts);
ctx.begin_tag(fig_id);
ctx.set_fill_color("#3366ff");
ctx.fill_rect(10.0, 10.0, 50.0, 20.0);
ctx.end_tag();
ctx.finish()
};
builder
.add_page(PdfPageSpec { width_pt: 200.0, height_pt: 100.0, raster: None, raster_px: (0, 0), text_runs: Vec::new(), links: Vec::new(), content })
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let struct_tree_root_ref = catalog.get(b"StructTreeRoot").and_then(lopdf::Object::as_reference).expect("StructTreeRoot ref");
let struct_tree_root = doc.get_dictionary(struct_tree_root_ref).expect("resolve StructTreeRoot");
let document_ref = struct_tree_root.get(b"K").and_then(lopdf::Object::as_reference).expect("Document ref");
let document_elem = doc.get_dictionary(document_ref).expect("resolve Document elem");
let children = document_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Document children");
let fig_ref = children[0].as_reference().expect("child ref");
let fig_elem = doc.get_dictionary(fig_ref).expect("resolve Figure elem");
assert!(fig_elem.get(b"Alt").is_err(), "no /Alt was registered, so none must be written");
}
#[test]
fn table_row_cell_hierarchy_is_well_formed_container_then_leaf() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let table_id = builder.register_struct_elem(PdfTagRole::Table, None, None);
let tr_id = builder.register_struct_elem(PdfTagRole::TableRow, Some(table_id), None);
let td_id = builder.register_struct_elem(PdfTagRole::TableCell, Some(tr_id), None);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(td_id), text: "Cell text" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let struct_tree_root_ref = catalog.get(b"StructTreeRoot").and_then(lopdf::Object::as_reference).expect("StructTreeRoot ref");
let struct_tree_root = doc.get_dictionary(struct_tree_root_ref).expect("resolve StructTreeRoot");
let document_ref = struct_tree_root.get(b"K").and_then(lopdf::Object::as_reference).expect("Document ref");
let document_elem = doc.get_dictionary(document_ref).expect("resolve Document elem");
let doc_children = document_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Document children");
assert_eq!(doc_children.len(), 1, "only the Table is a top-level child — TR/TD nest inside it, never flattened to the Document root");
let table_ref = doc_children[0].as_reference().expect("table ref");
let table_elem = doc.get_dictionary(table_ref).expect("resolve table elem");
assert_eq!(table_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"Table".as_slice());
assert!(table_elem.get(b"Pg").is_err(), "a container element must never carry its own /Pg");
let table_children = table_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Table children");
assert_eq!(table_children.len(), 1);
let tr_ref = table_children[0].as_reference().expect("tr ref");
let tr_elem = doc.get_dictionary(tr_ref).expect("resolve tr elem");
assert_eq!(tr_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"TR".as_slice());
assert!(tr_elem.get(b"Pg").is_err(), "TR is also a container — no /Pg of its own");
let tr_children = tr_elem.get(b"K").and_then(lopdf::Object::as_array).expect("TR children");
assert_eq!(tr_children.len(), 1);
let td_ref = tr_children[0].as_reference().expect("td ref");
let td_elem = doc.get_dictionary(td_ref).expect("resolve td elem");
assert_eq!(td_elem.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"), b"TD".as_slice());
assert!(td_elem.get(b"Pg").is_ok(), "the leaf TD must reference its own page");
assert_eq!(td_elem.get(b"K").and_then(lopdf::Object::as_i64).expect("/K must be an integer MCID"), 0, "the leaf's own /K must be the bare MCID integer");
}
#[test]
fn an_unused_struct_elem_tree_is_pruned_entirely_never_left_dangling() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
let table_id = builder.register_struct_elem(PdfTagRole::Table, None, None);
let tr_id = builder.register_struct_elem(PdfTagRole::TableRow, Some(table_id), None);
let _td_id = builder.register_struct_elem(PdfTagRole::TableCell, Some(tr_id), None);
let p_id = builder.register_struct_elem(PdfTagRole::P, None, None);
builder
.add_page(PdfPageSpec {
width_pt: 200.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: Some(p_id), text: "Real content" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let struct_tree_root_ref = catalog.get(b"StructTreeRoot").and_then(lopdf::Object::as_reference).expect("StructTreeRoot ref");
let struct_tree_root = doc.get_dictionary(struct_tree_root_ref).expect("resolve StructTreeRoot");
let document_ref = struct_tree_root.get(b"K").and_then(lopdf::Object::as_reference).expect("Document ref");
let document_elem = doc.get_dictionary(document_ref).expect("resolve Document elem");
let doc_children = document_elem.get(b"K").and_then(lopdf::Object::as_array).expect("Document children");
assert_eq!(doc_children.len(), 1, "the unused Table/TR/TD subtree must be pruned entirely — only the real P element remains");
let only_child_ref = doc_children[0].as_reference().expect("child ref");
let only_child = doc.get_dictionary(only_child_ref).expect("resolve the surviving child");
assert_eq!(
only_child.get(b"S").and_then(lopdf::Object::as_name).expect("/S must be present"),
b"P".as_slice(),
"the ONE surviving child must be the real, tagged P element"
);
}
#[test]
fn xmp_packet_is_written_when_opted_in_and_escapes_xml_entities() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.set_meta(PdfMeta { title: Some("Report <A & B>".to_owned()), write_xmp: true, ..Default::default() });
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let metadata_ref = catalog.get(b"Metadata").and_then(lopdf::Object::as_reference).expect("catalog must reference /Metadata when write_xmp is set");
let metadata_obj = doc.get_object(metadata_ref).expect("must resolve /Metadata");
let stream = match metadata_obj {
lopdf::Object::Stream(s) => s,
other => panic!("/Metadata must be a stream object, got {other:?}"),
};
assert_eq!(stream.dict.get(b"Subtype").and_then(lopdf::Object::as_name).expect("/Subtype must be present"), b"XML".as_slice());
let xmp_text = String::from_utf8_lossy(&stream.content).into_owned();
assert!(xmp_text.contains("Report <A & B>"), "the title must be XML-escaped in the XMP packet, got: {xmp_text}");
assert!(!xmp_text.contains("Report <A & B>"), "the RAW, unescaped title must never appear literally in the XMP packet");
}
#[test]
fn no_xmp_metadata_is_written_unless_write_xmp_is_set() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.set_meta(PdfMeta { title: Some("Untitled".to_owned()), ..Default::default() });
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
assert!(catalog.get(b"Metadata").is_err(), "no /Metadata entry must be written when write_xmp was never set");
}
#[test]
fn lang_is_written_on_the_catalog_when_set() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
builder.set_meta(PdfMeta { lang: Some("ru".to_owned()), ..Default::default() });
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
let lang_bytes = catalog.get(b"Lang").and_then(lopdf::Object::as_str).expect("catalog must carry /Lang when PdfMeta::lang is set");
assert_eq!(decode_pdf_text_string(lang_bytes), "ru");
}
#[test]
fn no_lang_is_written_unless_set() {
let mut builder = PdfBuilder::new();
let font = builder.register_font(ROBOTO_REGULAR);
builder
.add_page(PdfPageSpec {
width_pt: 100.0,
height_pt: 100.0,
raster: None,
raster_px: (0, 0),
text_runs: vec![PdfTextRun { font, size_pt: 12.0, x_pt: 10.0, y_pt: 20.0, rgb: 0x111111, glyph_advances_pt: None, struct_tag: None, text: "x" }],
links: Vec::new(),
content: PdfContentStream::empty(),
})
.expect("add_page should succeed");
let bytes = builder.finish();
let doc = lopdf::Document::load_mem(&bytes).expect("lopdf must parse this crate's own PDF output");
let catalog = doc.catalog().expect("catalog must be present");
assert!(catalog.get(b"Lang").is_err(), "no /Lang must be written unless PdfMeta::lang is set");
}
}