use crate::writer::{fmt_num, PdfWriter, Ref};
const PRODUCER: &str = concat!("lightweight-pdf ", env!("CARGO_PKG_VERSION"));
pub struct CidFont {
pub base_font: String,
pub subset_bytes: Vec<u8>,
pub widths: Vec<f32>,
pub ascent: f32,
pub descent: f32,
pub cap_height: f32,
pub italic_angle: f32,
pub bbox: (f32, f32, f32, f32),
pub is_italic: bool,
pub is_bold: bool,
pub to_unicode: Vec<(u16, char)>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ColorSpace {
DeviceGray,
DeviceRgb,
}
impl ColorSpace {
fn as_pdf_name(self) -> &'static str {
match self {
ColorSpace::DeviceGray => "DeviceGray",
ColorSpace::DeviceRgb => "DeviceRGB",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ImageDataFilter {
None,
DctDecode,
}
pub struct ImageXObject {
pub width_px: u32,
pub height_px: u32,
pub color_space: ColorSpace,
pub bits_per_component: u8,
pub filter: ImageDataFilter,
pub bytes: Vec<u8>,
pub smask: Option<Box<ImageXObject>>,
}
#[derive(Clone, Debug)]
pub enum PdfLinkAction {
Uri(String),
GoTo { page_index: usize, y: f32 },
}
#[derive(Clone, Debug)]
pub struct PdfLinkAnnotation {
pub rect: (f32, f32, f32, f32),
pub action: PdfLinkAction,
}
#[derive(Default)]
pub struct PdfPage {
pub width: f32,
pub height: f32,
pub content: Vec<u8>,
pub annotations: Vec<PdfLinkAnnotation>,
}
#[derive(Clone, Debug)]
pub struct PdfOutlineNode {
pub title: String,
pub page_index: usize,
pub y: f32,
pub children: Vec<PdfOutlineNode>,
}
#[derive(Clone, Debug, Default)]
pub struct PdfMetadata {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub creation_date: Option<String>,
pub mod_date: Option<String>,
#[cfg(feature = "pdf-a")]
pub xmp_creation_date: Option<String>,
#[cfg(feature = "pdf-a")]
pub xmp_mod_date: Option<String>,
}
#[derive(Default)]
pub struct PdfDocument {
fonts: Vec<CidFont>,
images: Vec<ImageXObject>,
pages: Vec<PdfPage>,
pub metadata: PdfMetadata,
pub outline: Vec<PdfOutlineNode>,
#[cfg(feature = "pdf-a")]
pub pdf_a3b: bool,
#[cfg(feature = "zugferd")]
pub zugferd_xml: Option<Vec<u8>>,
pub lang: Option<String>,
#[cfg(feature = "tagged-pdf")]
pub pdf_ua: bool,
#[cfg(feature = "tagged-pdf")]
pub struct_tree: Option<crate::struct_tree::PdfStructNode>,
}
impl PdfDocument {
pub fn new() -> Self {
Self::default()
}
pub fn add_font(&mut self, font: CidFont) -> usize {
self.fonts.push(font);
self.fonts.len() - 1
}
pub fn font_resource_name(index: usize) -> String {
format!("F{}", index + 1)
}
pub fn add_image(&mut self, image: ImageXObject) -> usize {
self.images.push(image);
self.images.len() - 1
}
pub fn image_resource_name(index: usize) -> String {
format!("Im{}", index + 1)
}
pub fn add_page(&mut self, page: PdfPage) {
self.pages.push(page);
}
#[cfg(feature = "pdf-a")]
fn is_pdf_a3b(&self) -> bool {
self.pdf_a3b
}
#[cfg(not(feature = "pdf-a"))]
fn is_pdf_a3b(&self) -> bool {
false
}
fn descriptor_flags(font: &CidFont) -> u32 {
let mut flags = 32u32;
if font.is_italic {
flags |= 64;
}
flags
}
fn to_unicode_cmap(font: &CidFont) -> Vec<u8> {
let mut body = String::new();
body.push_str("/CIDInit /ProcSet findresource begin\n");
body.push_str("12 dict begin\n");
body.push_str("begincmap\n");
body.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
body.push_str("/CMapName /Adobe-Identity-UCS def\n");
body.push_str("/CMapType 2 def\n");
body.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
for chunk in font.to_unicode.chunks(100) {
body.push_str(&format!("{} beginbfchar\n", chunk.len()));
for &(cid, ch) in chunk {
let utf16: Vec<u16> = ch.encode_utf16(&mut [0u16; 2]).to_vec();
let hex: String = utf16.iter().map(|u| format!("{u:04X}")).collect();
body.push_str(&format!("<{cid:04X}> <{hex}>\n"));
}
body.push_str("endbfchar\n");
}
body.push_str("endcmap\n");
body.push_str("CMapType findresource /CMap defineresource pop\n");
body.push_str("end\n");
body.push_str("end");
body.into_bytes()
}
#[cfg(feature = "pdf-a")]
const SRGB_ICC_PROFILE: &[u8] = include_bytes!("../assets/sRGB2014.icc");
#[cfg(feature = "pdf-a")]
fn write_output_intent(w: &mut PdfWriter) -> Ref {
let profile_ref = w.alloc();
w.compressed_stream(profile_ref, "/N 3", Self::SRGB_ICC_PROFILE);
let intent_ref = w.alloc();
w.object(
intent_ref,
&format!(
"<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB IEC61966-2.1) /Info (sRGB IEC61966-2.1) /DestOutputProfile {} >>",
profile_ref.write()
),
);
intent_ref
}
#[cfg(feature = "pdf-a")]
fn write_xmp_metadata(w: &mut PdfWriter, metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> Ref {
let xmp = build_xmp_packet(metadata, zugferd, pdf_ua);
let id = w.alloc();
w.stream(id, "/Type /Metadata /Subtype /XML", xmp.as_bytes());
id
}
#[cfg(feature = "zugferd")]
fn is_zugferd(&self) -> bool {
self.zugferd_xml.is_some()
}
#[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
fn is_zugferd(&self) -> bool {
false
}
#[cfg(feature = "tagged-pdf")]
fn is_pdf_ua(&self) -> bool {
self.pdf_ua
}
#[cfg(not(feature = "tagged-pdf"))]
fn is_pdf_ua(&self) -> bool {
false
}
#[cfg(feature = "zugferd")]
fn write_zugferd_attachment(w: &mut PdfWriter, xml: &[u8]) -> Ref {
const FILENAME: &str = "factur-x.xml";
let file_ref = w.alloc();
w.compressed_stream(file_ref, "/Type /EmbeddedFile /Subtype /text#2Fxml", xml);
let filespec_ref = w.alloc();
let name = format_pdf_string(FILENAME);
w.object(
filespec_ref,
&format!(
"<< /Type /Filespec /F {name} /UF {name} /AFRelationship /Alternative /EF << /F {file} /UF {file} >> >>",
file = file_ref.write(),
),
);
filespec_ref
}
#[cfg(feature = "zugferd")]
fn write_zugferd_catalog_entry(&self, w: &mut PdfWriter) -> String {
match self.zugferd_xml.as_deref() {
Some(xml) => {
let filespec_ref = Self::write_zugferd_attachment(w, xml);
format!(
" /AF [{fs}] /Names << /EmbeddedFiles << /Names [(factur-x.xml) {fs}] >> >>",
fs = filespec_ref.write()
)
}
None => String::new(),
}
}
#[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
fn write_zugferd_catalog_entry(&self, _w: &mut PdfWriter) -> String {
String::new()
}
fn write_image(w: &mut PdfWriter, image: &ImageXObject) -> Ref {
let smask_ref = image.smask.as_deref().map(|m| Self::write_image(w, m));
let image_ref = w.alloc();
let filter = match image.filter {
ImageDataFilter::None => String::new(),
ImageDataFilter::DctDecode => " /Filter /DCTDecode".to_string(),
};
let smask_entry = match smask_ref {
Some(r) => format!(" /SMask {}", r.write()),
None => String::new(),
};
let dict = format!(
"/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /{cs} /BitsPerComponent {bpc}{filter}{smask}",
w = image.width_px,
h = image.height_px,
cs = image.color_space.as_pdf_name(),
bpc = image.bits_per_component,
filter = filter,
smask = smask_entry,
);
match image.filter {
ImageDataFilter::None => w.compressed_stream(image_ref, &dict, &image.bytes),
ImageDataFilter::DctDecode => w.stream(image_ref, &dict, &image.bytes),
}
image_ref
}
fn join_with_space<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
items.iter().map(f).collect::<Vec<_>>().join(" ")
}
fn resource_entries(refs: &[Ref], name_fn: impl Fn(usize) -> String) -> String {
refs.iter()
.enumerate()
.map(|(i, r)| format!("/{} {}", name_fn(i), r.write()))
.collect::<Vec<_>>()
.join(" ")
}
fn write_fonts(w: &mut PdfWriter, fonts: &[CidFont]) -> String {
let font_refs: Vec<(Ref, Ref, Ref, Ref)> = fonts.iter().map(|_| (w.alloc(), w.alloc(), w.alloc(), w.alloc())).collect();
for (font, &(type0_ref, cid_ref, descriptor_ref, file_ref)) in fonts.iter().zip(&font_refs) {
let to_unicode_ref = w.alloc();
let widths_str = Self::join_with_space(&font.widths, |w| fmt_num(*w));
w.object(
type0_ref,
&format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{base} /Encoding /Identity-H /DescendantFonts [{cid}] /ToUnicode {tu} >>",
base = font.base_font,
cid = cid_ref.write(),
tu = to_unicode_ref.write(),
),
);
w.object(
cid_ref,
&format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{base} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} /DW 1000 /W [0 [{widths}]] /CIDToGIDMap /Identity >>",
base = font.base_font,
desc = descriptor_ref.write(),
widths = widths_str,
),
);
w.object(
descriptor_ref,
&format!(
"<< /Type /FontDescriptor /FontName /{base} /Flags {flags} /FontBBox [{bx0} {by0} {bx1} {by1}] /ItalicAngle {italic} /Ascent {ascent} /Descent {descent} /CapHeight {cap} /StemV {stemv} /FontFile2 {file} >>",
base = font.base_font,
flags = Self::descriptor_flags(font),
bx0 = fmt_num(font.bbox.0),
by0 = fmt_num(font.bbox.1),
bx1 = fmt_num(font.bbox.2),
by1 = fmt_num(font.bbox.3),
italic = fmt_num(font.italic_angle),
ascent = fmt_num(font.ascent),
descent = fmt_num(font.descent),
cap = fmt_num(font.cap_height),
stemv = if font.is_bold { 120 } else { 80 },
file = file_ref.write(),
),
);
w.compressed_stream(file_ref, &format!("/Length1 {}", font.subset_bytes.len()), &font.subset_bytes);
w.compressed_stream(to_unicode_ref, "", &Self::to_unicode_cmap(font));
}
let type0_refs: Vec<Ref> = font_refs.iter().map(|&(t, ..)| t).collect();
Self::resource_entries(&type0_refs, Self::font_resource_name)
}
fn write_pages(
w: &mut PdfWriter,
pages: &[PdfPage],
pages_ref: Ref,
font_resources: &str,
image_resources: &str,
pdf_a3b: bool,
pdf_ua: bool,
) -> Vec<Ref> {
let page_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
let content_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
for (page_index, ((page, &page_ref), &content_ref)) in pages.iter().zip(&page_refs).zip(&content_refs).enumerate() {
let mut annot_refs = Vec::new();
for annot in &page.annotations {
let id = w.alloc();
let action = match &annot.action {
PdfLinkAction::Uri(uri) => format!("/A << /S /URI /URI {} >>", format_pdf_string(uri)),
PdfLinkAction::GoTo { page_index, y } => {
let target = page_refs.get(*page_index).copied().unwrap_or(page_ref);
format!("/Dest [{} /XYZ null {} null]", target.write(), fmt_num(*y))
}
};
let flags_entry = if pdf_a3b { " /F 4" } else { "" };
w.object(
id,
&format!(
"<< /Type /Annot /Subtype /Link /Rect [{x0} {y0} {x1} {y1}] /Border [0 0 0]{flags} {action} >>",
x0 = fmt_num(annot.rect.0),
y0 = fmt_num(annot.rect.1),
x1 = fmt_num(annot.rect.2),
y1 = fmt_num(annot.rect.3),
flags = flags_entry,
),
);
annot_refs.push(id);
}
let annots_entry = if !annot_refs.is_empty() {
let refs = Self::join_with_space(&annot_refs, |r| r.write());
format!(" /Annots [{refs}]")
} else {
String::new()
};
let group_entry = if pdf_a3b {
" /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>"
} else {
""
};
let struct_parents_entry = if pdf_ua {
format!(" /StructParents {page_index}")
} else {
String::new()
};
w.object(
page_ref,
&format!(
"<< /Type /Page /Parent {parent} /MediaBox [0 0 {w} {h}] /Resources << /Font << {fonts} >> /XObject << {images} >> >>{group}{struct_parents} /Contents {content}{annots} >>",
parent = pages_ref.write(),
w = fmt_num(page.width),
h = fmt_num(page.height),
fonts = font_resources,
images = image_resources,
group = group_entry,
struct_parents = struct_parents_entry,
content = content_ref.write(),
annots = annots_entry,
),
);
w.compressed_stream(content_ref, "", &page.content);
}
page_refs
}
fn write_outline(w: &mut PdfWriter, outline: &[PdfOutlineNode], page_refs: &[Ref]) -> Option<Ref> {
if outline.is_empty() {
return None;
}
let outlines_ref = w.alloc();
let ref_tree = alloc_outline_refs(w, outline);
write_outline_siblings(w, outline, &ref_tree, outlines_ref, page_refs);
let total_count: i64 = outline.iter().map(|n| 1 + count_descendants(n)).sum();
let first = ref_tree.first().map(|t| t.r);
let last = ref_tree.last().map(|t| t.r);
let mut entries = vec!["/Type /Outlines".to_string(), format!("/Count {total_count}")];
if let Some(f) = first {
entries.push(format!("/First {}", f.write()));
}
if let Some(l) = last {
entries.push(format!("/Last {}", l.write()));
}
w.object(outlines_ref, &format!("<< {} >>", entries.join(" ")));
Some(outlines_ref)
}
pub fn write(&self) -> Vec<u8> {
let mut w = PdfWriter::new();
let catalog_ref = w.alloc();
let pages_ref = w.alloc();
let image_refs: Vec<Ref> = self.images.iter().map(|img| Self::write_image(&mut w, img)).collect();
let image_resources = Self::resource_entries(&image_refs, Self::image_resource_name);
let pdf_a3b = self.is_pdf_a3b();
let pdf_ua = self.is_pdf_ua();
let font_resources = Self::write_fonts(&mut w, &self.fonts);
let page_refs = Self::write_pages(&mut w, &self.pages, pages_ref, &font_resources, &image_resources, pdf_a3b, pdf_ua);
let kids = Self::join_with_space(&page_refs, |r| r.write());
w.object(pages_ref, &format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", self.pages.len()));
let outlines_entry = match Self::write_outline(&mut w, &self.outline, &page_refs) {
Some(outlines_ref) => format!(" /Outlines {}", outlines_ref.write()),
None => String::new(),
};
#[cfg(feature = "pdf-a")]
let pdf_a_entry = if pdf_a3b {
let output_intent_ref = Self::write_output_intent(&mut w);
let metadata_ref = Self::write_xmp_metadata(&mut w, &self.metadata, self.is_zugferd(), pdf_ua);
let zugferd_entry = self.write_zugferd_catalog_entry(&mut w);
format!(
" /OutputIntents [{}] /Metadata {}{zugferd_entry}",
output_intent_ref.write(),
metadata_ref.write()
)
} else {
String::new()
};
#[cfg(not(feature = "pdf-a"))]
let pdf_a_entry = String::new();
#[cfg(feature = "tagged-pdf")]
let tagged_entry = if pdf_ua {
use crate::struct_tree::{write_struct_tree, PdfStructNode};
let empty_root = PdfStructNode::Elem {
tag: "Document",
alt: None,
attrs: None,
children: Vec::new(),
};
let root = self.struct_tree.as_ref().unwrap_or(&empty_root);
let (struct_tree_root_ref, _struct_parents) = write_struct_tree(&mut w, root, &page_refs);
format!(
" /StructTreeRoot {} /MarkInfo << /Marked true >> /ViewerPreferences << /DisplayDocTitle true >>",
struct_tree_root_ref.write()
)
} else {
String::new()
};
#[cfg(not(feature = "tagged-pdf"))]
let tagged_entry = String::new();
let lang_entry = match &self.lang {
Some(lang) => format!(" /Lang {}", format_pdf_string(lang)),
None => String::new(),
};
w.object(
catalog_ref,
&format!(
"<< /Type /Catalog /Pages {}{outlines_entry}{pdf_a_entry}{tagged_entry}{lang_entry} >>",
pages_ref.write()
),
);
let mut info_entries = Vec::new();
if let Some(ref title) = self.metadata.title {
info_entries.push(format!("/Title {}", format_pdf_string(title)));
}
if let Some(ref author) = self.metadata.author {
info_entries.push(format!("/Author {}", format_pdf_string(author)));
}
if let Some(ref subject) = self.metadata.subject {
info_entries.push(format!("/Subject {}", format_pdf_string(subject)));
}
if let Some(ref keywords) = self.metadata.keywords {
info_entries.push(format!("/Keywords {}", format_pdf_string(keywords)));
}
if let Some(ref creator) = self.metadata.creator {
info_entries.push(format!("/Creator {}", format_pdf_string(creator)));
}
if let Some(ref creation_date) = self.metadata.creation_date {
info_entries.push(format!("/CreationDate {}", format_pdf_string(creation_date)));
}
if let Some(ref mod_date) = self.metadata.mod_date {
info_entries.push(format!("/ModDate {}", format_pdf_string(mod_date)));
}
info_entries.push(format!("/Producer {}", format_pdf_string(PRODUCER)));
let info_ref = {
let id = w.alloc();
w.object(id, &format!("<< {} >>", info_entries.join(" ")));
Some(id)
};
w.finish(catalog_ref, info_ref)
}
}
struct RefTree {
r: Ref,
children: Vec<RefTree>,
}
fn alloc_outline_refs(w: &mut PdfWriter, nodes: &[PdfOutlineNode]) -> Vec<RefTree> {
nodes
.iter()
.map(|n| RefTree {
r: w.alloc(),
children: alloc_outline_refs(w, &n.children),
})
.collect()
}
fn count_descendants(node: &PdfOutlineNode) -> i64 {
node.children.len() as i64 + node.children.iter().map(count_descendants).sum::<i64>()
}
fn write_outline_siblings(w: &mut PdfWriter, nodes: &[PdfOutlineNode], ref_nodes: &[RefTree], parent_ref: Ref, page_refs: &[Ref]) {
for (i, (node, ref_node)) in nodes.iter().zip(ref_nodes).enumerate() {
let prev = (i > 0).then(|| ref_nodes[i - 1].r);
let next = (i + 1 < nodes.len()).then(|| ref_nodes[i + 1].r);
let first = ref_node.children.first().map(|c| c.r);
let last = ref_node.children.last().map(|c| c.r);
let count = count_descendants(node);
let target_page = page_refs.get(node.page_index).copied().unwrap_or(ref_node.r);
let mut entries = vec![
format!("/Title {}", format_pdf_string(&node.title)),
format!("/Parent {}", parent_ref.write()),
format!("/Dest [{} /XYZ null {} null]", target_page.write(), fmt_num(node.y)),
];
if let Some(p) = prev {
entries.push(format!("/Prev {}", p.write()));
}
if let Some(n) = next {
entries.push(format!("/Next {}", n.write()));
}
if let Some(f) = first {
entries.push(format!("/First {}", f.write()));
}
if let Some(l) = last {
entries.push(format!("/Last {}", l.write()));
}
if count > 0 {
entries.push(format!("/Count {count}"));
}
w.object(ref_node.r, &format!("<< {} >>", entries.join(" ")));
write_outline_siblings(w, &node.children, &ref_node.children, ref_node.r, page_refs);
}
}
pub(crate) fn format_pdf_string(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
format!("({escaped})")
}
#[cfg(feature = "pdf-a")]
fn xml_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
#[cfg(feature = "pdf-a")]
fn build_xmp_packet(metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> String {
let mut props = String::new();
if let Some(ref title) = metadata.title {
props.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(ref author) = metadata.author {
props.push_str(&format!(
"<dc:creator><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></dc:creator>",
xml_escape(author)
));
}
if let Some(ref subject) = metadata.subject {
props.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(ref keywords) = metadata.keywords {
props.push_str(&format!("<pdf:Keywords>{}</pdf:Keywords>", xml_escape(keywords)));
}
if let Some(ref creator) = metadata.creator {
props.push_str(&format!("<xmp:CreatorTool>{}</xmp:CreatorTool>", xml_escape(creator)));
}
if let Some(ref created) = metadata.xmp_creation_date {
props.push_str(&format!("<xmp:CreateDate>{created}</xmp:CreateDate>"));
}
if let Some(ref modified) = metadata.xmp_mod_date {
props.push_str(&format!("<xmp:ModifyDate>{modified}</xmp:ModifyDate>"));
}
props.push_str("<pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>");
if pdf_ua {
props.push_str("<pdfuaid:part>1</pdfuaid:part>");
}
let zugferd_block = if zugferd { ZUGFERD_XMP_EXTENSION } else { "" };
let pdfua_block = if pdf_ua { PDFUA_XMP_EXTENSION } else { "" };
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:pdf=\"http://ns.adobe.com/pdf/1.3/\" \
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\" \
xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\">\
{props}\
</rdf:Description>\
{zugferd_block}\
{pdfua_block}\
</rdf:RDF>\
</x:xmpmeta>\
<?xpacket end=\"w\"?>"
)
}
#[cfg(all(feature = "pdf-a", not(feature = "tagged-pdf")))]
const PDFUA_XMP_EXTENSION: &str = "";
#[cfg(feature = "tagged-pdf")]
const PDFUA_XMP_EXTENSION: &str = "\
<rdf:Description rdf:about=\"\" \
xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema>\
<pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>\
<pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>\
<pdfaSchema:property><rdf:Seq>\
<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>part</pdfaProperty:name><pdfaProperty:valueType>Integer</pdfaProperty:valueType><pdfaProperty:category>internal</pdfaProperty:category><pdfaProperty:description>Indicates, as an integer, the part of ISO 14289 to which the file conforms</pdfaProperty:description></rdf:li>\
</rdf:Seq></pdfaSchema:property>\
</rdf:li></rdf:Bag></pdfaExtension:schemas>\
</rdf:Description>";
#[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
const ZUGFERD_XMP_EXTENSION: &str = "";
#[cfg(feature = "zugferd")]
const ZUGFERD_XMP_EXTENSION: &str = "\
<rdf:Description rdf:about=\"\" xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\">\
<fx:DocumentType>INVOICE</fx:DocumentType>\
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>\
<fx:Version>1.0</fx:Version>\
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>\
</rdf:Description>\
<rdf:Description rdf:about=\"\" \
xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>\
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>\
<pdfaSchema:prefix>fx</pdfaSchema:prefix>\
<pdfaSchema:property><rdf:Seq>\
<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>\
<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>\
<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>\
<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>\
</rdf:Seq></pdfaSchema:property>\
</rdf:li></rdf:Bag></pdfaExtension:schemas>\
</rdf:Description>";
#[cfg(test)]
mod tests {
use super::*;
fn tiny_font() -> CidFont {
CidFont {
base_font: "Test".to_string(),
subset_bytes: vec![0u8; 16],
widths: vec![0.0, 600.0],
ascent: 800.0,
descent: -200.0,
cap_height: 700.0,
italic_angle: 0.0,
bbox: (-100.0, -200.0, 900.0, 900.0),
is_italic: false,
is_bold: false,
to_unicode: vec![(1, 'H')],
}
}
#[test]
fn writes_a_single_empty_page() {
let mut doc = PdfDocument::new();
doc.add_page(PdfPage {
width: 595.0,
height: 842.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Type /Page"));
assert!(text.contains("/MediaBox [0 0 595 842]"));
assert!(text.contains("%%EOF"));
}
#[test]
fn writes_a_goto_destination_for_an_internal_link_annotation() {
let mut doc = PdfDocument::new();
doc.add_page(PdfPage {
width: 595.0,
height: 842.0,
content: Vec::new(),
annotations: vec![PdfLinkAnnotation {
rect: (10.0, 20.0, 100.0, 40.0),
action: PdfLinkAction::GoTo { page_index: 1, y: 700.0 },
}],
});
doc.add_page(PdfPage {
width: 595.0,
height: 842.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Subtype /Link"));
assert!(text.contains("/Dest ["));
assert!(text.contains("/XYZ null 700 null"));
assert!(!text.contains("/S /URI"), "a GoTo annotation must not also emit a URI action");
}
#[test]
fn writes_type0_cid_font_structure() {
let mut doc = PdfDocument::new();
doc.add_font(tiny_font());
doc.add_page(PdfPage {
width: 595.0,
height: 842.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Subtype /Type0"));
assert!(text.contains("/Encoding /Identity-H"));
assert!(text.contains("/Subtype /CIDFontType2"));
assert!(text.contains("/CIDToGIDMap /Identity"));
assert!(text.contains("/ToUnicode"));
let decoded = stream_bodies_decoded(&bytes);
assert!(decoded.contains("beginbfchar"));
assert!(decoded.contains("<0001> <0048>")); }
fn stream_bodies_decoded(bytes: &[u8]) -> String {
const START: &[u8] = b"stream\n";
const END: &[u8] = b"\nendstream";
let mut bodies = Vec::new();
let mut i = 0;
while let Some(start_rel) = bytes[i..].windows(START.len()).position(|w| w == START) {
let start = i + start_rel + START.len();
let Some(end_rel) = bytes[start..].windows(END.len()).position(|w| w == END) else {
break;
};
let end = start + end_rel;
bodies.push(&bytes[start..end]);
i = end + END.len();
}
bodies.into_iter().map(decode_one_stream_body).collect::<Vec<_>>().join("\n")
}
#[cfg(feature = "compress")]
fn decode_one_stream_body(body: &[u8]) -> String {
match miniz_oxide::inflate::decompress_to_vec_zlib(body) {
Ok(v) => String::from_utf8_lossy(&v).into_owned(),
Err(_) => String::new(), }
}
#[cfg(not(feature = "compress"))]
fn decode_one_stream_body(body: &[u8]) -> String {
String::from_utf8_lossy(body).into_owned()
}
#[test]
fn writes_image_xobject_with_smask() {
let mut doc = PdfDocument::new();
doc.add_image(ImageXObject {
width_px: 4,
height_px: 4,
color_space: ColorSpace::DeviceRgb,
bits_per_component: 8,
filter: ImageDataFilter::None,
bytes: vec![0u8; 4 * 4 * 3],
smask: Some(Box::new(ImageXObject {
width_px: 4,
height_px: 4,
color_space: ColorSpace::DeviceGray,
bits_per_component: 8,
filter: ImageDataFilter::None,
bytes: vec![255u8; 4 * 4],
smask: None,
})),
});
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Subtype /Image"));
assert!(text.contains("/ColorSpace /DeviceRGB"));
assert!(text.contains("/ColorSpace /DeviceGray"));
assert!(text.contains("/SMask"));
assert!(text.contains("/XObject << /Im1"));
}
#[test]
fn writes_jpeg_image_with_dct_decode_filter() {
let mut doc = PdfDocument::new();
doc.add_image(ImageXObject {
width_px: 10,
height_px: 10,
color_space: ColorSpace::DeviceRgb,
bits_per_component: 8,
filter: ImageDataFilter::DctDecode,
bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], smask: None,
});
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Filter /DCTDecode"));
}
#[cfg(feature = "pdf-a")]
#[test]
fn writes_output_intent_and_xmp_metadata_when_pdf_a3b_is_set() {
let mut doc = PdfDocument::new();
doc.pdf_a3b = true;
doc.metadata.title = Some("Rechnung".to_string());
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/OutputIntents ["));
assert!(text.contains("/S /GTS_PDFA1"));
assert!(text.contains("/DestOutputProfile"));
assert!(text.contains("/Type /Metadata /Subtype /XML"));
assert!(text.contains("<pdfaid:part>3</pdfaid:part>"));
assert!(text.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
assert!(text.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Rechnung</rdf:li></rdf:Alt></dc:title>"));
}
#[cfg(feature = "pdf-a")]
#[test]
fn omits_pdf_a_entries_when_pdf_a3b_is_not_set() {
let mut doc = PdfDocument::new();
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("/OutputIntents"));
assert!(!text.contains("/Type /Metadata"));
assert!(!text.contains("/Group"));
}
#[cfg(feature = "zugferd")]
#[test]
fn embeds_zugferd_xml_with_af_and_xmp_extension() {
let mut doc = PdfDocument::new();
doc.pdf_a3b = true;
doc.zugferd_xml = Some(b"<CrossIndustryInvoice/>".to_vec());
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/Type /Filespec"));
assert!(text.contains("/AFRelationship /Alternative"));
assert!(text.contains("/Type /EmbeddedFile /Subtype /text#2Fxml"));
assert!(text.contains("/AF ["));
assert!(text.contains("/Names << /EmbeddedFiles"));
assert!(text.contains("factur-x.xml"));
assert!(text.contains("xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\""));
assert!(text.contains("<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>"));
assert!(text.contains("pdfaSchema:namespaceURI"));
}
#[cfg(feature = "zugferd")]
#[test]
fn omits_zugferd_entries_when_zugferd_xml_is_not_set() {
let mut doc = PdfDocument::new();
doc.pdf_a3b = true;
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("/Type /Filespec"));
assert!(!text.contains("/AF ["));
assert!(!text.contains("xmlns:fx="));
}
#[cfg(feature = "tagged-pdf")]
#[test]
fn writes_struct_tree_mark_info_and_lang_when_pdf_ua_is_set() {
use crate::struct_tree::PdfStructNode;
let mut doc = PdfDocument::new();
doc.pdf_a3b = true;
doc.pdf_ua = true;
doc.lang = Some("en-US".to_string());
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
doc.struct_tree = Some(PdfStructNode::Elem {
tag: "Document",
alt: None,
attrs: None,
children: vec![PdfStructNode::Elem {
tag: "H1",
alt: None,
attrs: None,
children: vec![PdfStructNode::ContentRef { page_index: 0, mcid: 0 }],
}],
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MarkInfo << /Marked true >>"));
assert!(text.contains("/Lang (en-US)"));
assert!(text.contains("/Type /StructTreeRoot"));
assert!(text.contains("/Type /StructElem /S /Document"));
assert!(text.contains("/Type /StructElem /S /H1"));
assert!(text.contains("/Type /MCR /Pg"));
assert!(text.contains("/StructParents 0"));
assert!(text.contains("/Nums ["));
assert!(text.contains("<pdfuaid:part>1</pdfuaid:part>"));
}
#[cfg(feature = "tagged-pdf")]
#[test]
fn omits_struct_tree_entries_when_pdf_ua_is_not_set() {
let mut doc = PdfDocument::new();
doc.add_page(PdfPage {
width: 200.0,
height: 200.0,
content: Vec::new(),
annotations: Vec::new(),
});
let bytes = doc.write();
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("/StructTreeRoot"));
assert!(!text.contains("/MarkInfo"));
assert!(!text.contains("/StructParents"));
}
}