use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use oxml_layout::{
Color, FillRule, FontId, GradientStop, LayoutResult, LineCap, LineJoin, Paint, Path,
PathCommand, PathElement, PositionedElement, Stroke, Transform, walk,
};
use pdf_writer::types::{
ActionType, AnnotationFlags, AnnotationType, CidFontType, ColorSpaceOperand, FontFlags,
FunctionShadingType, LineCapStyle, LineJoinStyle, OutputIntentSubtype, SystemInfo,
};
use pdf_writer::{Content, Filter, Finish, Name, Pdf, Rect, Ref, Str, TextStr};
use crate::font::{self, PreparedFont};
use crate::image;
use crate::structure::{PreparedStructure, has_content_stream_output};
use crate::{PdfConformance, conformance};
struct AlphaEntry {
key: u32,
alpha: f32,
name: String,
reference: Ref,
}
struct AlphaStates {
entries: Vec<AlphaEntry>,
by_key: HashMap<u32, usize>,
}
impl AlphaStates {
fn new(pages: &[Arc<oxml_layout::PageFrame>], alloc: &mut impl FnMut() -> Ref) -> Self {
let mut keys = BTreeSet::new();
for page in pages {
collect_alpha_keys(&page.elements, &mut keys);
}
let entries: Vec<AlphaEntry> = keys
.into_iter()
.enumerate()
.map(|(index, key)| AlphaEntry {
key,
alpha: f32::from_bits(key),
name: format!("GS{index}"),
reference: alloc(),
})
.collect();
let by_key = entries
.iter()
.enumerate()
.map(|(index, entry)| (entry.key, index))
.collect();
Self { entries, by_key }
}
fn entry(&self, alpha: f64) -> Option<&AlphaEntry> {
let key = alpha_key(alpha)?;
self.by_key.get(&key).map(|index| &self.entries[*index])
}
fn page_entries(&self, page: &oxml_layout::PageFrame) -> Vec<&AlphaEntry> {
let mut keys = BTreeSet::new();
collect_alpha_keys(&page.elements, &mut keys);
keys.into_iter()
.filter_map(|key| self.by_key.get(&key).map(|index| &self.entries[*index]))
.collect()
}
}
fn alpha_key(alpha: f64) -> Option<u32> {
let normalized = if alpha.is_finite() {
alpha.clamp(0.0, 1.0) as f32
} else {
1.0
};
let normalized = if normalized == 0.0 { 0.0 } else { normalized };
(normalized < 1.0).then(|| normalized.to_bits())
}
fn collect_alpha_keys(elements: &[PositionedElement], keys: &mut BTreeSet<u32>) {
for element in elements {
match element {
PositionedElement::Text(run) if !(run.text.is_empty() && run.glyph_ids.is_empty()) => {
insert_alpha(keys, run.color.a)
}
PositionedElement::Text(_) => {}
PositionedElement::MultilingualText(run)
if !(run.logical_text.is_empty() && run.glyph_ids.is_empty()) =>
{
insert_alpha(keys, run.color.a)
}
PositionedElement::MultilingualText(_) => {}
PositionedElement::Line { color, .. } | PositionedElement::FilledRect { color, .. } => {
insert_alpha(keys, color.a)
}
PositionedElement::Path(path) => {
if let Some(Paint::Solid(color)) = &path.fill {
insert_alpha(keys, color.a);
}
if let Some(Stroke {
paint: Paint::Solid(color),
..
}) = &path.stroke
{
insert_alpha(keys, color.a);
}
}
PositionedElement::Group(group) => {
insert_alpha(keys, group.opacity);
collect_alpha_keys(&group.children, keys);
}
PositionedElement::MarkedContent { children, .. } => {
collect_alpha_keys(children, keys);
}
_ => {}
}
}
}
fn insert_alpha(keys: &mut BTreeSet<u32>, alpha: f64) {
if let Some(key) = alpha_key(alpha) {
keys.insert(key);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum GradientTarget {
Fill,
Stroke,
}
enum GradientGeometry {
Linear([f32; 4]),
Radial([f32; 6]),
}
struct GradientEntry {
page_idx: usize,
name: String,
pattern_ref: Ref,
shading_ref: Ref,
stitching_ref: Ref,
interval_refs: Vec<Ref>,
matrix: Transform,
geometry: GradientGeometry,
stops: Vec<GradientStop>,
extend: [bool; 2],
}
struct GradientRegistry {
entries: Vec<GradientEntry>,
by_occurrence: HashMap<(usize, usize, GradientTarget), usize>,
}
impl GradientRegistry {
fn new(pages: &[Arc<oxml_layout::PageFrame>], alloc: &mut impl FnMut() -> Ref) -> Self {
let mut registry = Self {
entries: Vec::new(),
by_occurrence: HashMap::new(),
};
for (page_idx, page) in pages.iter().enumerate() {
let mut leaf_idx = 0;
let page_flip = Transform {
a: 1.0,
b: 0.0,
c: 0.0,
d: -1.0,
e: 0.0,
f: page.height,
};
walk(&page.elements, &mut |element, transform| {
let current_idx = leaf_idx;
leaf_idx += 1;
let PositionedElement::Path(path) = element else {
return;
};
if let Some(fill) = &path.fill {
registry.collect(
fill,
page_idx,
current_idx,
GradientTarget::Fill,
transform.then(page_flip),
alloc,
);
}
if let Some(stroke) = &path.stroke {
registry.collect(
&stroke.paint,
page_idx,
current_idx,
GradientTarget::Stroke,
transform.then(page_flip),
alloc,
);
}
});
}
registry
}
fn collect(
&mut self,
paint: &Paint,
page_idx: usize,
leaf_idx: usize,
target: GradientTarget,
matrix: Transform,
alloc: &mut impl FnMut() -> Ref,
) {
let (geometry, stops, extend) = match paint {
Paint::Linear {
start,
end,
stops,
extend,
} => (
GradientGeometry::Linear([
start.x as f32,
start.y as f32,
end.x as f32,
end.y as f32,
]),
stops,
*extend,
),
Paint::Radial {
center,
radius,
focal,
stops,
extend,
} => (
GradientGeometry::Radial([
focal.x as f32,
focal.y as f32,
0.0,
center.x as f32,
center.y as f32,
finite_f32(*radius).max(0.0),
]),
stops,
*extend,
),
Paint::Solid(_) | Paint::Tile { .. } => return,
};
let Some(stops) = normalize_gradient_stops(stops) else {
return;
};
let stops = complete_gradient_domain(stops);
let index = self.entries.len();
let interval_refs = (1..stops.len()).map(|_| alloc()).collect();
self.entries.push(GradientEntry {
page_idx,
name: format!("P{index}"),
pattern_ref: alloc(),
shading_ref: alloc(),
stitching_ref: alloc(),
interval_refs,
matrix,
geometry,
stops,
extend: [extend.0, extend.1],
});
self.by_occurrence
.insert((page_idx, leaf_idx, target), index);
}
fn entry(
&self,
page_idx: usize,
leaf_idx: usize,
target: GradientTarget,
) -> Option<&GradientEntry> {
self.by_occurrence
.get(&(page_idx, leaf_idx, target))
.map(|index| &self.entries[*index])
}
fn page_entries(&self, page_idx: usize) -> impl Iterator<Item = &GradientEntry> {
self.entries
.iter()
.filter(move |entry| entry.page_idx == page_idx)
}
}
fn normalize_gradient_stops(stops: &[GradientStop]) -> Option<Vec<GradientStop>> {
let mut normalized: Vec<GradientStop> = stops
.iter()
.map(|stop| GradientStop {
offset: normalized_offset(stop.offset),
color: composite_stop_alpha(stop.color),
})
.collect();
normalized.sort_by(|left, right| left.offset.total_cmp(&right.offset));
let mut deduplicated: Vec<GradientStop> = Vec::with_capacity(normalized.len());
for stop in normalized {
if let Some(previous) = deduplicated.last_mut()
&& previous.offset == stop.offset
{
*previous = stop;
} else {
deduplicated.push(stop);
}
}
match deduplicated.as_slice() {
[] => None,
[stop] => Some(vec![
GradientStop {
offset: 0.0,
color: stop.color,
},
GradientStop {
offset: 1.0,
color: stop.color,
},
]),
_ => Some(deduplicated),
}
}
fn complete_gradient_domain(mut stops: Vec<GradientStop>) -> Vec<GradientStop> {
let first = stops[0];
if first.offset > 0.0 {
stops.insert(
0,
GradientStop {
offset: 0.0,
color: first.color,
},
);
}
let last = stops[stops.len() - 1];
if last.offset < 1.0 {
stops.push(GradientStop {
offset: 1.0,
color: last.color,
});
}
stops
}
fn normalized_offset(offset: f64) -> f64 {
if offset.is_nan() {
0.0
} else {
offset.clamp(0.0, 1.0)
}
}
fn composite_stop_alpha(color: Color) -> Color {
let alpha = if color.a.is_finite() {
color.a.clamp(0.0, 1.0)
} else {
1.0
};
Color {
r: color.r * alpha + (1.0 - alpha),
g: color.g * alpha + (1.0 - alpha),
b: color.b * alpha + (1.0 - alpha),
a: 1.0,
}
}
fn finite_f32(value: f64) -> f32 {
if value.is_finite() { value as f32 } else { 0.0 }
}
fn accessibility_xmp(title: &str) -> Vec<u8> {
let title = title
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'");
format!(
r#"<?xpacket begin="" 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:pdfuaid="http://www.aiim.org/pdfua/ns/id/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">{title}</rdf:li></rdf:Alt></dc:title>
<pdfuaid:part>1</pdfuaid:part>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#
)
.into_bytes()
}
pub(crate) fn write_pdf(layout: &LayoutResult) -> Vec<u8> {
write_pdf_impl(layout, None)
}
pub(crate) fn write_archival_pdf(layout: &LayoutResult, profile: PdfConformance) -> Vec<u8> {
write_pdf_impl(layout, Some(profile))
}
fn write_pdf_impl(layout: &LayoutResult, profile: Option<PdfConformance>) -> Vec<u8> {
let mut pdf = Pdf::new();
if profile.is_some() {
pdf.set_file_id((
conformance::FILE_IDENTIFIER.to_vec(),
conformance::FILE_IDENTIFIER.to_vec(),
));
}
let mut next_id = 1;
let mut alloc = || {
let r = Ref::new(next_id);
next_id += 1;
r
};
let catalog_id = alloc();
let page_tree_id = alloc();
let info_id = alloc();
let outline_root_id = alloc();
let page_ids: Vec<Ref> = layout.pages.iter().map(|_| alloc()).collect();
let content_ids: Vec<Ref> = layout.pages.iter().map(|_| alloc()).collect();
let mut glyph_usage = font::collect_glyph_usage(layout);
let mut prepared_fonts: BTreeMap<FontId, PreparedFont> = BTreeMap::new();
let mut font_refs: BTreeMap<FontId, (Ref, Ref, Ref, Ref, Ref)> = BTreeMap::new();
for fd in &layout.fonts {
if let Some(usage) = glyph_usage.get_mut(&fd.id)
&& let Some(prepared) = font::prepare_font(fd, usage)
{
let type0_ref = alloc();
let cid_ref = alloc();
let descriptor_ref = alloc();
let stream_ref = alloc();
let cmap_ref = alloc();
font_refs.insert(
fd.id,
(type0_ref, cid_ref, descriptor_ref, stream_ref, cmap_ref),
);
prepared_fonts.insert(fd.id, prepared);
}
}
struct ImageEntry {
decoded: image::DecodedImage,
xobject_ref: Ref,
smask_ref: Option<Ref>,
}
let mut image_entries: Vec<ImageEntry> = Vec::new();
let mut image_map: HashMap<(usize, usize), usize> = HashMap::new();
for (page_idx, page) in layout.pages.iter().enumerate() {
let mut elem_idx = 0;
walk(&page.elements, &mut |element, _| {
if matches!(element, PositionedElement::Text(run) if run.text.is_empty() && run.glyph_ids.is_empty())
{
return;
}
let current_idx = elem_idx;
elem_idx += 1;
if let PositionedElement::Image {
data, content_type, ..
} = element
{
if data.is_empty() {
return;
}
if let Some(decoded) = image::decode_image(data, content_type) {
let xobject_ref = alloc();
let smask_ref = if decoded.alpha.is_some() {
Some(alloc())
} else {
None
};
let idx = image_entries.len();
image_entries.push(ImageEntry {
decoded,
xobject_ref,
smask_ref,
});
image_map.insert((page_idx, current_idx), idx);
}
}
});
}
let mut annotation_refs: HashMap<(usize, usize), Ref> = HashMap::new();
for (page_idx, page) in layout.pages.iter().enumerate() {
let mut elem_idx = 0;
walk(&page.elements, &mut |element, _| {
let current_idx = elem_idx;
elem_idx += 1;
if let PositionedElement::LinkAnnotation { .. } = element {
annotation_refs.insert((page_idx, current_idx), alloc());
}
});
}
let alpha_states = AlphaStates::new(&layout.pages, &mut alloc);
let gradients = GradientRegistry::new(&layout.pages, &mut alloc);
let outline_item_ids: Vec<Ref> = layout.outlines.iter().map(|_| alloc()).collect();
let tagged = layout
.structure
.as_ref()
.and_then(|structure| PreparedStructure::new(structure, &layout.pages, &mut alloc));
let declares_pdfua = tagged.is_some() && !layout_uses_notdef(layout);
let metadata_id = (declares_pdfua || profile.is_some()).then(&mut alloc);
let icc_profile_id = profile.map(|_| alloc());
{
let mut cat = pdf.catalog(catalog_id);
cat.pages(page_tree_id);
if !layout.outlines.is_empty() {
cat.outlines(outline_root_id);
}
if let Some(tagged) = tagged.as_ref() {
cat.pair(Name(b"StructTreeRoot"), tagged.root_ref);
cat.mark_info().marked(true).suspects(false);
cat.lang(TextStr("und"));
cat.viewer_preferences().display_doc_title(true);
if let Some(metadata_id) = metadata_id {
cat.metadata(metadata_id);
}
} else if let Some(metadata_id) = metadata_id {
cat.metadata(metadata_id);
}
if let Some(icc_profile_id) = icc_profile_id {
let mut intents = cat.output_intents();
let mut intent = intents.push();
intent
.subtype(OutputIntentSubtype::PDFA)
.output_condition(TextStr("sRGB IEC61966-2.1"))
.output_condition_identifier(TextStr("sRGB2014"))
.registry_name(TextStr("https://registry.color.org"))
.info(TextStr("sRGB2014"))
.dest_output_profile(icc_profile_id);
intent.finish();
intents.finish();
}
}
{
let mut pages = pdf.pages(page_tree_id);
pages.count(layout.pages.len() as i32);
pages.kids(page_ids.iter().copied());
}
if let Some(meta) = &layout.metadata {
let mut info = pdf.document_info(info_id);
if let Some(title) = meta
.title
.as_deref()
.or_else(|| (tagged.is_some() || profile.is_some()).then_some("Untitled document"))
{
info.title(TextStr(title));
}
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));
}
if let Some(creator) = &meta.creator {
info.creator(TextStr(creator));
}
info.producer(TextStr("rdocx-pdf"));
} else if tagged.is_some() || profile.is_some() {
let mut info = pdf.document_info(info_id);
info.title(TextStr("Untitled document"));
info.producer(TextStr("rdocx-pdf"));
}
if let Some(metadata_id) = metadata_id {
let xmp = if let Some(profile) = profile {
conformance::archival_xmp(layout, profile, declares_pdfua)
} else {
let title = layout
.metadata
.as_ref()
.and_then(|meta| meta.title.as_deref())
.unwrap_or("Untitled document");
accessibility_xmp(title)
};
pdf.metadata(metadata_id, &xmp).finish();
}
if let Some(icc_profile_id) = icc_profile_id {
let mut icc = pdf.icc_profile(icc_profile_id, conformance::SRGB2014);
icc.n(3);
icc.alternate().device_rgb();
icc.finish();
}
for (font_id, prepared) in &prepared_fonts {
let (type0_ref, cid_ref, descriptor_ref, stream_ref, cmap_ref) = font_refs[font_id];
let base_name = sanitize_font_name(
&prepared.font_data.family,
prepared.font_data.bold,
prepared.font_data.italic,
);
let mut type0 = pdf.type0_font(type0_ref);
type0
.base_font(Name(base_name.as_bytes()))
.encoding_predefined(Name(b"Identity-H"))
.descendant_font(cid_ref)
.to_unicode(cmap_ref);
type0.finish();
let mut cid = pdf.cid_font(cid_ref);
cid.subtype(CidFontType::Type2);
cid.base_font(Name(base_name.as_bytes()));
cid.system_info(SystemInfo {
registry: Str(b"Adobe"),
ordering: Str(b"Identity"),
supplement: 0,
});
cid.font_descriptor(descriptor_ref);
cid.default_width(0.0);
cid.cid_to_gid_map_predefined(Name(b"Identity"));
if !prepared.widths.is_empty() {
let mut widths = cid.widths();
let mut i = 0;
while i < prepared.widths.len() {
let start_gid = prepared.widths[i].0;
let mut consecutive_widths = vec![prepared.widths[i].1 as f32];
let mut j = i + 1;
while j < prepared.widths.len()
&& prepared.widths[j].0 == start_gid + (j - i) as u16
{
consecutive_widths.push(prepared.widths[j].1 as f32);
j += 1;
}
widths.consecutive(start_gid, consecutive_widths.iter().copied());
i = j;
}
widths.finish();
}
cid.finish();
if let Some(metrics) = font::get_font_metrics(&prepared.font_data) {
let mut desc = pdf.font_descriptor(descriptor_ref);
desc.name(Name(base_name.as_bytes()));
let mut flags = FontFlags::empty();
if prepared.font_data.italic {
flags |= FontFlags::ITALIC;
}
flags |= FontFlags::NON_SYMBOLIC;
desc.flags(flags);
desc.bbox(Rect::new(
metrics.bbox[0] as f32,
metrics.bbox[1] as f32,
metrics.bbox[2] as f32,
metrics.bbox[3] as f32,
));
desc.italic_angle(metrics.italic_angle as f32);
desc.ascent(metrics.ascent as f32);
desc.descent(metrics.descent as f32);
desc.cap_height(metrics.cap_height as f32);
desc.stem_v(metrics.stem_v as f32);
desc.font_file2(stream_ref);
desc.finish();
}
let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&prepared.subset_bytes, 6);
let mut stream = pdf.stream(stream_ref, &compressed);
stream.filter(Filter::FlateDecode);
stream.pair(Name(b"Length1"), prepared.subset_bytes.len() as i32);
stream.finish();
let compressed_cmap = miniz_oxide::deflate::compress_to_vec_zlib(&prepared.cmap_bytes, 6);
let mut cmap_stream = pdf.stream(cmap_ref, &compressed_cmap);
cmap_stream.filter(Filter::FlateDecode);
cmap_stream.finish();
}
for entry in &image_entries {
let dec = &entry.decoded;
if dec.is_jpeg {
let mut img = pdf.image_xobject(entry.xobject_ref, &dec.data);
img.filter(Filter::DctDecode);
img.width(dec.width as i32);
img.height(dec.height as i32);
set_color_space(&mut img, dec.color_space);
img.bits_per_component(8);
img.finish();
} else {
let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&dec.data, 6);
let mut img = pdf.image_xobject(entry.xobject_ref, &compressed);
img.filter(Filter::FlateDecode);
img.width(dec.width as i32);
img.height(dec.height as i32);
set_color_space(&mut img, dec.color_space);
img.bits_per_component(8);
if let Some(smask_ref) = entry.smask_ref {
img.s_mask(smask_ref);
}
img.finish();
if let (Some(alpha), Some(smask_ref)) = (&dec.alpha, entry.smask_ref) {
let compressed_alpha = miniz_oxide::deflate::compress_to_vec_zlib(alpha, 6);
let mut mask = pdf.image_xobject(smask_ref, &compressed_alpha);
mask.filter(Filter::FlateDecode);
mask.width(dec.width as i32);
mask.height(dec.height as i32);
mask.color_space().device_gray();
mask.bits_per_component(8);
mask.finish();
}
}
}
for entry in &alpha_states.entries {
let mut state = pdf.ext_graphics(entry.reference);
state.stroking_alpha(entry.alpha);
state.non_stroking_alpha(entry.alpha);
state.finish();
}
write_gradient_objects(&mut pdf, &gradients);
for (page_idx, page) in layout.pages.iter().enumerate() {
let page_id = page_ids[page_idx];
let content_id = content_ids[page_idx];
let content_bytes = build_page_content(
page_idx,
page,
PageContentResources {
prepared_fonts: &prepared_fonts,
font_refs: &font_refs,
image_map: &image_map,
alpha_states: &alpha_states,
gradients: &gradients,
structure: tagged.as_ref(),
},
);
let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&content_bytes, 6);
let mut stream = pdf.stream(content_id, &compressed);
stream.filter(Filter::FlateDecode);
stream.finish();
let mut page_dict = pdf.page(page_id);
page_dict.parent(page_tree_id);
page_dict.media_box(Rect::new(0.0, 0.0, page.width as f32, page.height as f32));
page_dict.contents(content_id);
if tagged.is_some() {
page_dict.struct_parents(page_idx as i32);
}
let mut resources = page_dict.resources();
if !prepared_fonts.is_empty() {
let mut font_dict = resources.fonts();
for (font_id, (type0_ref, _, _, _, _)) in &font_refs {
let font_name = format!("F{}", font_id.0);
font_dict.pair(Name(font_name.as_bytes()), *type0_ref);
}
font_dict.finish();
}
let mut page_images: Vec<(usize, usize)> = image_map
.iter()
.filter(|((pi, _), _)| *pi == page_idx)
.map(|((_, ei), img_idx)| (*ei, *img_idx))
.collect();
page_images.sort_unstable();
let page_image_names: Vec<(String, Ref)> = page_images
.into_iter()
.map(|(ei, img_idx)| {
(
format!("Im{}_{}", page_idx, ei),
image_entries[img_idx].xobject_ref,
)
})
.collect();
if !page_image_names.is_empty() {
let mut xobjects = resources.x_objects();
for (name, xobj_ref) in &page_image_names {
xobjects.pair(Name(name.as_bytes()), *xobj_ref);
}
xobjects.finish();
}
let page_alpha_states = alpha_states.page_entries(page);
if !page_alpha_states.is_empty() {
let mut states = resources.ext_g_states();
for entry in page_alpha_states {
states.pair(Name(entry.name.as_bytes()), entry.reference);
}
states.finish();
}
let page_gradients: Vec<_> = gradients.page_entries(page_idx).collect();
if !page_gradients.is_empty() {
let mut patterns = resources.patterns();
for entry in page_gradients {
patterns.pair(Name(entry.name.as_bytes()), entry.pattern_ref);
}
patterns.finish();
}
resources.finish();
let mut page_annotations = Vec::new();
let mut elem_idx = 0;
walk(&page.elements, &mut |element, _| {
let current_idx = elem_idx;
elem_idx += 1;
if matches!(element, PositionedElement::LinkAnnotation { .. })
&& let Some(reference) = annotation_refs.get(&(page_idx, current_idx))
{
page_annotations.push(*reference);
}
});
if !page_annotations.is_empty() {
page_dict.annotations(page_annotations.iter().copied());
}
page_dict.finish();
}
for (page_idx, page) in layout.pages.iter().enumerate() {
let mut elem_idx = 0;
walk(&page.elements, &mut |element, transform| {
let current_idx = elem_idx;
elem_idx += 1;
if let PositionedElement::LinkAnnotation { rect, url } = element
&& let Some(&annot_ref) = annotation_refs.get(&(page_idx, current_idx))
{
let rect = transform.transform_rect_bbox(*rect);
let page_height = page.height;
let mut annot = pdf.annotation(annot_ref);
annot.subtype(AnnotationType::Link);
annot.rect(Rect::new(
rect.x as f32,
(page_height - rect.y - rect.height) as f32,
(rect.x + rect.width) as f32,
(page_height - rect.y) as f32,
));
annot.border(0.0, 0.0, 0.0, None);
if profile.is_some() {
annot.flags(AnnotationFlags::PRINT);
}
annot
.action()
.action_type(ActionType::Uri)
.uri(Str(url.as_bytes()));
annot.finish();
}
});
}
if !layout.outlines.is_empty() {
write_outlines(
&mut pdf,
outline_root_id,
&layout.outlines,
&outline_item_ids,
&page_ids,
layout,
);
}
if let Some(tagged) = tagged.as_ref() {
tagged.write(&mut pdf, &page_ids);
}
pdf.finish()
}
fn layout_uses_notdef(layout: &LayoutResult) -> bool {
layout.pages.iter().any(|page| {
let mut found = false;
walk(&page.elements, &mut |element, _| {
if let PositionedElement::Text(run) = element
&& run.glyph_ids.contains(&0)
{
found = true;
}
if let PositionedElement::MultilingualText(run) = element
&& run.glyph_ids.contains(&0)
{
found = true;
}
});
found
})
}
fn write_gradient_objects(pdf: &mut Pdf, gradients: &GradientRegistry) {
for entry in &gradients.entries {
let mut pattern = pdf.shading_pattern(entry.pattern_ref);
pattern.shading_ref(entry.shading_ref);
pattern.matrix([
entry.matrix.a as f32,
entry.matrix.b as f32,
entry.matrix.c as f32,
entry.matrix.d as f32,
entry.matrix.e as f32,
entry.matrix.f as f32,
]);
pattern.finish();
let mut shading = pdf.function_shading(entry.shading_ref);
match entry.geometry {
GradientGeometry::Linear(coords) => {
shading.shading_type(FunctionShadingType::Axial);
shading.coords(coords);
}
GradientGeometry::Radial(coords) => {
shading.shading_type(FunctionShadingType::Radial);
shading.coords(coords);
}
}
shading.color_space().device_rgb();
shading
.insert(Name(b"Domain"))
.array()
.items([0.0_f32, 1.0_f32]);
shading.extend(entry.extend);
shading.function(entry.stitching_ref);
shading.finish();
let mut stitching = pdf.stitching_function(entry.stitching_ref);
stitching.domain([0.0, 1.0]);
stitching.functions(entry.interval_refs.iter().copied());
stitching.bounds(
entry.stops[1..entry.stops.len() - 1]
.iter()
.map(|stop| stop.offset as f32),
);
stitching.encode(entry.interval_refs.iter().flat_map(|_| [0.0_f32, 1.0_f32]));
stitching.finish();
for ((start, end), reference) in entry
.stops
.windows(2)
.map(|pair| (&pair[0], &pair[1]))
.zip(entry.interval_refs.iter())
{
let mut function = pdf.exponential_function(*reference);
function.domain([0.0, 1.0]);
function.c0([
start.color.r as f32,
start.color.g as f32,
start.color.b as f32,
]);
function.c1([end.color.r as f32, end.color.g as f32, end.color.b as f32]);
function.n(1.0);
function.finish();
}
}
}
#[derive(Clone, Copy)]
struct PageContentResources<'a> {
prepared_fonts: &'a BTreeMap<FontId, PreparedFont>,
font_refs: &'a BTreeMap<FontId, (Ref, Ref, Ref, Ref, Ref)>,
image_map: &'a HashMap<(usize, usize), usize>,
alpha_states: &'a AlphaStates,
gradients: &'a GradientRegistry,
structure: Option<&'a PreparedStructure<'a>>,
}
fn build_page_content(
page_idx: usize,
page: &oxml_layout::PageFrame,
resources: PageContentResources<'_>,
) -> Vec<u8> {
let mut content = Content::new();
let page_height = page.height as f32;
content.save_state();
content.transform([1.0, 0.0, 0.0, -1.0, 0.0, page_height]);
let mut state = EmitState {
page_idx,
prepared_fonts: resources.prepared_fonts,
font_refs: resources.font_refs,
image_map: resources.image_map,
alpha_states: resources.alpha_states,
gradients: resources.gradients,
leaf_index: 0,
structure: resources.structure,
next_mcid: 0,
};
emit_elements(&mut content, &page.elements, &mut state);
content.restore_state();
content.finish().to_vec()
}
struct EmitState<'a> {
page_idx: usize,
prepared_fonts: &'a BTreeMap<FontId, PreparedFont>,
font_refs: &'a BTreeMap<FontId, (Ref, Ref, Ref, Ref, Ref)>,
image_map: &'a HashMap<(usize, usize), usize>,
alpha_states: &'a AlphaStates,
gradients: &'a GradientRegistry,
leaf_index: usize,
structure: Option<&'a PreparedStructure<'a>>,
next_mcid: i32,
}
fn emit_elements(content: &mut Content, elements: &[PositionedElement], state: &mut EmitState<'_>) {
for element in elements {
if let PositionedElement::MarkedContent {
structure,
children,
} = element
{
if state.structure.is_none() && structure.is_some() {
emit_elements(content, children, state);
continue;
}
if !has_content_stream_output(children) {
emit_elements(content, children, state);
continue;
}
match structure {
Some(id) => {
let tag = state
.structure
.and_then(|prepared| prepared.role_name(*id))
.unwrap_or(b"Span");
content
.begin_marked_content_with_properties(Name(tag))
.properties()
.identify(state.next_mcid);
state.next_mcid += 1;
}
None => {
content.begin_marked_content(Name(b"Artifact"));
}
}
emit_elements(content, children, state);
content.end_marked_content();
continue;
}
let elem_idx = state.leaf_index;
if !matches!(element, PositionedElement::Group(_))
&& !matches!(element, PositionedElement::Text(run) if run.text.is_empty() && run.glyph_ids.is_empty())
&& !matches!(element, PositionedElement::MultilingualText(run) if run.logical_text.is_empty() && run.glyph_ids.is_empty())
{
state.leaf_index += 1;
}
match element {
PositionedElement::Text(run) if !(run.text.is_empty() && run.glyph_ids.is_empty()) => {
if let Some(prepared) = state.prepared_fonts.get(&run.font_id)
&& state.font_refs.contains_key(&run.font_id)
{
let font_name = format!("F{}", run.font_id.0);
content.save_state();
content.set_fill_rgb(
run.color.r as f32,
run.color.g as f32,
run.color.b as f32,
);
apply_alpha(content, run.color.a, state.alpha_states);
content.begin_text();
content.set_font(Name(font_name.as_bytes()), run.font_size as f32);
content.set_text_matrix([
1.0,
0.0,
0.0,
-1.0,
run.origin.x as f32,
run.origin.y as f32,
]);
emit_glyphs(
content,
&run.glyph_ids,
&run.advances,
run.font_size,
&prepared.remapper,
&prepared.widths,
);
content.end_text();
content.restore_state();
}
}
PositionedElement::Text(_) => {}
PositionedElement::MultilingualText(run) => {
content
.begin_marked_content_with_properties(Name(b"Span"))
.properties()
.actual_text(TextStr(&run.logical_text));
if let Some(prepared) = state.prepared_fonts.get(&run.font_id)
&& state.font_refs.contains_key(&run.font_id)
{
let font_name = format!("F{}", run.font_id.0);
content.save_state();
content.set_fill_rgb(
run.color.r as f32,
run.color.g as f32,
run.color.b as f32,
);
apply_alpha(content, run.color.a, state.alpha_states);
content.begin_text();
content.set_font(Name(font_name.as_bytes()), run.font_size as f32);
emit_multilingual_glyphs(content, run, &prepared.remapper, &prepared.widths);
content.end_text();
content.restore_state();
}
content.end_marked_content();
}
PositionedElement::Line {
start,
end,
width,
color,
dash_pattern,
} => {
content.save_state();
content.set_stroke_rgb(color.r as f32, color.g as f32, color.b as f32);
apply_alpha(content, color.a, state.alpha_states);
content.set_line_width(*width as f32);
if let Some((on, off)) = dash_pattern {
content.set_dash_pattern([*on as f32, *off as f32], 0.0);
}
content.move_to(start.x as f32, start.y as f32);
content.line_to(end.x as f32, end.y as f32);
content.stroke();
content.restore_state();
}
PositionedElement::FilledRect { rect, color } => {
content.save_state();
content.set_fill_rgb(color.r as f32, color.g as f32, color.b as f32);
apply_alpha(content, color.a, state.alpha_states);
content.rect(
rect.x as f32,
rect.y as f32,
rect.width as f32,
rect.height as f32,
);
content.fill_nonzero();
content.restore_state();
}
PositionedElement::Image { rect, data, .. } => {
if !data.is_empty() && state.image_map.contains_key(&(state.page_idx, elem_idx)) {
let img_name = format!("Im{}_{}", state.page_idx, elem_idx);
content.save_state();
content.transform([
rect.width as f32,
0.0,
0.0,
-rect.height as f32,
rect.x as f32,
(rect.y + rect.height) as f32,
]);
content.x_object(Name(img_name.as_bytes()));
content.restore_state();
}
}
PositionedElement::LinkAnnotation { .. } => {
}
PositionedElement::Path(path) => {
emit_path(
content,
path,
state.page_idx,
elem_idx,
state.alpha_states,
state.gradients,
);
}
PositionedElement::Group(group) => {
content.save_state();
content.transform([
group.transform.a as f32,
group.transform.b as f32,
group.transform.c as f32,
group.transform.d as f32,
group.transform.e as f32,
group.transform.f as f32,
]);
if let Some(clip) = &group.clip {
emit_path_geometry(content, clip);
match clip.fill_rule {
FillRule::NonZero => content.clip_nonzero(),
FillRule::EvenOdd => content.clip_even_odd(),
};
content.end_path();
}
apply_alpha(content, group.opacity, state.alpha_states);
emit_elements(content, &group.children, state);
content.restore_state();
}
_ => {
}
}
}
}
fn apply_alpha(content: &mut Content, alpha: f64, states: &AlphaStates) {
if let Some(entry) = states.entry(alpha) {
content.set_parameters(Name(entry.name.as_bytes()));
}
}
enum ResolvedPaint<'a> {
Solid(Color),
Gradient(&'a GradientEntry),
}
fn emit_path(
content: &mut Content,
element: &PathElement,
page_idx: usize,
leaf_idx: usize,
alpha_states: &AlphaStates,
gradients: &GradientRegistry,
) {
let fill = element.fill.as_ref().and_then(|paint| {
resolve_paint(paint, page_idx, leaf_idx, GradientTarget::Fill, gradients)
});
let stroke = element.stroke.as_ref().and_then(|stroke| {
resolve_paint(
&stroke.paint,
page_idx,
leaf_idx,
GradientTarget::Stroke,
gradients,
)
.map(|paint| (stroke, paint))
});
if fill.is_none() && stroke.is_none() {
return;
}
content.save_state();
match (fill, stroke) {
(Some(ResolvedPaint::Solid(fill)), Some((stroke, ResolvedPaint::Solid(stroke_color))))
if alpha_key(fill.a) == alpha_key(stroke_color.a) =>
{
set_fill_color(content, fill, alpha_states);
emit_stroke_style(content, stroke, stroke_color);
emit_path_geometry(content, &element.path);
match element.path.fill_rule {
FillRule::NonZero => content.fill_nonzero_and_stroke(),
FillRule::EvenOdd => content.fill_even_odd_and_stroke(),
};
}
(Some(fill), Some((stroke, stroke_paint))) => {
content.save_state();
set_fill_paint(content, fill, alpha_states);
emit_path_geometry(content, &element.path);
match element.path.fill_rule {
FillRule::NonZero => content.fill_nonzero(),
FillRule::EvenOdd => content.fill_even_odd(),
};
content.restore_state();
content.save_state();
emit_stroke_paint(content, stroke, stroke_paint, alpha_states);
emit_path_geometry(content, &element.path);
content.stroke();
content.restore_state();
}
(Some(fill), None) => {
set_fill_paint(content, fill, alpha_states);
emit_path_geometry(content, &element.path);
match element.path.fill_rule {
FillRule::NonZero => content.fill_nonzero(),
FillRule::EvenOdd => content.fill_even_odd(),
};
}
(None, Some((stroke, stroke_paint))) => {
emit_stroke_paint(content, stroke, stroke_paint, alpha_states);
emit_path_geometry(content, &element.path);
content.stroke();
}
(None, None) => {}
}
content.restore_state();
}
fn resolve_paint<'a>(
paint: &Paint,
page_idx: usize,
leaf_idx: usize,
target: GradientTarget,
gradients: &'a GradientRegistry,
) -> Option<ResolvedPaint<'a>> {
match paint {
Paint::Solid(color) => Some(ResolvedPaint::Solid(*color)),
Paint::Linear { .. } | Paint::Radial { .. } => gradients
.entry(page_idx, leaf_idx, target)
.map(ResolvedPaint::Gradient),
Paint::Tile { .. } => None,
}
}
fn set_fill_paint(content: &mut Content, paint: ResolvedPaint<'_>, alpha_states: &AlphaStates) {
match paint {
ResolvedPaint::Solid(color) => set_fill_color(content, color, alpha_states),
ResolvedPaint::Gradient(entry) => {
content.set_fill_color_space(ColorSpaceOperand::Pattern);
content.set_fill_pattern([], Name(entry.name.as_bytes()));
}
}
}
fn emit_stroke_paint(
content: &mut Content,
stroke: &Stroke,
paint: ResolvedPaint<'_>,
alpha_states: &AlphaStates,
) {
match paint {
ResolvedPaint::Solid(color) => {
emit_stroke_style(content, stroke, color);
apply_alpha(content, color.a, alpha_states);
}
ResolvedPaint::Gradient(entry) => {
emit_stroke_geometry_style(content, stroke);
content.set_stroke_color_space(ColorSpaceOperand::Pattern);
content.set_stroke_pattern([], Name(entry.name.as_bytes()));
}
}
}
fn set_fill_color(content: &mut Content, color: oxml_layout::Color, alpha_states: &AlphaStates) {
content.set_fill_rgb(color.r as f32, color.g as f32, color.b as f32);
apply_alpha(content, color.a, alpha_states);
}
fn emit_stroke_style(content: &mut Content, stroke: &Stroke, color: oxml_layout::Color) {
emit_stroke_geometry_style(content, stroke);
content.set_stroke_rgb(color.r as f32, color.g as f32, color.b as f32);
}
fn emit_stroke_geometry_style(content: &mut Content, stroke: &Stroke) {
let width = if stroke.width.is_finite() {
stroke.width.max(0.0) as f32
} else {
0.0
};
content.set_line_width(width);
content.set_line_cap(match stroke.cap {
LineCap::Butt => LineCapStyle::ButtCap,
LineCap::Round => LineCapStyle::RoundCap,
LineCap::Square => LineCapStyle::ProjectingSquareCap,
});
content.set_line_join(match stroke.join {
LineJoin::Miter => LineJoinStyle::MiterJoin,
LineJoin::Round => LineJoinStyle::RoundJoin,
LineJoin::Bevel => LineJoinStyle::BevelJoin,
});
content.set_miter_limit(10.0);
if let Some(dash) = &stroke.dash {
content.set_dash_pattern(dash.iter().map(|value| *value as f32), 0.0);
}
}
fn emit_path_geometry(content: &mut Content, path: &Path) {
for command in &path.commands {
match command {
PathCommand::MoveTo(point) => {
content.move_to(point.x as f32, point.y as f32);
}
PathCommand::LineTo(point) => {
content.line_to(point.x as f32, point.y as f32);
}
PathCommand::CurveTo { c1, c2, to } => {
content.cubic_to(
c1.x as f32,
c1.y as f32,
c2.x as f32,
c2.y as f32,
to.x as f32,
to.y as f32,
);
}
PathCommand::Close => {
content.close_path();
}
}
}
}
fn emit_glyphs(
content: &mut Content,
glyph_ids: &[u16],
advances: &[f64],
font_size: f64,
remapper: &subsetter::GlyphRemapper,
widths: &[(u16, f64)],
) {
if glyph_ids.is_empty() {
return;
}
let width_map: HashMap<u16, f64> = widths.iter().copied().collect();
let mut positioned = content.show_positioned();
let mut items = positioned.items();
for (i, &gid) in glyph_ids.iter().enumerate() {
let new_gid = remapper.get(gid).unwrap_or(0);
let bytes = new_gid.to_be_bytes();
items.show(Str(&bytes));
if i + 1 < glyph_ids.len() {
let advance_pt = advances[i];
let declared_width = width_map.get(&new_gid).copied().unwrap_or(0.0);
let adjustment = (declared_width - advance_pt / font_size * 1000.0) as f32;
items.adjust(adjustment);
}
}
items.finish();
positioned.finish();
}
fn emit_multilingual_glyphs(
content: &mut Content,
run: &oxml_layout::output::MultilingualGlyphRun,
remapper: &subsetter::GlyphRemapper,
widths: &[(u16, f64)],
) {
if !run.is_valid() {
return;
}
let mut x = run.origin.x;
let mut y = run.origin.y;
for (index, &glyph_id) in run.glyph_ids.iter().enumerate() {
content.set_text_matrix([
1.0,
0.0,
0.0,
-1.0,
(x + run.x_offsets[index]) as f32,
(y - run.y_offsets[index]) as f32,
]);
emit_glyphs(
content,
std::slice::from_ref(&glyph_id),
std::slice::from_ref(&run.x_advances[index]),
run.font_size,
remapper,
widths,
);
x += run.x_advances[index];
y -= run.y_advances[index];
}
}
fn write_outlines(
pdf: &mut Pdf,
root_id: Ref,
outlines: &[oxml_layout::OutlineEntry],
item_ids: &[Ref],
page_ids: &[Ref],
layout: &LayoutResult,
) {
if outlines.is_empty() {
return;
}
let n = outlines.len();
let mut parent_idx: Vec<Option<usize>> = vec![None; n];
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut stack: Vec<(usize, u32)> = Vec::new();
for (i, entry) in outlines.iter().enumerate() {
while let Some(&(_, lvl)) = stack.last() {
if lvl >= entry.level {
stack.pop();
} else {
break;
}
}
if let Some(&(parent, _)) = stack.last() {
parent_idx[i] = Some(parent);
children[parent].push(i);
}
stack.push((i, entry.level));
}
let top_level: Vec<usize> = (0..n).filter(|i| parent_idx[*i].is_none()).collect();
if let (Some(&first), Some(&last)) = (top_level.first(), top_level.last()) {
let mut root = pdf.outline(root_id);
root.first(item_ids[first]);
root.last(item_ids[last]);
root.count(n as i32); root.finish();
}
for (i, entry) in outlines.iter().enumerate() {
let mut item = pdf.outline_item(item_ids[i]);
item.title(TextStr(&entry.title));
let actual_parent = match parent_idx[i] {
Some(pi) => item_ids[pi],
None => root_id,
};
item.parent(actual_parent);
let siblings: &[usize] = if let Some(pi) = parent_idx[i] {
&children[pi]
} else {
&top_level
};
if let Some(pos) = siblings.iter().position(|&x| x == i) {
if pos > 0 {
item.prev(item_ids[siblings[pos - 1]]);
}
if pos + 1 < siblings.len() {
item.next(item_ids[siblings[pos + 1]]);
}
}
if !children[i].is_empty() {
item.first(item_ids[children[i][0]]);
item.last(item_ids[*children[i].last().unwrap()]);
item.count(children[i].len() as i32);
}
let page_idx = entry.page_index.min(page_ids.len().saturating_sub(1));
if page_idx < page_ids.len() {
let page_height = layout
.pages
.get(page_idx)
.map(|p| p.height)
.unwrap_or(792.0);
let pdf_y = page_height - entry.y_position;
item.dest()
.page(page_ids[page_idx])
.xyz(0.0, pdf_y as f32, None);
}
item.finish();
}
}
fn set_color_space(img: &mut pdf_writer::writers::ImageXObject<'_>, cs: &str) {
match cs {
"DeviceGray" => {
img.color_space().device_gray();
}
"DeviceCMYK" => {
img.color_space().device_cmyk();
}
_ => {
img.color_space().device_rgb();
}
}
}
fn sanitize_font_name(family: &str, bold: bool, italic: bool) -> String {
let mut name = family
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<String>();
if name.is_empty() {
name = "Font".to_string();
}
if bold && italic {
name.push_str("-BoldItalic");
} else if bold {
name.push_str("-Bold");
} else if italic {
name.push_str("-Italic");
}
name
}
#[cfg(test)]
mod tests {
use super::*;
use oxml_layout::{
Color, DocumentStructure, Effect, FillRule, FontData, GlyphCluster, GlyphRun, GradientStop,
GroupElement, LineCap, LineJoin, MediaId, MultilingualGlyphRun, PageFrame, Paint, Path,
PathCommand, PathElement, Point, Rect, Stroke, StructureId, StructureNode, StructureRole,
TextDirection, TextScript, Transform,
};
fn page_with(elements: Vec<PositionedElement>) -> PageFrame {
PageFrame::new(1, 612.0, 792.0, elements)
}
fn content_for(elements: Vec<PositionedElement>) -> String {
let page = page_with(elements);
let mut next_ref = 100;
let page = Arc::new(page);
let alpha_states = AlphaStates::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let gradients = GradientRegistry::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
String::from_utf8(build_page_content(
0,
&page,
PageContentResources {
prepared_fonts: &BTreeMap::new(),
font_refs: &BTreeMap::new(),
image_map: &HashMap::new(),
alpha_states: &alpha_states,
gradients: &gradients,
structure: None,
},
))
.expect("PDF content operators are ASCII")
}
fn path_element(
fill_rule: FillRule,
fill: Option<Paint>,
stroke: Option<Stroke>,
) -> PositionedElement {
PositionedElement::Path(PathElement {
path: Path {
commands: vec![
PathCommand::MoveTo(Point { x: 10.0, y: 20.0 }),
PathCommand::LineTo(Point { x: 30.0, y: 40.0 }),
PathCommand::CurveTo {
c1: Point { x: 50.0, y: 60.0 },
c2: Point { x: 70.0, y: 80.0 },
to: Point { x: 90.0, y: 100.0 },
},
PathCommand::Close,
],
fill_rule,
},
fill,
stroke,
})
}
#[test]
fn rtl_pdf_paints_visual_order_but_maps_search_text_logically() {
let font_id = FontId(9);
let run = MultilingualGlyphRun {
origin: Point { x: 10.0, y: 20.0 },
font_id,
font_size: 12.0,
glyph_ids: vec![2, 1],
x_advances: vec![6.0, 6.0],
y_advances: vec![0.0, 0.0],
x_offsets: vec![0.0, 0.0],
y_offsets: vec![0.0, 0.0],
clusters: vec![GlyphCluster {
glyph_start: 0,
glyph_end: 2,
char_start: 0,
char_end: 2,
}],
logical_text: "אב".to_owned(),
logical_index: 0,
source: None,
script: TextScript::Hebrew,
language: Some("he".to_owned()),
direction: TextDirection::RightToLeft,
bidi_level: 1,
color: Color::BLACK,
bold: false,
italic: false,
field_kind: None,
note: None,
};
assert!(run.is_valid());
let mut invalid = run.clone();
invalid.x_offsets.clear();
assert!(!invalid.is_valid());
assert_eq!(run.glyph_ids, [2, 1]);
let mut remapper = subsetter::GlyphRemapper::new();
remapper.remap(1);
remapper.remap(2);
let prepared = PreparedFont {
font_data: FontData {
id: font_id,
family: "Test".to_owned(),
data: Vec::new().into(),
face_index: 0,
bold: false,
italic: false,
},
subset_bytes: Vec::new(),
remapper,
cmap_bytes: Vec::new(),
widths: vec![(1, 500.0), (2, 500.0)],
};
let page = Arc::new(page_with(vec![PositionedElement::MultilingualText(run)]));
let prepared_fonts = BTreeMap::from([(font_id, prepared)]);
let font_refs = BTreeMap::from([(
font_id,
(
Ref::new(1),
Ref::new(2),
Ref::new(3),
Ref::new(4),
Ref::new(5),
),
)]);
let mut next_ref = 100;
let alpha_states = AlphaStates::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let gradients = GradientRegistry::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let content = String::from_utf8(build_page_content(
0,
&page,
PageContentResources {
prepared_fonts: &prepared_fonts,
font_refs: &font_refs,
image_map: &HashMap::new(),
alpha_states: &alpha_states,
gradients: &gradients,
structure: None,
},
))
.expect("PDF content operators are ASCII");
assert!(content.contains("/ActualText <FEFF05D005D1>"), "{content}");
assert!(content.contains("/Span <<"), "{content}");
assert!(content.contains("1 0 0 -1 10 20 Tm"), "{content}");
assert!(content.contains("1 0 0 -1 16 20 Tm"), "{content}");
}
fn solid_stroke() -> Stroke {
Stroke {
paint: Paint::Solid(Color::BLACK),
width: 2.0,
cap: LineCap::Round,
join: LineJoin::Bevel,
dash: Some(vec![3.0, 4.0]),
}
}
fn has_operator(content: &str, operator: &str) -> bool {
content.lines().any(|line| line == operator)
}
#[test]
fn path_fill_only_emits_f() {
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Solid(Color::BLACK)),
None,
)]);
assert!(has_operator(&content, "f"), "{content}");
}
#[test]
fn path_stroke_only_emits_s() {
let content = content_for(vec![path_element(
FillRule::NonZero,
None,
Some(solid_stroke()),
)]);
assert!(has_operator(&content, "S"), "{content}");
}
#[test]
fn path_fill_and_stroke_emit_b() {
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Solid(Color::BLACK)),
Some(solid_stroke()),
)]);
assert!(has_operator(&content, "B"), "{content}");
assert_eq!(content.lines().filter(|line| *line == "q").count(), 2);
assert_eq!(content.lines().filter(|line| *line == "Q").count(), 2);
}
#[test]
fn even_odd_paths_use_starred_operators() {
let fill = content_for(vec![path_element(
FillRule::EvenOdd,
Some(Paint::Solid(Color::BLACK)),
None,
)]);
let both = content_for(vec![path_element(
FillRule::EvenOdd,
Some(Paint::Solid(Color::BLACK)),
Some(solid_stroke()),
)]);
assert!(has_operator(&fill, "f*"), "{fill}");
assert!(has_operator(&both, "B*"), "{both}");
}
#[test]
fn path_geometry_preserves_command_order() {
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Solid(Color::BLACK)),
None,
)]);
assert!(
content.contains("10 20 m\n30 40 l\n50 60 70 80 90 100 c\nh\nf"),
"{content}"
);
}
#[test]
fn stroke_state_maps_cap_join_miter_and_dash() {
let content = content_for(vec![path_element(
FillRule::NonZero,
None,
Some(solid_stroke()),
)]);
assert!(content.contains("2 w"), "{content}");
assert!(content.contains("1 J"), "{content}");
assert!(content.contains("2 j"), "{content}");
assert!(content.contains("10 M"), "{content}");
assert!(content.contains("[3 4] 0 d"), "{content}");
}
fn gradient_stops() -> Vec<GradientStop> {
vec![
GradientStop {
offset: 0.0,
color: Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
},
GradientStop {
offset: 1.0,
color: Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
},
},
]
}
fn linear_gradient() -> Paint {
Paint::Linear {
start: Point { x: 10.0, y: 20.0 },
end: Point { x: 90.0, y: 20.0 },
stops: gradient_stops(),
extend: (true, false),
}
}
#[test]
fn linear_gradient_writes_pattern_shading_and_stitching_resources() {
let pdf = pdf_for(vec![path_element(
FillRule::NonZero,
Some(linear_gradient()),
None,
)]);
assert!(pdf.contains("/PatternType 2"), "{pdf}");
assert!(pdf.contains("/ShadingType 2"), "{pdf}");
assert!(pdf.contains("/FunctionType 3"), "{pdf}");
assert!(pdf.contains("/FunctionType 2"), "{pdf}");
}
#[test]
fn radial_gradient_writes_type_three_shading() {
let pdf = pdf_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Radial {
center: Point { x: 50.0, y: 50.0 },
radius: 40.0,
focal: Point { x: 40.0, y: 45.0 },
stops: gradient_stops(),
extend: (false, true),
}),
None,
)]);
assert!(pdf.contains("/ShadingType 3"), "{pdf}");
assert!(pdf.contains("/Coords [40 45 0 50 50 40]"), "{pdf}");
assert!(pdf.contains("/Extend [false true]"), "{pdf}");
}
#[test]
fn gradient_stops_are_sorted_deduplicated_and_clamped() {
let stops = vec![
GradientStop {
offset: 2.0,
color: Color::WHITE,
},
GradientStop {
offset: -1.0,
color: Color::BLACK,
},
GradientStop {
offset: 0.5,
color: Color::BLACK,
},
GradientStop {
offset: 0.5,
color: Color::WHITE,
},
];
let normalized = normalize_gradient_stops(&stops).expect("non-empty gradient");
assert_eq!(
normalized
.iter()
.map(|stop| stop.offset)
.collect::<Vec<_>>(),
vec![0.0, 0.5, 1.0]
);
assert_eq!(normalized[1].color, Color::WHITE);
let pdf = pdf_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Linear {
start: Point { x: 0.0, y: 0.0 },
end: Point { x: 100.0, y: 0.0 },
stops,
extend: (false, false),
}),
None,
)]);
assert!(pdf.contains("/Domain [0 1]"), "{pdf}");
assert!(pdf.contains("/Bounds [0.5]"), "{pdf}");
assert!(pdf.contains("/Encode [0 1 0 1]"), "{pdf}");
}
#[test]
fn partial_range_gradient_holds_endpoint_colours_over_full_axis() {
let red = Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
let blue = Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
};
let pdf = pdf_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Linear {
start: Point { x: 0.0, y: 0.0 },
end: Point { x: 100.0, y: 0.0 },
stops: vec![
GradientStop {
offset: 0.25,
color: red,
},
GradientStop {
offset: 0.75,
color: blue,
},
],
extend: (false, false),
}),
None,
)]);
assert!(pdf.contains("/Domain [0 1]"), "{pdf}");
assert!(pdf.contains("/Bounds [0.25 0.75]"), "{pdf}");
assert!(pdf.contains("/Encode [0 1 0 1 0 1]"), "{pdf}");
assert_eq!(pdf.matches("/FunctionType 2").count(), 3, "{pdf}");
assert!(pdf.contains("/C0 [1 0 0]\n /C1 [1 0 0]"), "{pdf}");
assert!(pdf.contains("/C0 [0 0 1]\n /C1 [0 0 1]"), "{pdf}");
}
#[test]
fn gradient_fill_and_solid_stroke_both_render() {
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(linear_gradient()),
Some(solid_stroke()),
)]);
assert!(content.contains("/Pattern cs"), "{content}");
assert!(has_operator(&content, "f"), "{content}");
assert!(has_operator(&content, "S"), "{content}");
}
#[test]
fn gradient_stroke_uses_pattern_stroke_operators() {
let content = content_for(vec![path_element(
FillRule::NonZero,
None,
Some(Stroke {
paint: linear_gradient(),
..solid_stroke()
}),
)]);
assert!(content.contains("/Pattern CS"), "{content}");
assert!(content.contains("/P0 SCN"), "{content}");
assert!(has_operator(&content, "S"), "{content}");
}
#[test]
fn page_patterns_include_only_gradients_used_on_that_page() {
let first = page_with(vec![path_element(
FillRule::NonZero,
Some(linear_gradient()),
None,
)]);
let second = PageFrame::new(
2,
612.0,
792.0,
vec![path_element(
FillRule::NonZero,
Some(Paint::Radial {
center: Point { x: 50.0, y: 50.0 },
radius: 40.0,
focal: Point { x: 50.0, y: 50.0 },
stops: gradient_stops(),
extend: (false, false),
}),
None,
)],
);
let layout = LayoutResult::new(
vec![first.into(), second.into()],
Vec::new(),
None,
Vec::new(),
);
let pdf = String::from_utf8_lossy(&write_pdf(&layout)).into_owned();
let pattern_resources: Vec<_> = pdf
.split("/Pattern <<")
.skip(1)
.map(|tail| tail.split(">>").next().expect("pattern dictionary end"))
.collect();
assert_eq!(pattern_resources.len(), 2, "{pdf}");
assert!(pattern_resources[0].contains("/P0"), "{pdf}");
assert!(!pattern_resources[0].contains("/P1"), "{pdf}");
assert!(pattern_resources[1].contains("/P1"), "{pdf}");
assert!(!pattern_resources[1].contains("/P0"), "{pdf}");
}
#[test]
fn rotated_linear_gradient_renders_with_its_axis_rotated() {
let gradient_path = PositionedElement::Path(PathElement {
path: Path::rect(Rect {
x: 20.0,
y: 40.0,
width: 80.0,
height: 40.0,
}),
fill: Some(Paint::Linear {
start: Point { x: 20.0, y: 60.0 },
end: Point { x: 100.0, y: 60.0 },
stops: gradient_stops(),
extend: (false, false),
}),
stroke: None,
});
let rotated = group(
Transform::rotate_about(90.0, 60.0, 60.0),
vec![gradient_path],
);
let pdf = write_pdf(&LayoutResult::new(
vec![PageFrame::new(1, 120.0, 120.0, vec![rotated]).into()],
Vec::new(),
None,
Vec::new(),
));
let base = std::env::temp_dir().join(format!("oxml-pdf-f043-{}", std::process::id()));
let pdf_path = base.with_extension("pdf");
let png_path = base.with_extension("png");
std::fs::write(&pdf_path, pdf).expect("write PDF fixture");
let version = std::process::Command::new("pdftoppm")
.arg("-v")
.output()
.expect("pdftoppm is required for the F-043 gate");
let version = String::from_utf8_lossy(&version.stderr);
assert!(version.starts_with("pdftoppm version 26.01.0"), "{version}");
let status = std::process::Command::new("pdftoppm")
.args(["-png", "-r", "72", "-f", "1", "-singlefile"])
.arg(&pdf_path)
.arg(&base)
.status()
.expect("rasterise F-043 fixture");
assert!(status.success());
let pixmap = tiny_skia::Pixmap::load_png(&png_path).expect("decode raster fixture");
let top = pixmap.pixel(60, 30).expect("top sample");
let bottom = pixmap.pixel(60, 90).expect("bottom sample");
assert_eq!(
(top.red(), top.green(), top.blue(), top.alpha()),
(223, 0, 32, 255)
);
assert_eq!(
(bottom.red(), bottom.green(), bottom.blue(), bottom.alpha()),
(32, 0, 223, 255)
);
let _ = std::fs::remove_file(pdf_path);
let _ = std::fs::remove_file(png_path);
}
fn alpha_rect(alpha: f64) -> PositionedElement {
PositionedElement::FilledRect {
rect: Rect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
},
color: Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: alpha,
},
}
}
fn pdf_for(elements: Vec<PositionedElement>) -> String {
let layout = LayoutResult::new(
vec![page_with(elements).into()],
Vec::new(),
None,
Vec::new(),
);
String::from_utf8_lossy(&write_pdf(&layout)).into_owned()
}
fn tagged_pdf(elements: Vec<PositionedElement>, nodes: Vec<StructureNode>) -> String {
String::from_utf8_lossy(&tagged_pdf_bytes(elements, nodes)).into_owned()
}
fn tagged_pdf_bytes(elements: Vec<PositionedElement>, nodes: Vec<StructureNode>) -> Vec<u8> {
let mut layout = LayoutResult::new(
vec![page_with(elements).into()],
Vec::new(),
None,
Vec::new(),
);
layout.structure = Some(DocumentStructure {
root: StructureId::new(1).expect("non-zero root"),
nodes,
});
write_pdf(&layout)
}
fn structure_node(
value: u32,
role: StructureRole,
children: Vec<StructureId>,
) -> StructureNode {
StructureNode {
id: StructureId::new(value).expect("non-zero structure id"),
role,
children,
alternate_text: None,
}
}
#[test]
fn tagged_pdf_preserves_heading_and_nested_list_structure() {
let headings = (2..=7)
.map(|value| StructureId::new(value).unwrap())
.collect::<Vec<_>>();
let list = StructureId::new(8).unwrap();
let item = StructureId::new(9).unwrap();
let paragraph = StructureId::new(10).unwrap();
let nested_list = StructureId::new(11).unwrap();
let nested_item = StructureId::new(12).unwrap();
let nested_paragraph = StructureId::new(13).unwrap();
let mut elements = headings
.iter()
.copied()
.map(|structure| PositionedElement::MarkedContent {
structure: Some(structure),
children: vec![alpha_rect(1.0)],
})
.collect::<Vec<_>>();
elements.extend([paragraph, nested_paragraph].map(|structure| {
PositionedElement::MarkedContent {
structure: Some(structure),
children: vec![alpha_rect(1.0)],
}
}));
let mut root_children = headings.clone();
root_children.push(list);
let pdf = tagged_pdf(
elements,
vec![
structure_node(1, StructureRole::Document, root_children),
structure_node(2, StructureRole::Heading(1), Vec::new()),
structure_node(3, StructureRole::Heading(2), Vec::new()),
structure_node(4, StructureRole::Heading(3), Vec::new()),
structure_node(5, StructureRole::Heading(4), Vec::new()),
structure_node(6, StructureRole::Heading(5), Vec::new()),
structure_node(7, StructureRole::Heading(6), Vec::new()),
structure_node(8, StructureRole::List, vec![item]),
structure_node(9, StructureRole::ListItem, vec![paragraph, nested_list]),
structure_node(10, StructureRole::Paragraph, Vec::new()),
structure_node(11, StructureRole::List, vec![nested_item]),
structure_node(12, StructureRole::ListItem, vec![nested_paragraph]),
structure_node(13, StructureRole::Paragraph, Vec::new()),
],
);
assert!(pdf.contains("/StructTreeRoot"), "{pdf}");
assert!(pdf.contains("/ParentTree"), "{pdf}");
for level in 1..=6 {
assert!(pdf.contains(&format!("/S /H{level}")), "{pdf}");
}
assert_eq!(pdf.matches("/S /L\n").count(), 2, "{pdf}");
assert!(pdf.contains("/S /LI"), "{pdf}");
assert!(pdf.contains("/StructParents 0"), "{pdf}");
}
#[test]
fn tagged_pdf_marks_table_headers_and_cells() {
let table = StructureId::new(2).unwrap();
let row = StructureId::new(3).unwrap();
let header = StructureId::new(4).unwrap();
let data = StructureId::new(5).unwrap();
let pdf = tagged_pdf(
vec![
PositionedElement::MarkedContent {
structure: Some(header),
children: vec![alpha_rect(1.0)],
},
PositionedElement::MarkedContent {
structure: Some(data),
children: vec![alpha_rect(1.0)],
},
],
vec![
structure_node(1, StructureRole::Document, vec![table]),
structure_node(2, StructureRole::Table, vec![row]),
structure_node(3, StructureRole::TableRow, vec![header, data]),
structure_node(4, StructureRole::TableHeaderCell, Vec::new()),
structure_node(5, StructureRole::TableCell, Vec::new()),
],
);
assert!(pdf.contains("/S /Table"), "{pdf}");
assert!(pdf.contains("/S /TR"), "{pdf}");
assert!(pdf.contains("/S /TH"), "{pdf}");
assert!(pdf.contains("/S /TD"), "{pdf}");
assert!(pdf.contains("/O /Table"), "{pdf}");
assert!(pdf.contains("/Scope /Column"), "{pdf}");
}
#[test]
fn tagged_pdf_carries_figure_alternate_text() {
let figure = StructureId::new(2).unwrap();
let mut figure_node = structure_node(2, StructureRole::Figure, Vec::new());
figure_node.alternate_text = Some("A chart of quarterly revenue".to_owned());
let pdf = tagged_pdf(
vec![
PositionedElement::MarkedContent {
structure: Some(figure),
children: vec![alpha_rect(1.0)],
},
PositionedElement::MarkedContent {
structure: None,
children: vec![alpha_rect(1.0)],
},
],
vec![
structure_node(1, StructureRole::Document, vec![figure]),
figure_node,
],
);
assert!(pdf.contains("/S /Figure"), "{pdf}");
assert!(pdf.contains("A chart of quarterly revenue"), "{pdf}");
assert!(pdf.contains("/MarkInfo <<\n /Marked true"), "{pdf}");
assert!(pdf.contains("/Lang (und)"), "{pdf}");
assert!(pdf.contains("pdfuaid:part"), "{pdf}");
let artifact = content_for(vec![PositionedElement::MarkedContent {
structure: None,
children: vec![alpha_rect(1.0)],
}]);
assert!(artifact.contains("/Artifact BMC"), "{artifact}");
}
#[test]
fn tagged_pdf_with_notdef_does_not_claim_pdfua() {
let paragraph = StructureId::new(2).unwrap();
let text = PositionedElement::Text(GlyphRun {
origin: Point { x: 10.0, y: 10.0 },
font_id: FontId(1),
font_size: 12.0,
glyph_ids: vec![0],
advances: vec![6.0],
text: "missing".to_owned(),
source: None,
color: Color::BLACK,
bold: false,
italic: false,
field_kind: None,
note: None,
});
let pdf = tagged_pdf(
vec![PositionedElement::MarkedContent {
structure: Some(paragraph),
children: vec![text],
}],
vec![
structure_node(1, StructureRole::Document, vec![paragraph]),
structure_node(2, StructureRole::Paragraph, Vec::new()),
],
);
assert!(pdf.contains("/StructTreeRoot"), "{pdf}");
assert!(!pdf.contains("pdfuaid:part"), "{pdf}");
assert!(!pdf.contains("/Type /Metadata"), "{pdf}");
}
#[test]
fn rejected_structure_graph_emits_no_orphan_mcid() {
let root = StructureId::new(1).unwrap();
let paragraph = StructureId::new(2).unwrap();
let elements = vec![PositionedElement::MarkedContent {
structure: Some(paragraph),
children: vec![alpha_rect(1.0)],
}];
let pdf = tagged_pdf(
elements.clone(),
vec![
structure_node(1, StructureRole::Document, vec![paragraph]),
structure_node(2, StructureRole::Paragraph, vec![root]),
],
);
let content = content_for(elements);
assert!(!pdf.contains("/StructTreeRoot"), "{pdf}");
assert!(!content.contains("/MCID"), "{content}");
assert!(!content.contains("BDC"), "{content}");
}
#[test]
fn untagged_metadata_without_title_keeps_the_existing_info_shape() {
let mut layout = LayoutResult::new(
vec![page_with(vec![alpha_rect(1.0)]).into()],
Vec::new(),
Some(oxml_layout::DocumentMetadata {
author: Some("An author".to_owned()),
..oxml_layout::DocumentMetadata::default()
}),
Vec::new(),
);
layout.structure = None;
let pdf = String::from_utf8_lossy(&write_pdf(&layout)).into_owned();
assert!(pdf.contains("/Author (An author)"), "{pdf}");
assert!(pdf.contains("/Producer (rdocx-pdf)"), "{pdf}");
assert!(!pdf.contains("/Title"), "{pdf}");
assert!(!pdf.contains("/StructTreeRoot"), "{pdf}");
}
#[test]
#[ignore = "requires pinned veraPDF 1.30.2"]
fn pdfa_2b_and_3b_pass_verapdf() {
let verapdf =
std::env::var_os("VERAPDF_BIN").expect("VERAPDF_BIN must point to veraPDF 1.30.2");
let version = std::process::Command::new(&verapdf)
.arg("--version")
.output()
.expect("veraPDF version command runs");
let version_text = String::from_utf8_lossy(&version.stdout);
assert!(version.status.success(), "{version_text}");
assert!(version_text.contains("veraPDF 1.30.2"), "{version_text}");
let paragraph = StructureId::new(2).expect("non-zero paragraph");
let (family, font_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
let face = ttf_parser::Face::parse(font_bytes, 0).expect("parse bundled oracle font");
let glyph_id = face
.glyph_index('A')
.expect("bundled oracle font contains A")
.0;
let mut layout = LayoutResult::new(
vec![
page_with(vec![PositionedElement::MarkedContent {
structure: Some(paragraph),
children: vec![PositionedElement::Text(GlyphRun {
origin: Point { x: 72.0, y: 72.0 },
font_id: FontId(1),
font_size: 12.0,
glyph_ids: vec![glyph_id],
advances: vec![8.0],
text: "A".to_owned(),
source: None,
color: Color::BLACK,
bold: false,
italic: false,
field_kind: None,
note: None,
})],
}])
.into(),
],
vec![FontData {
id: FontId(1),
family: family.to_owned(),
data: Arc::from(font_bytes),
face_index: 0,
bold: false,
italic: false,
}],
None,
Vec::new(),
);
layout.structure = Some(DocumentStructure {
root: StructureId::new(1).expect("non-zero root"),
nodes: vec![
structure_node(1, StructureRole::Document, vec![paragraph]),
structure_node(2, StructureRole::Paragraph, Vec::new()),
],
});
for (profile, flavour) in [
(PdfConformance::PdfA2b, "2b"),
(PdfConformance::PdfA3b, "3b"),
] {
let pdf = crate::render_to_pdf_with_options(&layout, crate::PdfOptions::new(profile))
.expect("render archival oracle fixture");
let path = std::env::temp_dir().join(format!(
"rdocx-f174-verapdf-{flavour}-{}.pdf",
std::process::id()
));
std::fs::write(&path, pdf).expect("write deterministic oracle fixture");
for validation in [flavour, "ua1"] {
let report = std::process::Command::new(&verapdf)
.args(["--format", "text", "--flavour", validation, "--verbose"])
.arg(&path)
.output()
.expect("veraPDF command runs");
let stdout = String::from_utf8_lossy(&report.stdout);
let stderr = String::from_utf8_lossy(&report.stderr);
assert!(report.status.success(), "{stdout}\n{stderr}");
assert!(
stdout.contains("PASS") && stdout.contains(validation),
"{stdout}"
);
}
std::fs::remove_file(&path).expect("remove oracle fixture");
}
let link_layout = LayoutResult::new(
vec![
page_with(vec![PositionedElement::LinkAnnotation {
rect: Rect {
x: 72.0,
y: 72.0,
width: 72.0,
height: 12.0,
},
url: "https://example.com".to_owned(),
}])
.into(),
],
Vec::new(),
None,
Vec::new(),
);
for (profile, flavour) in [
(PdfConformance::PdfA2b, "2b"),
(PdfConformance::PdfA3b, "3b"),
] {
let pdf =
crate::render_to_pdf_with_options(&link_layout, crate::PdfOptions::new(profile))
.expect("render archival link fixture");
let path = std::env::temp_dir().join(format!(
"rdocx-f174-verapdf-link-{flavour}-{}.pdf",
std::process::id()
));
std::fs::write(&path, pdf).expect("write archival link fixture");
let report = std::process::Command::new(&verapdf)
.args(["--format", "text", "--flavour", flavour, "--verbose"])
.arg(&path)
.output()
.expect("veraPDF link command runs");
std::fs::remove_file(&path).expect("remove archival link fixture");
let stdout = String::from_utf8_lossy(&report.stdout);
let stderr = String::from_utf8_lossy(&report.stderr);
assert!(report.status.success(), "{stdout}\n{stderr}");
assert!(
stdout.contains("PASS") && stdout.contains(flavour),
"{stdout}"
);
}
}
#[test]
fn same_alpha_reuses_one_extgstate() {
let pdf = pdf_for(vec![alpha_rect(0.5), alpha_rect(0.5)]);
assert!(pdf.contains("/ExtGState"), "{pdf}");
assert_eq!(pdf.matches("/CA 0.5").count(), 1, "{pdf}");
assert_eq!(pdf.matches("/ca 0.5").count(), 1, "{pdf}");
}
#[test]
fn distinct_alpha_values_create_distinct_states() {
let pdf = pdf_for(vec![alpha_rect(0.5), alpha_rect(0.25)]);
assert_eq!(pdf.matches("/CA ").count(), 2, "{pdf}");
assert_eq!(pdf.matches("/ca ").count(), 2, "{pdf}");
}
#[test]
fn opaque_elements_emit_no_alpha_state() {
let pdf = pdf_for(vec![alpha_rect(1.0)]);
assert!(!pdf.contains("/ExtGState"), "{pdf}");
assert!(!pdf.contains("/CA "), "{pdf}");
assert!(!pdf.contains("/ca "), "{pdf}");
}
#[test]
fn alpha_state_sets_fill_and_stroke_constants() {
let content = content_for(vec![alpha_rect(0.5)]);
let pdf = pdf_for(vec![alpha_rect(0.5)]);
assert!(content.contains("/GS0 gs"), "{content}");
assert!(pdf.contains("/CA 0.5"), "{pdf}");
assert!(pdf.contains("/ca 0.5"), "{pdf}");
}
#[test]
fn solid_path_alpha_uses_shared_registry() {
let half_black = Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.5,
};
let path = path_element(
FillRule::NonZero,
Some(Paint::Solid(half_black)),
Some(Stroke {
paint: Paint::Solid(half_black),
..solid_stroke()
}),
);
let content = content_for(vec![alpha_rect(0.5), path]);
let pdf = pdf_for(vec![
alpha_rect(0.5),
path_element(
FillRule::NonZero,
Some(Paint::Solid(half_black)),
Some(Stroke {
paint: Paint::Solid(half_black),
..solid_stroke()
}),
),
]);
assert_eq!(pdf.matches("/CA 0.5").count(), 1, "{pdf}");
assert_eq!(content.matches("/GS0 gs").count(), 2, "{content}");
assert!(has_operator(&content, "B"), "{content}");
}
#[test]
fn different_fill_and_stroke_alpha_repeat_path_geometry() {
let fill = Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.25,
};
let stroke = Color { a: 0.5, ..fill };
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Solid(fill)),
Some(Stroke {
paint: Paint::Solid(stroke),
..solid_stroke()
}),
)]);
assert_eq!(content.matches("10 20 m").count(), 2, "{content}");
assert!(has_operator(&content, "f"), "{content}");
assert!(has_operator(&content, "S"), "{content}");
assert!(!has_operator(&content, "B"), "{content}");
}
#[test]
fn opaque_stroke_restores_after_transparent_fill() {
let fill = Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.25,
};
let content = content_for(vec![path_element(
FillRule::NonZero,
Some(Paint::Solid(fill)),
Some(Stroke {
paint: Paint::Solid(Color::BLACK),
..solid_stroke()
}),
)]);
assert_eq!(content.matches("/GS0 gs").count(), 1, "{content}");
assert_eq!(content.lines().filter(|line| *line == "q").count(), 4);
assert_eq!(content.lines().filter(|line| *line == "Q").count(), 4);
}
fn group(transform: Transform, children: Vec<PositionedElement>) -> PositionedElement {
PositionedElement::Group(GroupElement {
transform,
clip: None,
opacity: 1.0,
effects: Vec::new(),
children,
})
}
#[test]
fn three_deep_groups_balance_graphics_state() {
let content = content_for(vec![group(
Transform::IDENTITY,
vec![group(
Transform::IDENTITY,
vec![group(Transform::IDENTITY, vec![alpha_rect(1.0)])],
)],
)]);
assert_eq!(
content.lines().filter(|line| *line == "q").count(),
content.lines().filter(|line| *line == "Q").count(),
"{content}"
);
assert_eq!(content.lines().filter(|line| *line == "q").count(), 5);
}
#[test]
fn group_emits_transform_before_children() {
let transform = Transform {
a: 1.0,
b: 2.0,
c: 3.0,
d: 4.0,
e: 5.0,
f: 6.0,
};
let content = content_for(vec![group(transform, vec![alpha_rect(1.0)])]);
let matrix = content.find("1 2 3 4 5 6 cm").expect("group matrix");
let child = content.find("0 0 10 10 re").expect("child rectangle");
assert!(matrix < child, "{content}");
}
#[test]
fn group_clip_uses_declared_fill_rule() {
let mut nonzero = match group(Transform::IDENTITY, Vec::new()) {
PositionedElement::Group(group) => group,
_ => unreachable!(),
};
nonzero.clip = Some(Path::rect(Rect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
}));
let mut even_odd = nonzero.clone();
even_odd.clip.as_mut().expect("clip").fill_rule = FillRule::EvenOdd;
let content = content_for(vec![
PositionedElement::Group(nonzero),
PositionedElement::Group(even_odd),
]);
assert!(content.contains("W\nn"), "{content}");
assert!(content.contains("W*\nn"), "{content}");
}
#[test]
fn group_opacity_uses_registered_graphics_state() {
let mut transparent = match group(Transform::IDENTITY, vec![alpha_rect(1.0)]) {
PositionedElement::Group(group) => group,
_ => unreachable!(),
};
transparent.opacity = 0.5;
let content = content_for(vec![PositionedElement::Group(transparent)]);
let alpha = content.find("/GS0 gs").expect("group opacity state");
let child = content.find("0 0 10 10 re").expect("child rectangle");
assert!(alpha < child, "{content}");
}
#[test]
fn group_effects_do_not_change_pdf_output_yet() {
let plain = group(Transform::IDENTITY, vec![alpha_rect(1.0)]);
let mut with_effect = match plain.clone() {
PositionedElement::Group(group) => group,
_ => unreachable!(),
};
with_effect.effects.push(Effect::OuterShadow {
dx: 1.0,
dy: 2.0,
blur: 3.0,
color: Color::BLACK,
});
assert_eq!(
content_for(vec![plain]),
content_for(vec![PositionedElement::Group(with_effect)])
);
}
#[test]
fn grouped_text_is_included_in_font_subsetting() {
let font_id = FontId(42);
let text = PositionedElement::Text(GlyphRun {
origin: Point { x: 0.0, y: 0.0 },
font_id,
font_size: 12.0,
glyph_ids: vec![7],
advances: vec![6.0],
text: "A".to_owned(),
source: None,
color: Color::BLACK,
bold: false,
italic: false,
field_kind: None,
note: None,
});
let layout = LayoutResult::new(
vec![page_with(vec![group(Transform::IDENTITY, vec![text])]).into()],
Vec::new(),
None,
Vec::new(),
);
assert!(font::collect_glyph_usage(&layout).contains_key(&font_id));
}
#[test]
fn grouped_image_registers_and_uses_xobject() {
let png = tiny_skia::Pixmap::new(1, 1)
.expect("one-pixel pixmap")
.encode_png()
.expect("encode fixture");
let image = PositionedElement::Image {
rect: Rect {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
},
data: png,
content_type: "image/png".to_owned(),
media_id: MediaId(9),
};
let pdf = pdf_for(vec![group(Transform::IDENTITY, vec![image])]);
assert!(pdf.contains("/Subtype /Image"), "{pdf}");
assert!(pdf.contains("/Im0_0"), "{pdf}");
}
#[test]
fn grouped_link_emits_transformed_annotation() {
let transform = Transform {
e: 10.0,
f: 20.0,
..Transform::IDENTITY
};
let link = PositionedElement::LinkAnnotation {
rect: Rect {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
},
url: "https://example.com".to_owned(),
};
let pdf = pdf_for(vec![group(transform, vec![link])]);
assert!(pdf.contains("/Annots ["), "{pdf}");
assert!(pdf.contains("/Rect [11 766 14 770]"), "{pdf}");
}
#[test]
fn nested_leaf_ordinals_match_content_order() {
let png = tiny_skia::Pixmap::new(1, 1)
.expect("one-pixel pixmap")
.encode_png()
.expect("encode fixture");
let image = PositionedElement::Image {
rect: Rect {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
},
data: png,
content_type: "image/png".to_owned(),
media_id: MediaId(10),
};
let elements = vec![alpha_rect(1.0), group(Transform::IDENTITY, vec![image])];
let pdf = pdf_for(elements.clone());
let page = page_with(elements);
let image_map = HashMap::from([((0, 1), 0)]);
let mut next_ref = 100;
let page = Arc::new(page);
let alpha_states = AlphaStates::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let gradients = GradientRegistry::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let content = String::from_utf8(build_page_content(
0,
&page,
PageContentResources {
prepared_fonts: &BTreeMap::new(),
font_refs: &BTreeMap::new(),
image_map: &image_map,
alpha_states: &alpha_states,
gradients: &gradients,
structure: None,
},
))
.expect("PDF content operators are ASCII");
assert!(pdf.contains("/Im0_1"), "{pdf}");
assert!(content.contains("/Im0_1 Do"), "{content}");
}
#[test]
fn top_level_collection_output_remains_stable() {
let link = PositionedElement::LinkAnnotation {
rect: Rect {
x: 72.0,
y: 100.0,
width: 100.0,
height: 15.0,
},
url: "https://example.com".to_owned(),
};
let pdf = pdf_for(vec![link]);
assert!(pdf.contains("/Rect [72 677 172 692]"), "{pdf}");
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "{pdf}");
}
#[test]
fn page_content_uses_one_global_flip() {
let content = content_for(Vec::new());
assert_eq!(content.matches("1 0 0 -1 0 792 cm").count(), 1);
assert!(content.starts_with("q\n1 0 0 -1 0 792 cm\n"));
assert!(content.ends_with('Q'));
}
#[test]
fn text_and_images_cancel_the_outer_flip() {
let font_id = FontId(7);
let mut remapper = subsetter::GlyphRemapper::new();
remapper.remap(1);
let prepared = PreparedFont {
font_data: FontData {
id: font_id,
family: "Test".to_owned(),
data: Vec::new().into(),
face_index: 0,
bold: false,
italic: false,
},
subset_bytes: Vec::new(),
remapper,
cmap_bytes: Vec::new(),
widths: vec![(0, 500.0)],
};
let elements = vec![
PositionedElement::Text(GlyphRun {
origin: Point { x: 30.0, y: 40.0 },
font_id,
font_size: 12.0,
glyph_ids: vec![1],
advances: vec![6.0],
text: "A".to_owned(),
source: None,
color: Color::BLACK,
bold: false,
italic: false,
field_kind: None,
note: None,
}),
PositionedElement::Image {
rect: Rect {
x: 50.0,
y: 60.0,
width: 70.0,
height: 80.0,
},
data: vec![1],
content_type: "image/png".to_owned(),
media_id: MediaId(1),
},
];
let page = page_with(elements);
let prepared_fonts = BTreeMap::from([(font_id, prepared)]);
let font_refs = BTreeMap::from([(
font_id,
(
Ref::new(1),
Ref::new(2),
Ref::new(3),
Ref::new(4),
Ref::new(5),
),
)]);
let image_map = HashMap::from([((0, 1), 0)]);
let mut next_ref = 100;
let page = Arc::new(page);
let alpha_states = AlphaStates::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let gradients = GradientRegistry::new(std::slice::from_ref(&page), &mut || {
let reference = Ref::new(next_ref);
next_ref += 1;
reference
});
let content = String::from_utf8(build_page_content(
0,
&page,
PageContentResources {
prepared_fonts: &prepared_fonts,
font_refs: &font_refs,
image_map: &image_map,
alpha_states: &alpha_states,
gradients: &gradients,
structure: None,
},
))
.expect("PDF content operators are ASCII");
assert!(content.contains("1 0 0 -1 30 40 Tm"));
assert!(content.contains("70 0 0 -80 50 140 cm"));
}
#[test]
fn lines_and_rectangles_use_top_left_coordinates() {
let content = content_for(vec![
PositionedElement::Line {
start: Point { x: 72.0, y: 73.0 },
end: Point { x: 540.0, y: 74.0 },
width: 1.0,
color: Color::BLACK,
dash_pattern: None,
},
PositionedElement::FilledRect {
rect: Rect {
x: 75.0,
y: 101.0,
width: 468.0,
height: 20.0,
},
color: Color::WHITE,
},
]);
assert!(content.contains("72 73 m"));
assert!(content.contains("540 74 l"));
assert!(content.contains("75 101 468 20 re"));
}
#[test]
fn annotations_remain_outside_the_content_transform() {
let layout = LayoutResult::new(
vec![
page_with(vec![PositionedElement::LinkAnnotation {
rect: Rect {
x: 72.0,
y: 100.0,
width: 100.0,
height: 15.0,
},
url: "https://example.com".to_owned(),
}])
.into(),
],
Vec::new(),
None,
Vec::new(),
);
let pdf = String::from_utf8_lossy(&write_pdf(&layout)).into_owned();
assert!(pdf.contains("/Rect [72 677 172 692]"), "{pdf}");
}
}