use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdf_writer::types::{ActionType, AnnotationType, LineCapStyle, TextRenderingMode};
use pdf_writer::{Content, Finish, Name, Pdf, Rect as PdfRect, Ref, TextStr};
use crate::fonts::{Font, FontCollection};
use crate::html::NodeId;
use crate::img::resolve_against_base_href;
use crate::layout::{
resolve_border, shape_standalone_line, EdgeSizes, EmphasisMark, FragmentPosition, LaidOutBox,
LaidOutContent, LaidOutTableRow, Layout, LineBox, Page, PageSettings, Rect, TextRun,
};
use crate::sink::Sink;
use crate::style::{
compose_transform, resolve_margin_box_content, resolve_page_rules, BackgroundRepeat,
BackgroundSize, BorderCollapse, BorderStyle, Color, ComputedBoxShadow, ComputedStyle,
CornerRadius, EmphasisPosition, EmphasisShape, EmphasisStyle, EmptyCells, Length,
LengthPercentage, LengthPercentageOrAuto, MarginBoxArea, ObjectFit, PageRule, Position,
PropertyDeclaration, RgbaColor,
};
use super::color_font::{write_color_fonts, FontPlan};
use super::font::{deflate, embed_font, FontUsage};
use super::img::{embed_image, ids_for_image, image_resource_name, ImageIds, PreparedImage};
use super::options::{current_datetime, producer_string, DocumentMetadata, PdfOutputOptions};
pub(super) struct RenderTarget<'a> {
content: &'a mut Content,
grayscale: bool,
}
impl<'a> RenderTarget<'a> {
pub(super) fn new(content: &'a mut Content, grayscale: bool) -> Self {
Self { content, grayscale }
}
pub(super) fn wrap<'b>(&self, content: &'b mut Content) -> RenderTarget<'b> {
RenderTarget::new(content, self.grayscale)
}
fn map(&self, r: f32, g: f32, b: f32) -> (f32, f32, f32) {
if !self.grayscale {
return (r, g, b);
}
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
(y, y, y)
}
pub(super) fn set_fill_rgb(&mut self, r: f32, g: f32, b: f32) -> &mut Content {
let (r, g, b) = self.map(r, g, b);
self.content.set_fill_rgb(r, g, b)
}
pub(super) fn set_stroke_rgb(&mut self, r: f32, g: f32, b: f32) -> &mut Content {
let (r, g, b) = self.map(r, g, b);
self.content.set_stroke_rgb(r, g, b)
}
}
impl std::ops::Deref for RenderTarget<'_> {
type Target = Content;
fn deref(&self) -> &Self::Target {
self.content
}
}
impl std::ops::DerefMut for RenderTarget<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.content
}
}
pub fn encode_pdf(
pages: &[Page],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
fonts: &FontCollection,
settings: &PageSettings,
) -> Vec<u8> {
encode_pdf_with_anchors(
pages,
styles,
background_images,
fonts,
settings,
&LinkSettings::default(),
)
}
pub fn encode_pdf_with_anchors(
pages: &[Page],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
fonts: &FontCollection,
settings: &PageSettings,
links: &LinkSettings,
) -> Vec<u8> {
encode_pdf_with_options(
pages,
styles,
background_images,
fonts,
settings,
links,
&PdfOutputOptions::default(),
)
}
pub fn encode_pdf_with_options(
pages: &[Page],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
fonts: &FontCollection,
settings: &PageSettings,
links: &LinkSettings,
output: &PdfOutputOptions,
) -> Vec<u8> {
let mut pdf = Pdf::new();
let mut alloc = RefAllocator::default();
let catalog_id = alloc.next();
let pages_tree_id = alloc.next();
let alpha_gs_ids: Vec<Ref> = (0..=ALPHA_STEPS).map(|_| alloc.next()).collect();
let alpha_gs_names: Vec<String> = (0..=ALPHA_STEPS).map(alpha_gs_resource_name).collect();
for (step, &id) in alpha_gs_ids.iter().enumerate() {
let a = step as f32 / ALPHA_STEPS as f32;
pdf.ext_graphics(id).non_stroking_alpha(a).stroking_alpha(a);
}
let mut usages: Vec<FontUsage> = (0..fonts.len()).map(|_| FontUsage::default()).collect();
for page in pages {
for b in &page.boxes {
collect_usage(b, fonts, &mut usages);
}
}
let color_font_counts: Vec<usize> = usages.iter().map(|u| u.color_font_count()).collect();
let plan = FontPlan::new(fonts, &mut alloc, &color_font_counts);
let remaps: Vec<HashMap<u16, u16>> = fonts
.fonts()
.iter()
.enumerate()
.zip(usages.iter())
.map(|((index, font), usage)| match plan.simple(index) {
Some(simple) => embed_font(&mut pdf, font, simple.ids, usage, output.compress)
.into_iter()
.collect(),
None => HashMap::new(),
})
.collect();
for (_, chunk) in write_color_fonts(fonts, &plan, &usages, &mut alloc, output) {
pdf.extend(&chunk);
}
let text_fonts = TextFonts {
any_color: usages.iter().any(|u| u.color_font_count() > 0),
remaps: Some(&remaps),
plan: &plan,
usages: &usages,
};
let mut image_ids: HashMap<usize, ImageIds> = HashMap::new();
let mut failed_svg_ids: HashSet<usize> = HashSet::new();
let mut page_ids = Vec::with_capacity(pages.len());
let mut destinations: Vec<(String, Ref, f32, f32)> = Vec::new();
let mut link_annotations: Vec<(Ref, LinkArea)> = Vec::new();
for page in pages {
let page_id = alloc.next();
let content_id = alloc.next();
page_ids.push(page_id);
let mut used_images = Vec::new();
for b in &page.boxes {
collect_image_uses(b, background_images, &mut used_images);
}
let mut page_image_refs = Vec::with_capacity(used_images.len());
for image in &used_images {
let Some((ids, is_new)) =
ids_for_image(&mut alloc, &mut image_ids, &mut failed_svg_ids, image)
else {
continue;
};
if is_new {
embed_image(&mut pdf, image, ids, output.grayscale);
}
page_image_refs.push(ids.root);
}
let mut opacity_nodes = Vec::new();
for b in &page.boxes {
collect_opacity_uses(b, styles, &mut opacity_nodes);
}
let opacity_form_ids: HashMap<NodeId, Ref> =
opacity_nodes.iter().map(|&n| (n, alloc.next())).collect();
let mut pending_forms: Vec<(Ref, Vec<u8>)> = Vec::new();
let mut content = Content::new();
content.transform([output.scale, 0.0, 0.0, output.scale, 0.0, 0.0]);
let mut target = RenderTarget::new(&mut content, output.grayscale);
for b in &page.boxes {
render_box(
&mut target,
b,
styles,
fonts,
settings,
&text_fonts,
&image_ids,
background_images,
&alpha_gs_names,
&opacity_form_ids,
&mut pending_forms,
);
}
let content_bytes = content.finish();
let mut page_links = Vec::new();
let mut page_anchors = Vec::new();
for b in &page.boxes {
collect_link_areas(b, settings, &mut page_links);
collect_anchor_positions(b, &links.anchor_names, settings, &mut page_anchors);
}
links.retain_enabled(&mut page_links);
for (name, x, y) in page_anchors {
if !destinations.iter().any(|(existing, ..)| *existing == name) {
destinations.push((name, page_id, x, y));
}
}
let page_annotation_ids: Vec<Ref> = page_links
.into_iter()
.map(|area| {
let id = alloc.next();
link_annotations.push((id, area));
id
})
.collect();
let form_refs: Vec<Ref> = pending_forms.iter().map(|(id, _)| *id).collect();
let mut p = pdf.page(page_id);
p.parent(pages_tree_id);
p.media_box(PdfRect::new(
0.0,
0.0,
output.to_pt(settings.size.width),
output.to_pt(settings.size.height),
));
p.contents(content_id);
if !page_annotation_ids.is_empty() {
p.annotations(page_annotation_ids.iter().copied());
}
write_resources(
p.resources(),
&plan,
&page_image_refs,
&form_refs,
&alpha_gs_names,
&alpha_gs_ids,
);
p.finish();
let stream_bytes = if output.compress {
deflate(&content_bytes)
} else {
content_bytes.to_vec()
};
let mut content_stream = pdf.stream(content_id, &stream_bytes);
if output.compress {
content_stream.filter(pdf_writer::Filter::FlateDecode);
}
content_stream.finish();
for (form_ref, bytes) in &pending_forms {
let mut form = pdf.form_xobject(*form_ref, bytes);
form.bbox(PdfRect::new(
0.0,
0.0,
settings.size.width,
settings.size.height,
));
form.group().transparency().isolated(true).knockout(false);
write_resources(
form.resources(),
&plan,
&page_image_refs,
&form_refs,
&alpha_gs_names,
&alpha_gs_ids,
);
}
}
for (id, area) in &link_annotations {
write_link_annotation(
pdf.annotation(*id),
area,
links.annotation_base_href(),
output.scale,
);
}
let dests_id = (!destinations.is_empty()).then(|| alloc.next());
if let Some(dests_id) = dests_id {
let mut dests = pdf.destinations(dests_id);
for (name, page_id, x, y) in &destinations {
dests.insert(Name(name.as_bytes())).page(*page_id).xyz(
output.to_pt(*x),
output.to_pt(*y),
None,
);
}
}
pdf.pages(pages_tree_id)
.kids(page_ids.iter().copied())
.count(page_ids.len() as i32);
let mut catalog = pdf.catalog(catalog_id);
catalog.pages(pages_tree_id);
if let Some(dests_id) = dests_id {
catalog.destinations(dests_id);
}
catalog.finish();
let info_id = alloc.next();
write_document_info(pdf.document_info(info_id), &output.metadata);
let id = file_identifier(&output.metadata, pages.len());
pdf.set_file_id((id.clone(), id));
pdf.finish()
}
pub(super) fn write_link_annotation(
mut annotation: pdf_writer::writers::Annotation<'_>,
area: &LinkArea,
base_href: Option<&str>,
scale: f32,
) {
annotation.subtype(AnnotationType::Link);
annotation.rect(PdfRect::new(
area.x0 * scale,
area.y0 * scale,
area.x1 * scale,
area.y1 * scale,
));
annotation.border(0.0, 0.0, 0.0, None);
match internal_anchor_target(&area.href) {
Some(id) => {
let name = anchor_destination_name(id);
annotation.pair(Name(b"Dest"), Name(name.as_bytes()));
}
None => {
let uri = resolve_against_base_href(base_href, &area.href);
annotation
.action()
.action_type(ActionType::Uri)
.uri(pdf_writer::Str(uri.as_bytes()));
}
}
}
pub fn anchor_destination_name(id: &str) -> String {
let mut name = String::with_capacity(id.len() + 2);
name.push_str("a_");
for ch in id.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
name.push(ch);
} else {
name.push('_');
}
}
name
}
pub fn write_document<S: Sink>(
pages: &[Page],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
fonts: &FontCollection,
settings: &PageSettings,
sink: S,
) -> Result<S::Output, S::Error> {
write_document_with_options(
pages,
styles,
background_images,
fonts,
settings,
&LinkSettings::default(),
&PdfOutputOptions::default(),
sink,
)
}
#[allow(clippy::too_many_arguments)]
pub fn write_document_with_options<S: Sink>(
pages: &[Page],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
fonts: &FontCollection,
settings: &PageSettings,
links: &LinkSettings,
output: &PdfOutputOptions,
mut sink: S,
) -> Result<S::Output, S::Error> {
let bytes = encode_pdf_with_options(
pages,
styles,
background_images,
fonts,
settings,
links,
output,
);
sink.write(&bytes)?;
sink.finish()
}
pub(super) fn file_identifier(metadata: &DocumentMetadata, page_count: usize) -> Vec<u8> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let datetime = current_datetime();
let mut id = Vec::with_capacity(16);
for salt in [0u64, 0x9e37_79b9_7f4a_7c15] {
let mut hasher = DefaultHasher::new();
salt.hash(&mut hasher);
producer_string().hash(&mut hasher);
metadata.title.hash(&mut hasher);
metadata.author.hash(&mut hasher);
metadata.subject.hash(&mut hasher);
metadata.keywords.hash(&mut hasher);
datetime.hash(&mut hasher);
page_count.hash(&mut hasher);
id.extend_from_slice(&Hasher::finish(&hasher).to_be_bytes());
}
id
}
pub(super) fn write_document_info(
mut info: pdf_writer::writers::DocumentInfo<'_>,
metadata: &DocumentMetadata,
) {
if let Some(title) = metadata.title.as_deref() {
info.title(TextStr(title));
}
if let Some(author) = metadata.author.as_deref() {
info.author(TextStr(author));
}
if let Some(subject) = metadata.subject.as_deref() {
info.subject(TextStr(subject));
}
if let Some(keywords) = metadata.keywords.as_deref() {
info.keywords(TextStr(keywords));
}
info.producer(TextStr(&producer_string()));
let (year, month, day, hour, minute, second) = current_datetime();
let date = pdf_writer::Date::new(year as u16)
.month(month as u8)
.day(day as u8)
.hour(hour as u8)
.minute(minute as u8)
.second(second as u8)
.utc_offset_hour(0)
.utc_offset_minute(0);
info.creation_date(date);
info.finish();
}
#[allow(clippy::too_many_arguments)]
pub(super) fn write_resources(
mut resources: pdf_writer::writers::Resources<'_>,
plan: &FontPlan,
page_image_refs: &[Ref],
form_refs: &[Ref],
alpha_gs_names: &[String],
alpha_gs_ids: &[Ref],
) {
let mut font_dict = resources.fonts();
for (name, id) in plan.resource_entries() {
font_dict.pair(Name(name.as_bytes()), id);
}
font_dict.finish();
let mut xobject_dict = resources.x_objects();
for color_ref in page_image_refs {
xobject_dict.pair(Name(image_resource_name(*color_ref).as_bytes()), *color_ref);
}
for &form_ref in form_refs {
xobject_dict.pair(Name(form_resource_name(form_ref).as_bytes()), form_ref);
}
xobject_dict.finish();
let mut ext_g_state_dict = resources.ext_g_states();
for (name, &id) in alpha_gs_names.iter().zip(alpha_gs_ids.iter()) {
ext_g_state_dict.pair(Name(name.as_bytes()), id);
}
}
pub(super) struct TextFonts<'a> {
pub any_color: bool,
pub remaps: Option<&'a [HashMap<u16, u16>]>,
pub plan: &'a FontPlan,
pub usages: &'a [FontUsage],
}
impl TextFonts<'_> {
fn target(&self, font_index: usize, glyph_id: u16) -> GlyphTarget<'_> {
if let Some((ordinal, code)) = self
.usages
.get(font_index)
.and_then(|usage| usage.color_code(glyph_id))
{
if let Some(color) = self.plan.color(font_index, ordinal) {
return GlyphTarget::Color {
name: &color.name,
code,
};
}
}
let Some(simple) = self.plan.simple(font_index) else {
return GlyphTarget::Dropped;
};
let cid = match self.remaps {
Some(remaps) => match remaps.get(font_index).and_then(|m| m.get(&glyph_id)) {
Some(&cid) => cid,
None => return GlyphTarget::Dropped,
},
None => glyph_id,
};
GlyphTarget::Simple {
name: &simple.name,
cid,
}
}
}
#[derive(PartialEq)]
enum GlyphTarget<'a> {
Simple { name: &'a str, cid: u16 },
Color { name: &'a str, code: u8 },
Dropped,
}
impl GlyphTarget<'_> {
fn resource_name(&self) -> Option<&str> {
match self {
GlyphTarget::Simple { name, .. } | GlyphTarget::Color { name, .. } => Some(name),
GlyphTarget::Dropped => None,
}
}
fn code_bytes(&self) -> Vec<u8> {
match self {
GlyphTarget::Simple { cid, .. } => cid.to_be_bytes().to_vec(),
GlyphTarget::Color { code, .. } => vec![*code],
GlyphTarget::Dropped => Vec::new(),
}
}
}
#[derive(Default)]
pub(super) struct RefAllocator(i32);
impl RefAllocator {
pub(super) fn next(&mut self) -> Ref {
self.0 += 1;
Ref::new(self.0)
}
#[cfg(feature = "svg")]
pub(super) fn peek(&self) -> Ref {
Ref::new(self.0 + 1)
}
#[cfg(feature = "svg")]
pub(super) fn commit(&mut self, count: usize) {
self.0 += i32::try_from(count).expect("the number of Refs allocated does not fit an i32");
}
}
pub(super) fn collect_usage(b: &LaidOutBox, fonts: &FontCollection, usages: &mut [FontUsage]) {
if let Some(marker) = &b.marker {
collect_line_usage(marker, fonts, usages);
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in children {
collect_usage(child, fonts, usages);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
collect_usage(child, fonts, usages);
}
}
LaidOutContent::Inline(lines) => {
for line in lines {
collect_line_usage(line, fonts, usages);
for atomic in &line.atomics {
collect_usage(&atomic.content, fonts, usages);
}
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
collect_usage(caption, fonts, usages);
}
for row in &table.rows {
for cell in &row.cells {
collect_usage(cell, fonts, usages);
}
}
}
LaidOutContent::Image(_) => {}
}
}
fn collect_line_usage(line: &LineBox, fonts: &FontCollection, usages: &mut [FontUsage]) {
for run in &line.runs {
let Some(font) = fonts.get(run.font_index) else {
continue;
};
for (i, glyph) in run.glyphs.iter().enumerate() {
let text = cluster_text(&run.text, &run.glyphs, i);
usages[run.font_index].record(font, glyph.glyph_id, text);
}
if let Some(EmphasisStyle::String(ch)) = run.emphasis.as_ref().map(|mark| &mark.style) {
if let Some(glyph_id) = font.glyph_id(*ch) {
usages[run.font_index].record(font, glyph_id, ch.encode_utf8(&mut [0u8; 4]));
}
}
}
}
fn cluster_text<'a>(text: &'a str, glyphs: &[crate::fonts::ShapedGlyph], index: usize) -> &'a str {
let start = (glyphs[index].cluster as usize).min(text.len());
let single_char = || {
let len = text[start..].chars().next().map_or(0, char::len_utf8);
&text[start..start + len]
};
if index > 0 && glyphs[index - 1].cluster as usize == start {
return single_char();
}
let end = match glyphs.get(index + 1) {
Some(next) if (next.cluster as usize) > start => (next.cluster as usize).min(text.len()),
Some(_) => return single_char(),
None => text.len(),
};
text.get(start..end).unwrap_or_else(single_char)
}
pub(super) fn collect_image_uses(
b: &LaidOutBox,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
out: &mut Vec<Rc<PreparedImage>>,
) {
if let Some(image) = b.node.and_then(|n| background_images.get(&n)) {
push_unique_image(out, image);
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in children {
collect_image_uses(child, background_images, out);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
collect_image_uses(child, background_images, out);
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
collect_image_uses(caption, background_images, out);
}
for row in &table.rows {
for cell in &row.cells {
collect_image_uses(cell, background_images, out);
}
}
}
LaidOutContent::Image(Some(image)) => push_unique_image(out, image),
LaidOutContent::Inline(lines) => {
for line in lines {
for atomic in &line.atomics {
collect_image_uses(&atomic.content, background_images, out);
}
}
}
LaidOutContent::Image(None) => {}
}
}
#[derive(Debug, Clone)]
pub struct LinkSettings {
pub anchor_names: HashMap<NodeId, String>,
pub base_href: Option<String>,
pub external: bool,
pub internal: bool,
pub keep_relative: bool,
}
impl Default for LinkSettings {
fn default() -> Self {
Self {
anchor_names: HashMap::new(),
base_href: None,
external: true,
internal: true,
keep_relative: false,
}
}
}
impl LinkSettings {
pub(super) fn retain_enabled(&self, areas: &mut Vec<LinkArea>) {
areas.retain(|area| {
if internal_anchor_target(&area.href).is_some() {
self.internal
} else {
self.external
}
});
}
pub(super) fn annotation_base_href(&self) -> Option<&str> {
if self.keep_relative {
None
} else {
self.base_href.as_deref()
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct LinkArea {
pub href: Rc<str>,
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
pub(super) fn collect_link_areas(b: &LaidOutBox, settings: &PageSettings, out: &mut Vec<LinkArea>) {
if let Some(href) = &b.link {
let border_box = b.layout.border_box();
if border_box.width > 0.0 && border_box.height > 0.0 {
let x0 = settings.margin.left + border_box.x;
out.push(LinkArea {
href: href.clone(),
x0,
y0: to_pdf_y(settings, border_box.y + border_box.height),
x1: x0 + border_box.width,
y1: to_pdf_y(settings, border_box.y),
});
}
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in children {
collect_link_areas(child, settings, out);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
collect_link_areas(child, settings, out);
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
collect_link_areas(caption, settings, out);
}
for row in &table.rows {
for cell in &row.cells {
collect_link_areas(cell, settings, out);
}
}
}
LaidOutContent::Inline(lines) => {
for line in lines {
push_line_link_areas(line, settings, out);
for atomic in &line.atomics {
collect_link_areas(&atomic.content, settings, out);
}
}
}
LaidOutContent::Image(_) => {}
}
}
struct LinePiece<'a> {
link: Option<&'a Rc<str>>,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
}
fn push_line_link_areas(line: &LineBox, settings: &PageSettings, out: &mut Vec<LinkArea>) {
let mut pieces: Vec<LinePiece<'_>> = Vec::with_capacity(line.runs.len() + line.atomics.len());
for run in &line.runs {
let x0 = settings.margin.left + line.rect.x + run.x_offset;
let baseline_y = to_pdf_y(settings, line.rect.y + line.baseline) + run.baseline_shift;
pieces.push(LinePiece {
link: run.link.as_ref(),
x0,
y0: baseline_y - run.descent,
x1: x0 + run.width,
y1: baseline_y + run.ascent,
});
}
for atomic in &line.atomics {
let border_box = atomic.content.layout.border_box();
let x0 = settings.margin.left + border_box.x;
pieces.push(LinePiece {
link: atomic.link.as_ref(),
x0,
y0: to_pdf_y(settings, border_box.y + border_box.height),
x1: x0 + border_box.width,
y1: to_pdf_y(settings, border_box.y),
});
}
if !line.atomics.is_empty() {
pieces.sort_by(|a, b| a.x0.total_cmp(&b.x0));
}
let mut current: Option<LinkArea> = None;
for piece in pieces {
let Some(href) = piece.link else {
if let Some(area) = current.take() {
out.push(area);
}
continue;
};
match &mut current {
Some(area) if area.href == *href => {
area.x1 = area.x1.max(piece.x1);
area.y0 = area.y0.min(piece.y0);
area.y1 = area.y1.max(piece.y1);
}
_ => {
if let Some(area) = current.take() {
out.push(area);
}
current = Some(LinkArea {
href: href.clone(),
x0: piece.x0,
y0: piece.y0,
x1: piece.x1,
y1: piece.y1,
});
}
}
}
if let Some(area) = current {
out.push(area);
}
}
pub(super) fn collect_anchor_positions(
b: &LaidOutBox,
anchor_names: &HashMap<NodeId, String>,
settings: &PageSettings,
out: &mut Vec<(String, f32, f32)>,
) {
if let Some(name) = b.node.and_then(|n| anchor_names.get(&n)) {
if !out.iter().any(|(existing, _, _)| existing == name) {
let border_box = b.layout.border_box();
out.push((
name.clone(),
settings.margin.left + border_box.x,
to_pdf_y(settings, border_box.y),
));
}
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in children {
collect_anchor_positions(child, anchor_names, settings, out);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
collect_anchor_positions(child, anchor_names, settings, out);
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
collect_anchor_positions(caption, anchor_names, settings, out);
}
for row in &table.rows {
for cell in &row.cells {
collect_anchor_positions(cell, anchor_names, settings, out);
}
}
}
LaidOutContent::Inline(lines) => {
for line in lines {
for atomic in &line.atomics {
collect_anchor_positions(&atomic.content, anchor_names, settings, out);
}
}
}
LaidOutContent::Image(_) => {}
}
}
pub(super) fn internal_anchor_target(href: &str) -> Option<&str> {
href.strip_prefix('#').filter(|id| !id.is_empty())
}
pub(super) fn collect_opacity_uses(
b: &LaidOutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
out: &mut Vec<NodeId>,
) {
if let Some(node) = b.node {
if styles.get(&node).is_some_and(|s| s.opacity < 1.0) {
out.push(node);
}
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in children {
collect_opacity_uses(child, styles, out);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
collect_opacity_uses(child, styles, out);
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
collect_opacity_uses(caption, styles, out);
}
for row in &table.rows {
for cell in &row.cells {
collect_opacity_uses(cell, styles, out);
}
}
}
LaidOutContent::Inline(lines) => {
for line in lines {
for atomic in &line.atomics {
collect_opacity_uses(&atomic.content, styles, out);
}
}
}
LaidOutContent::Image(_) => {}
}
}
pub(super) fn form_resource_name(form_ref: Ref) -> String {
format!("Fo{}", form_ref.get())
}
fn push_unique_image(out: &mut Vec<Rc<PreparedImage>>, image: &Rc<PreparedImage>) {
if !out.iter().any(|existing| Rc::ptr_eq(existing, image)) {
out.push(image.clone());
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_box(
content: &mut RenderTarget<'_>,
b: &LaidOutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
image_ids: &HashMap<usize, ImageIds>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
alpha_gs_names: &[String],
opacity_form_ids: &HashMap<NodeId, Ref>,
pending_forms: &mut Vec<(Ref, Vec<u8>)>,
) {
let style = b
.node
.and_then(|n| styles.get(&n))
.cloned()
.unwrap_or_default();
render_box_with_style(
content,
b,
&style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
#[allow(clippy::too_many_arguments)]
fn render_box_with_style(
content: &mut RenderTarget<'_>,
b: &LaidOutBox,
style: &ComputedStyle,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
image_ids: &HashMap<usize, ImageIds>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
alpha_gs_names: &[String],
opacity_form_ids: &HashMap<NodeId, Ref>,
pending_forms: &mut Vec<(Ref, Vec<u8>)>,
) {
if style.transform.is_empty() {
render_box_opacity_wrapped(
content,
b,
style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
return;
}
let pdf_matrix = transform_matrix_pdf_space(b, style, settings);
content.save_state();
content.transform(pdf_matrix);
render_box_opacity_wrapped(
content,
b,
style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
content.restore_state();
}
#[allow(clippy::too_many_arguments)]
fn render_box_opacity_wrapped(
content: &mut RenderTarget<'_>,
b: &LaidOutBox,
style: &ComputedStyle,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
image_ids: &HashMap<usize, ImageIds>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
alpha_gs_names: &[String],
opacity_form_ids: &HashMap<NodeId, Ref>,
pending_forms: &mut Vec<(Ref, Vec<u8>)>,
) {
if style.opacity >= 1.0 {
render_box_with_style_inner(
content,
b,
style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
return;
}
let form_ref = *b
.node
.and_then(|n| opacity_form_ids.get(&n))
.expect("an element with opacity < 1 should have had a Ref allocated in advance");
let mut sub_content = Content::new();
let mut sub_target = content.wrap(&mut sub_content);
render_box_with_style_inner(
&mut sub_target,
b,
style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
pending_forms.push((form_ref, sub_content.finish().to_vec()));
content.save_state();
apply_fill_alpha(content, style.opacity, alpha_gs_names);
content.x_object(Name(form_resource_name(form_ref).as_bytes()));
content.restore_state();
}
fn transform_matrix_pdf_space(
b: &LaidOutBox,
style: &ComputedStyle,
settings: &PageSettings,
) -> [f32; 6] {
let border_box = b.layout.border_box();
let css_matrix = compose_transform(&style.transform, border_box.width, border_box.height);
let [a, b_, c, d, e, f] = css_matrix;
let pdf_matrix_no_origin = [a, -b_, -c, d, e, -f];
let origin_x = settings.margin.left
+ border_box.x
+ resolve_length_percentage(style.transform_origin.horizontal, border_box.width);
let origin_y = to_pdf_y(
settings,
border_box.y
+ resolve_length_percentage(style.transform_origin.vertical, border_box.height),
);
apply_transform_origin(pdf_matrix_no_origin, origin_x, origin_y)
}
fn resolve_length_percentage(lp: LengthPercentage, basis: f32) -> f32 {
match lp {
LengthPercentage::Length(px) => px,
LengthPercentage::Percentage(p) => p * basis,
LengthPercentage::Calc { px, percent } => px + percent * basis,
}
}
fn apply_transform_origin(m: [f32; 6], ox: f32, oy: f32) -> [f32; 6] {
let [a, b, c, d, e, f] = m;
[
a,
b,
c,
d,
e + ox - a * ox - c * oy,
f + oy - b * ox - d * oy,
]
}
#[allow(clippy::too_many_arguments)]
fn render_box_with_style_inner(
content: &mut RenderTarget<'_>,
b: &LaidOutBox,
style: &ComputedStyle,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
image_ids: &HashMap<usize, ImageIds>,
background_images: &HashMap<NodeId, Rc<PreparedImage>>,
alpha_gs_names: &[String],
opacity_form_ids: &HashMap<NodeId, Ref>,
pending_forms: &mut Vec<(Ref, Vec<u8>)>,
) {
if style.visibility.is_hidden() {
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in paint_order(children, styles) {
render_box(
content,
child,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
render_box(
content,
child,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
render_box(
content,
caption,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
for row in &table.rows {
for cell in &row.cells {
render_box(
content,
cell,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
}
LaidOutContent::Inline(_) | LaidOutContent::Image(_) => {}
}
return;
}
let background_image_paint = b
.node
.and_then(|n| background_images.get(&n))
.and_then(|image| {
image_ids
.get(&(Rc::as_ptr(image) as usize))
.map(|ids| BackgroundImagePaint {
resource: ids.root,
intrinsic_width: image.width,
intrinsic_height: image.height,
})
});
render_box_decoration(
content,
&b.layout,
style,
settings,
background_image_paint,
alpha_gs_names,
);
render_outline(content, &b.layout, style, settings);
if let Some(marker) = &b.marker {
render_line(content, marker, fonts, settings, text_fonts, alpha_gs_names);
}
if style.overflow.clips() {
let padding_box = b.layout.padding_box();
let x = settings.margin.left + padding_box.x;
let y = to_pdf_y(settings, padding_box.y + padding_box.height);
content.save_state();
content.rect(x, y, padding_box.width, padding_box.height);
content.clip_nonzero();
content.end_path();
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for child in paint_order(children, styles) {
render_box(
content,
child,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
LaidOutContent::Grid(grid) => {
for child in grid.rows.iter().flat_map(|row| &row.items) {
render_box(
content,
child,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
LaidOutContent::Inline(lines) => {
for line in lines {
render_line(content, line, fonts, settings, text_fonts, alpha_gs_names);
for atomic in &line.atomics {
render_box(
content,
&atomic.content,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
}
LaidOutContent::Image(image) => {
if let Some(image) = image {
if let Some(ids) = image_ids.get(&(Rc::as_ptr(image) as usize)) {
render_replaced_image(
content,
b.layout.content,
style,
settings,
image,
ids.root,
);
}
}
}
LaidOutContent::Table(table) => {
if let Some(caption) = &table.caption {
render_box(
content,
caption,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
let collapse = style.border_collapse == BorderCollapse::Collapse;
let all_cells: Vec<&LaidOutBox> = if collapse {
table.rows.iter().flat_map(|row| &row.cells).collect()
} else {
Vec::new()
};
render_row_group_backgrounds(content, &table.rows, styles, settings, alpha_gs_names);
for row in &table.rows {
render_row_background(content, row, styles, settings, alpha_gs_names);
for cell in &row.cells {
let cell_style = cell
.node
.and_then(|n| styles.get(&n))
.cloned()
.unwrap_or_default();
let hide_this_cell = !collapse
&& cell_style.empty_cells == EmptyCells::Hide
&& laid_content_is_empty(&cell.content);
if hide_this_cell {
continue;
}
if collapse {
let (resolved_style, resolved_border) =
resolve_collapsed_cell_style(cell, &cell_style, &all_cells, styles);
let mut resolved_cell = cell.clone();
resolved_cell.layout.border = resolved_border;
render_box_with_style(
content,
&resolved_cell,
&resolved_style,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
} else {
render_box(
content,
cell,
styles,
fonts,
settings,
text_fonts,
image_ids,
background_images,
alpha_gs_names,
opacity_form_ids,
pending_forms,
);
}
}
}
}
}
if style.overflow.clips() {
content.restore_state();
}
}
fn paint_order<'a>(
children: &'a [LaidOutBox],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
) -> Vec<&'a LaidOutBox> {
let effective_z_index = |child: &LaidOutBox| -> i32 {
let Some(style) = child.node.and_then(|n| styles.get(&n)) else {
return 0;
};
if style.position == Position::Relative {
style.z_index.sort_key()
} else {
0
}
};
let mut order: Vec<&LaidOutBox> = children.iter().collect();
order.sort_by_key(|child| (effective_z_index(child), u8::from(child.is_float)));
order
}
fn laid_content_is_empty(content: &LaidOutContent) -> bool {
match content {
LaidOutContent::Inline(lines) => lines.iter().all(|line| {
line.runs
.iter()
.all(|run| crate::layout::is_collapsible_only(&run.text))
}),
LaidOutContent::Blocks(children) => {
children.is_empty() || children.iter().all(|c| laid_content_is_empty(&c.content))
}
LaidOutContent::Grid(grid) => grid
.rows
.iter()
.flat_map(|row| &row.items)
.all(|item| laid_content_is_empty(&item.content)),
LaidOutContent::Table(_) | LaidOutContent::Flex(_) | LaidOutContent::Image(_) => false,
}
}
fn resolve_collapsed_cell_style(
cell: &LaidOutBox,
cell_style: &ComputedStyle,
all_cells: &[&LaidOutBox],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
) -> (ComputedStyle, EdgeSizes) {
const EPSILON: f32 = 0.5;
fn ranges_overlap(a_start: f32, a_end: f32, b_start: f32, b_end: f32) -> bool {
a_start < b_end - EPSILON && b_start < a_end - EPSILON
}
let rect = cell.layout.border_box();
let mut resolved = cell_style.clone();
let has_left_neighbor = all_cells.iter().any(|other| {
let o = other.layout.border_box();
(o.x + o.width - rect.x).abs() < EPSILON
&& ranges_overlap(rect.y, rect.y + rect.height, o.y, o.y + o.height)
});
if has_left_neighbor {
resolved.border_left_style = BorderStyle::None;
resolved.border_left_width = Length(0.0);
}
let has_top_neighbor = all_cells.iter().any(|other| {
let o = other.layout.border_box();
(o.y + o.height - rect.y).abs() < EPSILON
&& ranges_overlap(rect.x, rect.x + rect.width, o.x, o.x + o.width)
});
if has_top_neighbor {
resolved.border_top_style = BorderStyle::None;
resolved.border_top_width = Length(0.0);
}
if let Some(right_neighbor) = all_cells.iter().find(|other| {
let o = other.layout.border_box();
(o.x - (rect.x + rect.width)).abs() < EPSILON
&& ranges_overlap(rect.y, rect.y + rect.height, o.y, o.y + o.height)
}) {
let neighbor_style = right_neighbor
.node
.and_then(|n| styles.get(&n))
.cloned()
.unwrap_or_default();
let own = border_edge(
cell_style.border_right_width.0,
cell_style.border_right_style,
cell_style.border_right_color,
);
let theirs = border_edge(
neighbor_style.border_left_width.0,
neighbor_style.border_left_style,
neighbor_style.border_left_color,
);
let (width, style, color) = resolve_border_conflict(own, theirs);
resolved.border_right_width = Length(width);
resolved.border_right_style = style;
resolved.border_right_color = color;
}
if let Some(bottom_neighbor) = all_cells.iter().find(|other| {
let o = other.layout.border_box();
(o.y - (rect.y + rect.height)).abs() < EPSILON
&& ranges_overlap(rect.x, rect.x + rect.width, o.x, o.x + o.width)
}) {
let neighbor_style = bottom_neighbor
.node
.and_then(|n| styles.get(&n))
.cloned()
.unwrap_or_default();
let own = border_edge(
cell_style.border_bottom_width.0,
cell_style.border_bottom_style,
cell_style.border_bottom_color,
);
let theirs = border_edge(
neighbor_style.border_top_width.0,
neighbor_style.border_top_style,
neighbor_style.border_top_color,
);
let (width, style, color) = resolve_border_conflict(own, theirs);
resolved.border_bottom_width = Length(width);
resolved.border_bottom_style = style;
resolved.border_bottom_color = color;
}
let border = resolve_border(&resolved);
(resolved, border)
}
fn border_edge(width: f32, style: BorderStyle, color: RgbaColor) -> (f32, BorderStyle, RgbaColor) {
if style == BorderStyle::None {
(0.0, BorderStyle::None, color)
} else {
(width, style, color)
}
}
fn resolve_border_conflict(
a: (f32, BorderStyle, RgbaColor),
b: (f32, BorderStyle, RgbaColor),
) -> (f32, BorderStyle, RgbaColor) {
if a.0 != b.0 {
return if a.0 > b.0 { a } else { b };
}
fn style_priority(s: BorderStyle) -> u8 {
match s {
BorderStyle::Double => 8,
BorderStyle::Solid => 7,
BorderStyle::Dashed => 6,
BorderStyle::Dotted => 5,
BorderStyle::Ridge => 4,
BorderStyle::Outset => 3,
BorderStyle::Groove => 2,
BorderStyle::Inset => 1,
BorderStyle::None => 0,
}
}
if style_priority(a.1) != style_priority(b.1) {
return if style_priority(a.1) > style_priority(b.1) {
a
} else {
b
};
}
a
}
#[allow(clippy::too_many_arguments)]
fn render_box_decoration(
content: &mut RenderTarget<'_>,
layout: &Layout,
style: &ComputedStyle,
settings: &PageSettings,
background_image_paint: Option<BackgroundImagePaint>,
alpha_gs_names: &[String],
) {
let radii = effective_radii(layout, style);
let has_radius = [radii.0, radii.1, radii.2, radii.3]
.into_iter()
.any(|(rx, ry)| rx > 0.0 || ry > 0.0);
render_box_shadows(content, layout, style, settings, radii, alpha_gs_names);
if has_radius {
render_rounded_decoration(
content,
layout,
style,
settings,
radii,
background_image_paint,
alpha_gs_names,
);
return;
}
if style.background_color.alpha > 0.0 {
render_background(
content,
layout.border_box(),
style.background_color,
settings,
alpha_gs_names,
);
}
if let Some(paint) = background_image_paint {
render_background_image(content, layout.border_box(), style, settings, &paint);
}
render_border(content, layout, style, settings);
}
const BOX_SHADOW_BLUR_STEPS: u32 = 8;
fn render_box_shadows(
content: &mut RenderTarget<'_>,
layout: &Layout,
style: &ComputedStyle,
settings: &PageSettings,
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
alpha_gs_names: &[String],
) {
let shadows: Vec<&ComputedBoxShadow> = style
.box_shadow
.iter()
.filter(|shadow| !shadow.inset && quantize_alpha_step(shadow.color.alpha) > 0)
.collect();
if shadows.is_empty() {
return;
}
let border_box = layout.border_box();
let x0 = settings.margin.left + border_box.x;
let x1 = x0 + border_box.width;
let y_top = to_pdf_y(settings, border_box.y);
let y_bottom = to_pdf_y(settings, border_box.y + border_box.height);
let reach = shadows
.iter()
.map(|shadow| {
shadow.offset_x.abs().max(shadow.offset_y.abs())
+ shadow.spread_radius.max(0.0)
+ shadow.blur_radius
})
.fold(0.0, f32::max)
+ 1.0;
content.save_state();
content.rect(
x0 - reach,
y_bottom - reach,
x1 - x0 + 2.0 * reach,
y_top - y_bottom + 2.0 * reach,
);
rounded_rect_path(content, x0, y_top, x1, y_bottom, radii);
content.clip_even_odd();
content.end_path();
for shadow in shadows.into_iter().rev() {
render_single_box_shadow(content, border_box, shadow, settings, radii, alpha_gs_names);
}
content.restore_state();
}
fn render_single_box_shadow(
content: &mut RenderTarget<'_>,
border_box: Rect,
shadow: &ComputedBoxShadow,
settings: &PageSettings,
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
alpha_gs_names: &[String],
) {
let shape_radii = spread_radii(radii, shadow.spread_radius);
let outline = |grow: f32| {
let expand = shadow.spread_radius + grow;
let x0 = settings.margin.left + border_box.x + shadow.offset_x - expand;
let x1 = settings.margin.left + border_box.x + border_box.width + shadow.offset_x + expand;
let y_top = to_pdf_y(settings, border_box.y + shadow.offset_y - expand);
let y_bottom = to_pdf_y(
settings,
border_box.y + border_box.height + shadow.offset_y + expand,
);
(x1 > x0 && y_top > y_bottom)
.then(|| (x0, y_top, x1, y_bottom, grow_radii(shape_radii, grow)))
};
let fill = |content: &mut RenderTarget<'_>, outer: f32, inner: Option<f32>, alpha: f32| {
if quantize_alpha_step(alpha) == 0 {
return;
}
let Some((x0, y_top, x1, y_bottom, outer_radii)) = outline(outer) else {
return;
};
let use_alpha = alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, alpha, alpha_gs_names);
}
content.set_fill_rgb(
shadow.color.red as f32 / 255.0,
shadow.color.green as f32 / 255.0,
shadow.color.blue as f32 / 255.0,
);
rounded_rect_path(content, x0, y_top, x1, y_bottom, outer_radii);
if let Some((x0, y_top, x1, y_bottom, inner_radii)) = inner.and_then(outline) {
rounded_rect_path(content, x0, y_top, x1, y_bottom, inner_radii);
content.fill_even_odd();
} else {
content.fill_nonzero();
}
if use_alpha {
content.restore_state();
}
};
if shadow.blur_radius <= 0.0 {
fill(content, 0.0, None, shadow.color.alpha);
return;
}
let blur = shadow.blur_radius;
let band = 2.0 * blur / BOX_SHADOW_BLUR_STEPS as f32;
fill(content, -blur, None, shadow.color.alpha);
for step in 0..BOX_SHADOW_BLUR_STEPS {
let inner = -blur + band * step as f32;
let alpha = shadow.color.alpha * (1.0 - (step as f32 + 0.5) / BOX_SHADOW_BLUR_STEPS as f32);
fill(content, inner + band, Some(inner), alpha);
}
}
fn spread_radii(
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
spread: f32,
) -> (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
) {
let one = |r: f32| {
if spread > 0.0 && r < spread {
let ratio = r / spread;
r + spread * (1.0 + (ratio - 1.0).powi(3))
} else {
(r + spread).max(0.0)
}
};
let corner = |(rx, ry): CornerRadiusPx| (one(rx), one(ry));
(
corner(radii.0),
corner(radii.1),
corner(radii.2),
corner(radii.3),
)
}
fn grow_radii(
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
grow: f32,
) -> (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
) {
let one = |r: f32| if r > 0.0 { (r + grow).max(0.0) } else { 0.0 };
let corner = |(rx, ry): CornerRadiusPx| (one(rx), one(ry));
(
corner(radii.0),
corner(radii.1),
corner(radii.2),
corner(radii.3),
)
}
type CornerRadiusPx = (f32, f32);
fn effective_radii(
layout: &Layout,
style: &ComputedStyle,
) -> (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
) {
let apply_top = matches!(
layout.fragment,
FragmentPosition::Whole | FragmentPosition::First
);
let apply_bottom = matches!(
layout.fragment,
FragmentPosition::Whole | FragmentPosition::Last
);
let px = |r: CornerRadius| (r.horizontal.0, r.vertical.0);
(
if apply_top {
px(style.border_top_left_radius)
} else {
(0.0, 0.0)
},
if apply_top {
px(style.border_top_right_radius)
} else {
(0.0, 0.0)
},
if apply_bottom {
px(style.border_bottom_right_radius)
} else {
(0.0, 0.0)
},
if apply_bottom {
px(style.border_bottom_left_radius)
} else {
(0.0, 0.0)
},
)
}
pub(super) const ALPHA_STEPS: usize = 20;
fn quantize_alpha_step(alpha: f32) -> usize {
(alpha.clamp(0.0, 1.0) * ALPHA_STEPS as f32).round() as usize
}
pub(super) fn alpha_gs_resource_name(step: usize) -> String {
format!("GSA{step}")
}
fn apply_fill_alpha(content: &mut RenderTarget<'_>, alpha: f32, alpha_gs_names: &[String]) {
let step = quantize_alpha_step(alpha);
if step >= ALPHA_STEPS {
return;
}
content.set_parameters(Name(alpha_gs_names[step].as_bytes()));
}
fn render_background(
content: &mut RenderTarget<'_>,
border_box: Rect,
color: RgbaColor,
settings: &PageSettings,
alpha_gs_names: &[String],
) {
let x = settings.margin.left + border_box.x;
let y = to_pdf_y(settings, border_box.y + border_box.height);
let use_alpha = color.alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, color.alpha, alpha_gs_names);
}
content.set_fill_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
content.rect(x, y, border_box.width, border_box.height);
content.fill_nonzero();
if use_alpha {
content.restore_state();
}
}
fn render_image(
content: &mut RenderTarget<'_>,
rect: Rect,
settings: &PageSettings,
resource_ref: Ref,
) {
let x = settings.margin.left + rect.x;
let y = to_pdf_y(settings, rect.y + rect.height);
let name = image_resource_name(resource_ref);
content.save_state();
content.transform([rect.width, 0.0, 0.0, rect.height, x, y]);
content.x_object(Name(name.as_bytes()));
content.restore_state();
}
fn render_replaced_image(
content: &mut RenderTarget<'_>,
content_box: Rect,
style: &ComputedStyle,
settings: &PageSettings,
image: &PreparedImage,
resource_ref: Ref,
) {
let rect = object_fit_rect(content_box, style, (image.width, image.height));
let x = settings.margin.left + content_box.x;
let y = to_pdf_y(settings, content_box.y + content_box.height);
content.save_state();
content.rect(x, y, content_box.width, content_box.height);
content.clip_nonzero();
content.end_path();
render_image(content, rect, settings, resource_ref);
content.restore_state();
}
fn object_fit_rect(content_box: Rect, style: &ComputedStyle, intrinsic: (f32, f32)) -> Rect {
let (iw, ih) = intrinsic;
if iw <= 0.0 || ih <= 0.0 {
return content_box;
}
let (draw_w, draw_h) = match style.object_fit {
ObjectFit::Fill => (content_box.width, content_box.height),
ObjectFit::Cover => {
let scale = (content_box.width / iw).max(content_box.height / ih);
(iw * scale, ih * scale)
}
ObjectFit::Contain => {
let scale = (content_box.width / iw).min(content_box.height / ih);
(iw * scale, ih * scale)
}
ObjectFit::None => (iw, ih),
ObjectFit::ScaleDown => {
if iw <= content_box.width && ih <= content_box.height {
(iw, ih)
} else {
let scale = (content_box.width / iw).min(content_box.height / ih);
(iw * scale, ih * scale)
}
}
};
let x = content_box.x
+ resolve_background_position_offset(
style.object_position.horizontal,
content_box.width,
draw_w,
);
let y = content_box.y
+ resolve_background_position_offset(
style.object_position.vertical,
content_box.height,
draw_h,
);
Rect {
x,
y,
width: draw_w,
height: draw_h,
}
}
#[derive(Debug, Clone, Copy)]
struct BackgroundImagePaint {
resource: Ref,
intrinsic_width: f32,
intrinsic_height: f32,
}
fn background_tile_rects(
border_box: Rect,
style: &ComputedStyle,
intrinsic: (f32, f32),
) -> Vec<Rect> {
let (iw, ih) = intrinsic;
if iw <= 0.0 || ih <= 0.0 {
return vec![border_box];
}
let (draw_w, draw_h) = match style.background_size {
BackgroundSize::Cover => {
let scale = (border_box.width / iw).max(border_box.height / ih);
(iw * scale, ih * scale)
}
BackgroundSize::Contain => {
let scale = (border_box.width / iw).min(border_box.height / ih);
(iw * scale, ih * scale)
}
BackgroundSize::WidthHeight(w, h) => {
let resolved_w = resolve_background_size_component(w, border_box.width);
let resolved_h = resolve_background_size_component(h, border_box.height);
match (resolved_w, resolved_h) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * ih / iw),
(None, Some(h)) => (h * iw / ih, h),
(None, None) => (iw, ih),
}
}
};
if draw_w <= 0.0 || draw_h <= 0.0 {
return Vec::new();
}
let origin_x = border_box.x
+ resolve_background_position_offset(
style.background_position.horizontal,
border_box.width,
draw_w,
);
let origin_y = border_box.y
+ resolve_background_position_offset(
style.background_position.vertical,
border_box.height,
draw_h,
);
let (repeat_x, repeat_y) = match style.background_repeat {
BackgroundRepeat::Repeat => (true, true),
BackgroundRepeat::RepeatX => (true, false),
BackgroundRepeat::RepeatY => (false, true),
BackgroundRepeat::NoRepeat => (false, false),
};
let xs = tile_starts(
origin_x,
draw_w,
border_box.x,
border_box.x + border_box.width,
repeat_x,
);
let ys = tile_starts(
origin_y,
draw_h,
border_box.y,
border_box.y + border_box.height,
repeat_y,
);
xs.into_iter()
.flat_map(|x| {
ys.iter().map(move |&y| Rect {
x,
y,
width: draw_w,
height: draw_h,
})
})
.collect()
}
fn resolve_background_size_component(value: LengthPercentageOrAuto, basis: f32) -> Option<f32> {
match value {
LengthPercentageOrAuto::Auto => None,
LengthPercentageOrAuto::LengthPercentage(lp) => Some(resolve_length_percentage(lp, basis)),
}
}
fn resolve_background_position_offset(value: LengthPercentage, container: f32, tile: f32) -> f32 {
match value {
LengthPercentage::Length(l) => l,
LengthPercentage::Percentage(p) => (container - tile) * p,
LengthPercentage::Calc { px, percent } => px + (container - tile) * percent,
}
}
fn tile_starts(origin: f32, tile: f32, min: f32, max: f32, repeat: bool) -> Vec<f32> {
if !repeat || tile <= 0.0 {
return vec![origin];
}
const MAX_TILES_PER_AXIS: usize = 200;
let steps_back = ((origin - min) / tile).ceil().max(0.0);
let first = origin - steps_back * tile;
let mut starts = Vec::new();
let mut x = first;
while x < max && starts.len() < MAX_TILES_PER_AXIS {
starts.push(x);
x += tile;
}
starts
}
fn render_background_image(
content: &mut RenderTarget<'_>,
border_box: Rect,
style: &ComputedStyle,
settings: &PageSettings,
paint: &BackgroundImagePaint,
) {
let rects = background_tile_rects(
border_box,
style,
(paint.intrinsic_width, paint.intrinsic_height),
);
if rects.is_empty() {
return;
}
let fits_without_clip = rects.len() == 1
&& rects[0].x >= border_box.x
&& rects[0].y >= border_box.y
&& rects[0].x + rects[0].width <= border_box.x + border_box.width
&& rects[0].y + rects[0].height <= border_box.y + border_box.height;
if !fits_without_clip {
let x = settings.margin.left + border_box.x;
let y = to_pdf_y(settings, border_box.y + border_box.height);
content.save_state();
content.rect(x, y, border_box.width, border_box.height);
content.clip_nonzero();
content.end_path();
}
for rect in rects {
render_image(content, rect, settings, paint.resource);
}
if !fits_without_clip {
content.restore_state();
}
}
fn render_row_background(
content: &mut RenderTarget<'_>,
row: &LaidOutTableRow,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
settings: &PageSettings,
alpha_gs_names: &[String],
) {
let Some(style) = row.node.and_then(|node| styles.get(&node)) else {
return;
};
if style.background_color.alpha <= 0.0 || row.cells.is_empty() {
return;
}
let mut left = f32::MAX;
let mut right = f32::MIN;
let mut top = f32::MAX;
let mut bottom = f32::MIN;
for cell in &row.cells {
let b = cell.layout.border_box();
left = left.min(b.x);
right = right.max(b.x + b.width);
top = top.min(b.y);
bottom = bottom.max(b.y + b.height);
}
if right <= left || bottom <= top {
return;
}
let use_alpha = style.background_color.alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, style.background_color.alpha, alpha_gs_names);
}
content.set_fill_rgb(
style.background_color.red as f32 / 255.0,
style.background_color.green as f32 / 255.0,
style.background_color.blue as f32 / 255.0,
);
let x = settings.margin.left + left;
let y_bottom = to_pdf_y(settings, bottom);
content.rect(x, y_bottom, right - left, bottom - top);
content.fill_nonzero();
if use_alpha {
content.restore_state();
}
}
fn render_row_group_backgrounds(
content: &mut RenderTarget<'_>,
rows: &[LaidOutTableRow],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
settings: &PageSettings,
alpha_gs_names: &[String],
) {
for run in rows.chunk_by(|a, b| a.group == b.group) {
let Some(style) = run[0].group.and_then(|node| styles.get(&node)) else {
continue;
};
let color = style.background_color;
let cells: Vec<Rect> = run
.iter()
.flat_map(|row| &row.cells)
.map(|cell| cell.layout.border_box())
.filter(|b| b.width > 0.0 && b.height > 0.0)
.collect();
if color.alpha <= 0.0 || cells.is_empty() {
continue;
}
let use_alpha = color.alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, color.alpha, alpha_gs_names);
}
content.set_fill_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
for b in cells {
let y_bottom = to_pdf_y(settings, b.y + b.height);
content.rect(settings.margin.left + b.x, y_bottom, b.width, b.height);
}
content.fill_nonzero();
if use_alpha {
content.restore_state();
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_rounded_decoration(
content: &mut RenderTarget<'_>,
layout: &Layout,
style: &ComputedStyle,
settings: &PageSettings,
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
background_image_paint: Option<BackgroundImagePaint>,
alpha_gs_names: &[String],
) {
let border_box = layout.border_box();
let x0 = settings.margin.left + border_box.x;
let x1 = x0 + border_box.width;
let y_top = to_pdf_y(settings, border_box.y);
let y_bottom = to_pdf_y(settings, border_box.y + border_box.height);
if style.background_color.alpha > 0.0 {
let use_alpha = style.background_color.alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, style.background_color.alpha, alpha_gs_names);
}
content.set_fill_rgb(
style.background_color.red as f32 / 255.0,
style.background_color.green as f32 / 255.0,
style.background_color.blue as f32 / 255.0,
);
rounded_rect_path(content, x0, y_top, x1, y_bottom, radii);
content.fill_nonzero();
if use_alpha {
content.restore_state();
}
}
if let Some(paint) = background_image_paint {
render_background_image(content, border_box, style, settings, &paint);
}
let is_shaded_style = matches!(
style.border_top_style,
BorderStyle::Groove | BorderStyle::Ridge | BorderStyle::Inset | BorderStyle::Outset
);
if !is_uniform_border(style) || is_shaded_style {
render_border(content, layout, style, settings);
return;
}
let thickness = layout.border.left;
if thickness <= 0.0 || style.border_top_style == BorderStyle::None {
return;
}
content.set_stroke_rgb(
style.border_top_color.red as f32 / 255.0,
style.border_top_color.green as f32 / 255.0,
style.border_top_color.blue as f32 / 255.0,
);
if style.border_top_style == BorderStyle::Double {
let band = thickness / 3.0;
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([], 0.0);
content.set_line_width(band);
for offset in [band / 2.0, thickness - band / 2.0] {
rounded_border_path(
content,
(x0, y_top, x1, y_bottom),
offset,
shrink_radii(radii, offset),
layout.fragment,
);
content.stroke();
}
return;
}
let inset = thickness / 2.0;
content.set_line_width(thickness);
apply_border_style_dash(content, style.border_top_style, thickness);
rounded_border_path(
content,
(x0, y_top, x1, y_bottom),
inset,
shrink_radii(radii, inset),
layout.fragment,
);
content.stroke();
}
fn rounded_border_path(
content: &mut RenderTarget<'_>,
(x0, y_top, x1, y_bottom): (f32, f32, f32, f32),
inset: f32,
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
fragment: FragmentPosition,
) {
let has_top = matches!(fragment, FragmentPosition::Whole | FragmentPosition::First);
let has_bottom = matches!(fragment, FragmentPosition::Whole | FragmentPosition::Last);
let left = x0 + inset;
let right = x1 - inset;
let top = if has_top { y_top - inset } else { y_top };
let bottom = if has_bottom {
y_bottom + inset
} else {
y_bottom
};
if has_top && has_bottom {
rounded_rect_path(content, left, top, right, bottom, radii);
return;
}
let max_rx = ((right - left) / 2.0).max(0.0);
let max_ry = ((top - bottom) / 2.0).max(0.0);
let clamp = |(rx, ry): CornerRadiusPx| (rx.clamp(0.0, max_rx), ry.clamp(0.0, max_ry));
let (tl, tr, br, bl) = radii;
if has_top {
let (rx_tl, ry_tl) = clamp(tl);
let (rx_tr, ry_tr) = clamp(tr);
content.move_to(left, bottom);
content.line_to(left, top - ry_tl);
if rx_tl > 0.0 || ry_tl > 0.0 {
let kx = rx_tl * BEZIER_KAPPA;
let ky = ry_tl * BEZIER_KAPPA;
content.cubic_to(
left,
top - ry_tl + ky,
left + rx_tl - kx,
top,
left + rx_tl,
top,
);
}
content.line_to(right - rx_tr, top);
if rx_tr > 0.0 || ry_tr > 0.0 {
let kx = rx_tr * BEZIER_KAPPA;
let ky = ry_tr * BEZIER_KAPPA;
content.cubic_to(
right - rx_tr + kx,
top,
right,
top - ry_tr + ky,
right,
top - ry_tr,
);
}
content.line_to(right, bottom);
} else if has_bottom {
let (rx_br, ry_br) = clamp(br);
let (rx_bl, ry_bl) = clamp(bl);
content.move_to(right, top);
content.line_to(right, bottom + ry_br);
if rx_br > 0.0 || ry_br > 0.0 {
let kx = rx_br * BEZIER_KAPPA;
let ky = ry_br * BEZIER_KAPPA;
content.cubic_to(
right,
bottom + ry_br - ky,
right - rx_br + kx,
bottom,
right - rx_br,
bottom,
);
}
content.line_to(left + rx_bl, bottom);
if rx_bl > 0.0 || ry_bl > 0.0 {
let kx = rx_bl * BEZIER_KAPPA;
let ky = ry_bl * BEZIER_KAPPA;
content.cubic_to(
left + rx_bl - kx,
bottom,
left,
bottom + ry_bl - ky,
left,
bottom + ry_bl,
);
}
content.line_to(left, top);
} else {
content.move_to(left, bottom);
content.line_to(left, top);
content.move_to(right, top);
content.line_to(right, bottom);
}
}
fn is_uniform_border(style: &ComputedStyle) -> bool {
style.border_top_width == style.border_right_width
&& style.border_top_width == style.border_bottom_width
&& style.border_top_width == style.border_left_width
&& style.border_top_style == style.border_right_style
&& style.border_top_style == style.border_bottom_style
&& style.border_top_style == style.border_left_style
&& style.border_top_color == style.border_right_color
&& style.border_top_color == style.border_bottom_color
&& style.border_top_color == style.border_left_color
}
const BEZIER_KAPPA: f32 = 0.552_284_8;
fn rounded_rect_path(
content: &mut RenderTarget<'_>,
x0: f32,
y_top: f32,
x1: f32,
y_bottom: f32,
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
) {
let max_rx = ((x1 - x0) / 2.0).max(0.0);
let max_ry = ((y_top - y_bottom) / 2.0).max(0.0);
let clamp = |(rx, ry): CornerRadiusPx| (rx.clamp(0.0, max_rx), ry.clamp(0.0, max_ry));
let (tl, tr, br, bl) = radii;
let (rx_tl, ry_tl) = clamp(tl);
let (rx_tr, ry_tr) = clamp(tr);
let (rx_br, ry_br) = clamp(br);
let (rx_bl, ry_bl) = clamp(bl);
content.move_to(x0 + rx_tl, y_top);
content.line_to(x1 - rx_tr, y_top);
if rx_tr > 0.0 || ry_tr > 0.0 {
let kx = rx_tr * BEZIER_KAPPA;
let ky = ry_tr * BEZIER_KAPPA;
content.cubic_to(
x1 - rx_tr + kx,
y_top,
x1,
y_top - ry_tr + ky,
x1,
y_top - ry_tr,
);
}
content.line_to(x1, y_bottom + ry_br);
if rx_br > 0.0 || ry_br > 0.0 {
let kx = rx_br * BEZIER_KAPPA;
let ky = ry_br * BEZIER_KAPPA;
content.cubic_to(
x1,
y_bottom + ry_br - ky,
x1 - rx_br + kx,
y_bottom,
x1 - rx_br,
y_bottom,
);
}
content.line_to(x0 + rx_bl, y_bottom);
if rx_bl > 0.0 || ry_bl > 0.0 {
let kx = rx_bl * BEZIER_KAPPA;
let ky = ry_bl * BEZIER_KAPPA;
content.cubic_to(
x0 + rx_bl - kx,
y_bottom,
x0,
y_bottom + ry_bl - ky,
x0,
y_bottom + ry_bl,
);
}
content.line_to(x0, y_top - ry_tl);
if rx_tl > 0.0 || ry_tl > 0.0 {
let kx = rx_tl * BEZIER_KAPPA;
let ky = ry_tl * BEZIER_KAPPA;
content.cubic_to(
x0,
y_top - ry_tl + ky,
x0 + rx_tl - kx,
y_top,
x0 + rx_tl,
y_top,
);
}
content.close_path();
}
fn shrink_radii(
radii: (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
),
inset: f32,
) -> (
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
CornerRadiusPx,
) {
let shrink = |(rx, ry): CornerRadiusPx| ((rx - inset).max(0.0), (ry - inset).max(0.0));
(
shrink(radii.0),
shrink(radii.1),
shrink(radii.2),
shrink(radii.3),
)
}
fn render_outline(
content: &mut RenderTarget<'_>,
layout: &Layout,
style: &ComputedStyle,
settings: &PageSettings,
) {
let t = style.outline_width.0;
if t <= 0.0 || style.outline_style == BorderStyle::None {
return;
}
let border_box = layout.border_box();
let x0 = settings.margin.left + border_box.x;
let x1 = x0 + border_box.width;
let y_top = to_pdf_y(settings, border_box.y);
let y_bottom = to_pdf_y(settings, border_box.y + border_box.height);
let tl_inner = (x0, y_top);
let tr_inner = (x1, y_top);
let br_inner = (x1, y_bottom);
let bl_inner = (x0, y_bottom);
let tl_outer = (x0 - t, y_top + t);
let tr_outer = (x1 + t, y_top + t);
let br_outer = (x1 + t, y_bottom - t);
let bl_outer = (x0 - t, y_bottom - t);
render_border_side(
content,
BorderSideKind::Top,
style.outline_style,
style.outline_color,
t,
BorderSideCorners::new(tl_outer, tr_outer, tr_inner, tl_inner),
);
render_border_side(
content,
BorderSideKind::Right,
style.outline_style,
style.outline_color,
t,
BorderSideCorners::new(tr_outer, br_outer, br_inner, tr_inner),
);
render_border_side(
content,
BorderSideKind::Bottom,
style.outline_style,
style.outline_color,
t,
BorderSideCorners::new(br_outer, bl_outer, bl_inner, br_inner),
);
render_border_side(
content,
BorderSideKind::Left,
style.outline_style,
style.outline_color,
t,
BorderSideCorners::new(bl_outer, tl_outer, tl_inner, bl_inner),
);
}
fn render_border(
content: &mut RenderTarget<'_>,
layout: &Layout,
style: &ComputedStyle,
settings: &PageSettings,
) {
let border_box = layout.border_box();
let x0 = settings.margin.left + border_box.x;
let x1 = x0 + border_box.width;
let y_top = to_pdf_y(settings, border_box.y);
let y_bottom = to_pdf_y(settings, border_box.y + border_box.height);
let t = layout.border;
let tl_outer = (x0, y_top);
let tr_outer = (x1, y_top);
let br_outer = (x1, y_bottom);
let bl_outer = (x0, y_bottom);
let tl_inner = (x0 + t.left, y_top - t.top);
let tr_inner = (x1 - t.right, y_top - t.top);
let br_inner = (x1 - t.right, y_bottom + t.bottom);
let bl_inner = (x0 + t.left, y_bottom + t.bottom);
render_border_side(
content,
BorderSideKind::Top,
style.border_top_style,
style.border_top_color,
t.top,
BorderSideCorners::new(tl_outer, tr_outer, tr_inner, tl_inner),
);
render_border_side(
content,
BorderSideKind::Right,
style.border_right_style,
style.border_right_color,
t.right,
BorderSideCorners::new(tr_outer, br_outer, br_inner, tr_inner),
);
render_border_side(
content,
BorderSideKind::Bottom,
style.border_bottom_style,
style.border_bottom_color,
t.bottom,
BorderSideCorners::new(br_outer, bl_outer, bl_inner, br_inner),
);
render_border_side(
content,
BorderSideKind::Left,
style.border_left_style,
style.border_left_color,
t.left,
BorderSideCorners::new(bl_outer, tl_outer, tl_inner, bl_inner),
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BorderSideKind {
Top,
Right,
Bottom,
Left,
}
fn lighten(color: RgbaColor, amount: f32) -> RgbaColor {
let mix = |c: u8| (c as f32 + (255.0 - c as f32) * amount).round() as u8;
RgbaColor {
red: mix(color.red),
green: mix(color.green),
blue: mix(color.blue),
alpha: color.alpha,
}
}
fn darken(color: RgbaColor, amount: f32) -> RgbaColor {
let mix = |c: u8| (c as f32 * (1.0 - amount)).round() as u8;
RgbaColor {
red: mix(color.red),
green: mix(color.green),
blue: mix(color.blue),
alpha: color.alpha,
}
}
const SHADE_AMOUNT: f32 = 0.35;
struct BorderSideColors {
outer: RgbaColor,
inner: RgbaColor,
}
fn border_side_colors(
border_style: BorderStyle,
side: BorderSideKind,
color: RgbaColor,
) -> BorderSideColors {
let light = lighten(color, SHADE_AMOUNT);
let dark = darken(color, SHADE_AMOUNT);
let is_top_or_left = matches!(side, BorderSideKind::Top | BorderSideKind::Left);
match border_style {
BorderStyle::Inset => {
let c = if is_top_or_left { dark } else { light };
BorderSideColors { outer: c, inner: c }
}
BorderStyle::Outset => {
let c = if is_top_or_left { light } else { dark };
BorderSideColors { outer: c, inner: c }
}
BorderStyle::Groove => {
if is_top_or_left {
BorderSideColors {
outer: dark,
inner: light,
}
} else {
BorderSideColors {
outer: light,
inner: dark,
}
}
}
BorderStyle::Ridge => {
if is_top_or_left {
BorderSideColors {
outer: light,
inner: dark,
}
} else {
BorderSideColors {
outer: dark,
inner: light,
}
}
}
_ => BorderSideColors {
outer: color,
inner: color,
},
}
}
struct BorderSideCorners {
outer_a: (f32, f32),
outer_b: (f32, f32),
inner_b: (f32, f32),
inner_a: (f32, f32),
}
impl BorderSideCorners {
fn new(
outer_a: (f32, f32),
outer_b: (f32, f32),
inner_b: (f32, f32),
inner_a: (f32, f32),
) -> Self {
Self {
outer_a,
outer_b,
inner_b,
inner_a,
}
}
}
fn render_border_side(
content: &mut RenderTarget<'_>,
side: BorderSideKind,
border_style: BorderStyle,
color: RgbaColor,
thickness: f32,
corners: BorderSideCorners,
) {
if thickness <= 0.0 || border_style == BorderStyle::None {
return;
}
let BorderSideCorners {
outer_a,
outer_b,
inner_b,
inner_a,
} = corners;
match border_style {
BorderStyle::Solid => {
content.set_fill_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
fill_quad(content, outer_a, outer_b, inner_b, inner_a);
}
BorderStyle::Groove | BorderStyle::Ridge | BorderStyle::Inset | BorderStyle::Outset => {
let colors = border_side_colors(border_style, side, color);
for (t0, t1, band_color) in [(0.0, 0.5, colors.outer), (0.5, 1.0, colors.inner)] {
content.set_fill_rgb(
band_color.red as f32 / 255.0,
band_color.green as f32 / 255.0,
band_color.blue as f32 / 255.0,
);
fill_quad(
content,
lerp(outer_a, inner_a, t0),
lerp(outer_b, inner_b, t0),
lerp(outer_b, inner_b, t1),
lerp(outer_a, inner_a, t1),
);
}
}
BorderStyle::Double => {
content.set_fill_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
const BAND: f32 = 1.0 / 3.0;
for (t0, t1) in [(0.0, BAND), (1.0 - BAND, 1.0)] {
fill_quad(
content,
lerp(outer_a, inner_a, t0),
lerp(outer_b, inner_b, t0),
lerp(outer_b, inner_b, t1),
lerp(outer_a, inner_a, t1),
);
}
}
BorderStyle::Dashed | BorderStyle::Dotted => {
content.set_stroke_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
content.set_line_width(thickness);
apply_border_style_dash(content, border_style, thickness);
let from = lerp(outer_a, inner_a, 0.5);
let to = lerp(outer_b, inner_b, 0.5);
content.move_to(from.0, from.1);
content.line_to(to.0, to.1);
content.stroke();
}
BorderStyle::None => {}
}
}
fn stroke_line(
content: &mut RenderTarget<'_>,
thickness: f32,
color: RgbaColor,
from: (f32, f32),
to: (f32, f32),
) {
if thickness <= 0.0 {
return;
}
content.set_stroke_rgb(
color.red as f32 / 255.0,
color.green as f32 / 255.0,
color.blue as f32 / 255.0,
);
content.set_line_width(thickness);
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([], 0.0);
content.move_to(from.0, from.1);
content.line_to(to.0, to.1);
content.stroke();
}
fn lerp(a: (f32, f32), b: (f32, f32), t: f32) -> (f32, f32) {
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
}
fn fill_quad(
content: &mut RenderTarget<'_>,
a: (f32, f32),
b: (f32, f32),
c: (f32, f32),
d: (f32, f32),
) {
content.move_to(a.0, a.1);
content.line_to(b.0, b.1);
content.line_to(c.0, c.1);
content.line_to(d.0, d.1);
content.close_path();
content.fill_nonzero();
}
fn apply_border_style_dash(
content: &mut RenderTarget<'_>,
border_style: BorderStyle,
thickness: f32,
) {
match border_style {
BorderStyle::Solid
| BorderStyle::Double
| BorderStyle::Groove
| BorderStyle::Ridge
| BorderStyle::Inset
| BorderStyle::Outset => {
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([], 0.0);
}
BorderStyle::Dashed => {
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([thickness * 3.0], 0.0);
}
BorderStyle::Dotted => {
content.set_line_cap(LineCapStyle::RoundCap);
content.set_dash_pattern([0.01, thickness * 2.0], 0.0);
}
BorderStyle::None => {}
}
}
const ITALIC_SHEAR: f32 = 0.2126; const BOLD_STROKE_RATIO: f32 = 0.03;
struct RunSegment<'a> {
x: f32,
first: GlyphTarget<'a>,
range: std::ops::Range<usize>,
}
fn run_segments<'a>(
run: &TextRun,
text_fonts: &'a TextFonts<'a>,
include_color: bool,
codes: &mut Vec<u16>,
) -> Vec<RunSegment<'a>> {
let mut segments: Vec<RunSegment<'a>> = Vec::new();
codes.clear();
codes.resize(run.glyphs.len(), 0);
if !text_fonts.any_color {
let Some(simple) = text_fonts.plan.simple(run.font_index) else {
return segments;
};
let Some(remaps) = text_fonts.remaps else {
let Some(first) = run.glyphs.first() else {
return segments;
};
for (code, glyph) in codes.iter_mut().zip(&run.glyphs) {
*code = glyph.glyph_id;
}
segments.push(RunSegment {
x: 0.0,
first: GlyphTarget::Simple {
name: &simple.name,
cid: first.glyph_id,
},
range: 0..run.glyphs.len(),
});
return segments;
};
let remap = remaps.get(run.font_index);
let mut x = 0.0;
for (index, glyph) in run.glyphs.iter().enumerate() {
if let Some(&cid) = remap.and_then(|m| m.get(&glyph.glyph_id)) {
codes[index] = cid;
match segments.last_mut() {
Some(last) if last.range.end == index => last.range.end = index + 1,
_ => segments.push(RunSegment {
x,
first: GlyphTarget::Simple {
name: &simple.name,
cid,
},
range: index..index + 1,
}),
}
}
x += glyph.x_advance + run.letter_spacing;
}
return segments;
}
let mut x = 0.0;
for (index, glyph) in run.glyphs.iter().enumerate() {
let target = match text_fonts.target(run.font_index, glyph.glyph_id) {
GlyphTarget::Color { .. } if !include_color => GlyphTarget::Dropped,
target => target,
};
if target != GlyphTarget::Dropped {
codes[index] = match target {
GlyphTarget::Simple { cid, .. } => cid,
GlyphTarget::Color { code, .. } => code as u16,
GlyphTarget::Dropped => 0,
};
match segments.last_mut() {
Some(last)
if last.range.end == index
&& last.first.resource_name() == target.resource_name() =>
{
last.range.end = index + 1;
}
_ => segments.push(RunSegment {
x,
first: target,
range: index..index + 1,
}),
}
}
x += glyph.x_advance + run.letter_spacing;
}
segments
}
const ADVANCE_EPSILON: f32 = 0.01;
fn show_segment_glyphs(
content: &mut RenderTarget<'_>,
run: &TextRun,
font: &Font,
codes: &[u16],
segment: &RunSegment<'_>,
) {
let glyphs = &run.glyphs[segment.range.clone()];
let codes = &codes[segment.range.clone()];
let color_segment = matches!(segment.first, GlyphTarget::Color { .. });
let push_code = |out: &mut Vec<u8>, code: u16| {
if color_segment {
out.push(code as u8);
} else {
out.extend_from_slice(&code.to_be_bytes());
}
};
let units_per_em = font.units_per_em() as f32;
if run.font_size <= 0.0 || units_per_em <= 0.0 {
let mut glyph_bytes = Vec::with_capacity(glyphs.len() * 2);
for &code in codes {
push_code(&mut glyph_bytes, code);
}
content.show(pdf_writer::Str(&glyph_bytes));
return;
}
let mut positioned = content.show_positioned();
let mut items = positioned.items();
let mut pending = Vec::with_capacity(glyphs.len() * 2);
for (glyph, &code) in glyphs.iter().zip(codes) {
push_code(&mut pending, code);
let pdf_advance = font.glyph_hor_advance(glyph.glyph_id).unwrap_or(0) as f32
* run.font_size
/ units_per_em;
let delta = glyph.x_advance - pdf_advance;
if delta.abs() < ADVANCE_EPSILON {
continue;
}
items.show(pdf_writer::Str(&pending));
pending.clear();
let adjustment = (-delta * 1000.0 / run.font_size * 100.0).round() / 100.0;
items.adjust(adjustment);
}
if !pending.is_empty() {
items.show(pdf_writer::Str(&pending));
}
}
fn render_line(
content: &mut RenderTarget<'_>,
line: &LineBox,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
alpha_gs_names: &[String],
) {
if line.runs.is_empty() {
return;
}
let baseline_y = to_pdf_y(settings, line.rect.y + line.baseline);
for run in &line.runs {
if run.background_color.alpha <= 0.0 || run.width <= 0.0 {
continue;
}
let run_baseline_y = baseline_y + run.baseline_shift;
let use_alpha = run.background_color.alpha < 1.0;
if use_alpha {
content.save_state();
apply_fill_alpha(content, run.background_color.alpha, alpha_gs_names);
}
content.set_fill_rgb(
run.background_color.red as f32 / 255.0,
run.background_color.green as f32 / 255.0,
run.background_color.blue as f32 / 255.0,
);
content.rect(
settings.margin.left + line.rect.x + run.x_offset,
run_baseline_y - run.descent,
run.width,
run.ascent + run.descent,
);
content.fill_nonzero();
if use_alpha {
content.restore_state();
}
}
render_text_shadows(
content,
line,
fonts,
settings,
text_fonts,
alpha_gs_names,
baseline_y,
);
content.begin_text();
let mut glyph_codes: Vec<u16> = Vec::new();
const WORD_GAP_EPSILON: f32 = 0.01;
let mut previous_run_end: Option<f32> = None;
for run in &line.runs {
if run.glyphs.is_empty() {
continue;
}
if let Some(prev_end) = previous_run_end {
if run.x_offset > prev_end + WORD_GAP_EPSILON {
let mut marked = content.begin_marked_content_with_properties(Name(b"Span"));
marked.properties().actual_text(TextStr(" "));
marked.finish();
content.end_marked_content();
}
}
previous_run_end = Some(run.x_offset + run.width);
let Some(font) = fonts.get(run.font_index) else {
continue;
};
content.set_fill_rgb(
run.color.red as f32 / 255.0,
run.color.green as f32 / 255.0,
run.color.blue as f32 / 255.0,
);
if run.bold {
content.set_stroke_rgb(
run.color.red as f32 / 255.0,
run.color.green as f32 / 255.0,
run.color.blue as f32 / 255.0,
);
content.set_line_width(run.font_size * BOLD_STROKE_RATIO);
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([], 0.0);
content.set_text_rendering_mode(TextRenderingMode::FillStroke);
} else {
content.set_text_rendering_mode(TextRenderingMode::Fill);
}
let x = settings.margin.left + line.rect.x + run.x_offset;
let shear = if run.italic { ITALIC_SHEAR } else { 0.0 };
content.set_char_spacing(run.letter_spacing);
for segment in run_segments(run, text_fonts, true, &mut glyph_codes) {
let Some(resource_name) = segment.first.resource_name() else {
continue;
};
content.set_font(Name(resource_name.as_bytes()), run.font_size);
content.set_text_matrix([
1.0,
0.0,
shear,
1.0,
x + segment.x,
baseline_y + run.baseline_shift,
]);
show_segment_glyphs(content, run, font, &glyph_codes, &segment);
}
}
content.end_text();
for run in &line.runs {
if !run.underline && !run.line_through {
continue;
}
let Some(font) = fonts.get(run.font_index) else {
continue;
};
let x = settings.margin.left + line.rect.x + run.x_offset;
let run_baseline_y = baseline_y + run.baseline_shift;
if run.underline {
let (y, thickness) =
decoration_metrics(font, run.font_size, font.underline_metrics(), -0.1);
stroke_line(
content,
thickness,
run.color,
(x, run_baseline_y + y),
(x + run.width, run_baseline_y + y),
);
}
if run.line_through {
let (y, thickness) =
decoration_metrics(font, run.font_size, font.strikeout_metrics(), 0.3);
stroke_line(
content,
thickness,
run.color,
(x, run_baseline_y + y),
(x + run.width, run_baseline_y + y),
);
}
}
render_emphasis_marks(content, line, fonts, settings, text_fonts, baseline_y);
}
#[allow(clippy::too_many_arguments)]
fn render_emphasis_marks(
content: &mut RenderTarget<'_>,
line: &LineBox,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
baseline_y: f32,
) {
for run in &line.runs {
let Some(mark) = &run.emphasis else {
continue;
};
let run_baseline_y = baseline_y + run.baseline_shift;
let center_y = match mark.position {
EmphasisPosition::Over => run_baseline_y + run.ascent - mark.size / 2.0,
EmphasisPosition::Under => run_baseline_y - run.descent + mark.size / 2.0,
};
let mut x = settings.margin.left + line.rect.x + run.x_offset;
for glyph in &run.glyphs {
let advance = glyph.x_advance + run.letter_spacing;
let ch = run
.text
.get(glyph.cluster as usize..)
.and_then(|rest| rest.chars().next());
if !ch.is_some_and(|ch| ch.is_whitespace()) {
render_emphasis_mark(
content,
mark,
x + advance / 2.0,
center_y,
run,
fonts,
text_fonts,
);
}
x += advance;
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_emphasis_mark(
content: &mut RenderTarget<'_>,
mark: &EmphasisMark,
center_x: f32,
center_y: f32,
run: &TextRun,
fonts: &FontCollection,
text_fonts: &TextFonts<'_>,
) {
let (r, g, b) = (
mark.color.red as f32 / 255.0,
mark.color.green as f32 / 255.0,
mark.color.blue as f32 / 255.0,
);
let (shape, filled) = match &mark.style {
EmphasisStyle::None => return,
EmphasisStyle::Shape { shape, filled } => (*shape, *filled),
EmphasisStyle::String(ch) => {
render_emphasis_glyph(
content, *ch, center_x, center_y, mark, run, fonts, text_fonts,
);
return;
}
};
content.set_fill_rgb(r, g, b);
content.set_stroke_rgb(r, g, b);
let stroke_width = (mark.size * 0.08).max(0.3);
content.set_line_width(stroke_width);
content.set_line_cap(LineCapStyle::ButtCap);
content.set_dash_pattern([], 0.0);
match shape {
EmphasisShape::Dot => {
circle_path(content, center_x, center_y, mark.size * 0.16);
finish_mark_path(content, filled);
}
EmphasisShape::Circle => {
circle_path(content, center_x, center_y, mark.size * 0.3);
finish_mark_path(content, filled);
}
EmphasisShape::DoubleCircle => {
circle_path(content, center_x, center_y, mark.size * 0.34);
finish_mark_path(content, false);
circle_path(content, center_x, center_y, mark.size * 0.15);
finish_mark_path(content, filled);
}
EmphasisShape::Triangle => {
let s = mark.size * 0.34;
content.move_to(center_x, center_y + s);
content.line_to(center_x + s, center_y - s);
content.line_to(center_x - s, center_y - s);
content.close_path();
finish_mark_path(content, filled);
}
EmphasisShape::Sesame => {
ellipse_path(
content,
center_x,
center_y,
mark.size * 0.12,
mark.size * 0.3,
);
finish_mark_path(content, filled);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_emphasis_glyph(
content: &mut RenderTarget<'_>,
ch: char,
center_x: f32,
center_y: f32,
mark: &EmphasisMark,
run: &TextRun,
fonts: &FontCollection,
text_fonts: &TextFonts<'_>,
) {
let Some(glyph_id) = fonts.get(run.font_index).and_then(|font| font.glyph_id(ch)) else {
return;
};
let target = text_fonts.target(run.font_index, glyph_id);
let Some(resource_name) = target.resource_name() else {
return;
};
let code = target.code_bytes();
if code.iter().all(|&b| b == 0) {
return;
}
content.begin_text();
content.set_fill_rgb(
mark.color.red as f32 / 255.0,
mark.color.green as f32 / 255.0,
mark.color.blue as f32 / 255.0,
);
content.set_text_rendering_mode(TextRenderingMode::Fill);
content.set_font(Name(resource_name.as_bytes()), mark.size);
content.set_char_spacing(0.0);
content.set_text_matrix([
1.0,
0.0,
0.0,
1.0,
center_x - mark.size / 2.0,
center_y - mark.size / 2.0,
]);
content.show(pdf_writer::Str(&code));
content.end_text();
}
fn finish_mark_path(content: &mut RenderTarget<'_>, filled: bool) {
if filled {
content.fill_nonzero();
} else {
content.stroke();
}
}
fn circle_path(content: &mut RenderTarget<'_>, cx: f32, cy: f32, r: f32) {
ellipse_path(content, cx, cy, r, r);
}
fn ellipse_path(content: &mut RenderTarget<'_>, cx: f32, cy: f32, rx: f32, ry: f32) {
let (kx, ky) = (rx * BEZIER_KAPPA, ry * BEZIER_KAPPA);
content.move_to(cx + rx, cy);
content.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
content.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
content.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
content.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
content.close_path();
}
const TEXT_SHADOW_BLUR_STEPS: usize = 2;
#[allow(clippy::too_many_arguments)]
fn render_text_shadows(
content: &mut RenderTarget<'_>,
line: &LineBox,
fonts: &FontCollection,
settings: &PageSettings,
text_fonts: &TextFonts<'_>,
alpha_gs_names: &[String],
baseline_y: f32,
) {
let mut glyph_codes: Vec<u16> = Vec::new();
for run in &line.runs {
let Some(shadows) = run.text_shadow.as_deref() else {
continue;
};
if shadows.is_empty() || run.glyphs.is_empty() {
continue;
}
let Some(font) = fonts.get(run.font_index) else {
continue;
};
let segments = run_segments(run, text_fonts, false, &mut glyph_codes);
if segments.is_empty() {
continue;
}
let x = settings.margin.left + line.rect.x + run.x_offset;
let run_baseline_y = baseline_y + run.baseline_shift;
let shear = if run.italic { ITALIC_SHEAR } else { 0.0 };
for shadow in shadows.iter().rev() {
for (dx, dy, alpha_scale) in shadow_blur_offsets(shadow.blur_radius) {
let alpha = shadow.color.alpha * alpha_scale;
if quantize_alpha_step_is_transparent(alpha) {
continue;
}
content.save_state();
apply_fill_alpha(content, alpha, alpha_gs_names);
content.begin_text();
content.set_fill_rgb(
shadow.color.red as f32 / 255.0,
shadow.color.green as f32 / 255.0,
shadow.color.blue as f32 / 255.0,
);
content.set_text_rendering_mode(TextRenderingMode::Fill);
content.set_char_spacing(run.letter_spacing);
for segment in &segments {
let Some(resource_name) = segment.first.resource_name() else {
continue;
};
content.set_font(Name(resource_name.as_bytes()), run.font_size);
content.set_text_matrix([
1.0,
0.0,
shear,
1.0,
x + segment.x + shadow.offset_x + dx,
run_baseline_y - shadow.offset_y - dy,
]);
show_segment_glyphs(content, run, font, &glyph_codes, segment);
}
content.end_text();
content.restore_state();
}
}
}
}
fn shadow_blur_offsets(blur_radius: f32) -> Vec<(f32, f32, f32)> {
if blur_radius <= 0.0 {
return vec![(0.0, 0.0, 1.0)];
}
let mut offsets = Vec::with_capacity(1 + TEXT_SHADOW_BLUR_STEPS * 4);
let count = 1 + TEXT_SHADOW_BLUR_STEPS * 4;
let alpha_scale = 1.0 / count as f32;
offsets.push((0.0, 0.0, alpha_scale));
for step in 1..=TEXT_SHADOW_BLUR_STEPS {
let r = blur_radius * step as f32 / TEXT_SHADOW_BLUR_STEPS as f32;
offsets.push((r, 0.0, alpha_scale));
offsets.push((-r, 0.0, alpha_scale));
offsets.push((0.0, r, alpha_scale));
offsets.push((0.0, -r, alpha_scale));
}
offsets
}
fn quantize_alpha_step_is_transparent(alpha: f32) -> bool {
quantize_alpha_step(alpha) == 0
}
fn decoration_metrics(
font: &crate::fonts::Font,
font_size: f32,
metrics: Option<(i16, i16)>,
fallback_ratio: f32,
) -> (f32, f32) {
let units_per_em = font.units_per_em() as f32;
match metrics {
Some((position, thickness)) if thickness > 0 => (
position as f32 / units_per_em * font_size,
thickness as f32 / units_per_em * font_size,
),
_ => (font_size * fallback_ratio, font_size * 0.05),
}
}
fn to_pdf_y(settings: &PageSettings, y_from_content_top: f32) -> f32 {
settings.size.height - settings.margin.top - y_from_content_top
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum HAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum VAlign {
Top,
Middle,
Bottom,
}
fn margin_box_area_rect(area: MarginBoxArea, settings: &PageSettings) -> (Rect, HAlign, VAlign) {
let m = settings.margin;
let content_width = settings.content_width();
let content_height = settings.content_height();
let strip_w = content_width / 3.0;
let strip_h = content_height / 3.0;
use MarginBoxArea::*;
match area {
TopLeftCorner => (
rect(-m.left, -m.top, m.left, m.top),
HAlign::Left,
VAlign::Middle,
),
TopLeft => (
rect(0.0, -m.top, strip_w, m.top),
HAlign::Left,
VAlign::Middle,
),
TopCenter => (
rect(strip_w, -m.top, strip_w, m.top),
HAlign::Center,
VAlign::Middle,
),
TopRight => (
rect(strip_w * 2.0, -m.top, strip_w, m.top),
HAlign::Right,
VAlign::Middle,
),
TopRightCorner => (
rect(content_width, -m.top, m.right, m.top),
HAlign::Right,
VAlign::Middle,
),
BottomLeftCorner => (
rect(-m.left, content_height, m.left, m.bottom),
HAlign::Left,
VAlign::Middle,
),
BottomLeft => (
rect(0.0, content_height, strip_w, m.bottom),
HAlign::Left,
VAlign::Middle,
),
BottomCenter => (
rect(strip_w, content_height, strip_w, m.bottom),
HAlign::Center,
VAlign::Middle,
),
BottomRight => (
rect(strip_w * 2.0, content_height, strip_w, m.bottom),
HAlign::Right,
VAlign::Middle,
),
BottomRightCorner => (
rect(content_width, content_height, m.right, m.bottom),
HAlign::Right,
VAlign::Middle,
),
LeftTop => (
rect(-m.left, 0.0, m.left, strip_h),
HAlign::Center,
VAlign::Top,
),
LeftMiddle => (
rect(-m.left, strip_h, m.left, strip_h),
HAlign::Center,
VAlign::Middle,
),
LeftBottom => (
rect(-m.left, strip_h * 2.0, m.left, strip_h),
HAlign::Center,
VAlign::Bottom,
),
RightTop => (
rect(content_width, 0.0, m.right, strip_h),
HAlign::Center,
VAlign::Top,
),
RightMiddle => (
rect(content_width, strip_h, m.right, strip_h),
HAlign::Center,
VAlign::Middle,
),
RightBottom => (
rect(content_width, strip_h * 2.0, m.right, strip_h),
HAlign::Center,
VAlign::Bottom,
),
}
}
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
Rect {
x,
y,
width,
height,
}
}
fn margin_box_style(decls: &[PropertyDeclaration]) -> ComputedStyle {
let mut style = ComputedStyle::default();
for decl in decls {
match decl {
PropertyDeclaration::FontSize(v) => {
style.font_size = v.resolve(style.font_size.0, style.font_size.0)
}
PropertyDeclaration::FontFamily(v) => style.font_family = v.clone(),
PropertyDeclaration::FontWeight(v) => style.font_weight = *v,
PropertyDeclaration::FontStyle(v) => style.font_style = *v,
PropertyDeclaration::Color(Color::Rgba {
red,
green,
blue,
alpha,
}) => {
style.color = RgbaColor {
red: *red,
green: *green,
blue: *blue,
alpha: *alpha,
}
}
_ => {}
}
}
style
}
struct ShapedMarginBox {
rect: Rect,
h_align: HAlign,
v_align: VAlign,
line: LineBox,
}
#[derive(Clone)]
pub struct PageOverlay {
pub boxes: Vec<LaidOutBox>,
pub background_images: HashMap<NodeId, Rc<PreparedImage>>,
pub styles: HashMap<NodeId, Rc<ComputedStyle>>,
pub settings: PageSettings,
pub clip: Rect,
}
pub(super) fn render_page_overlay(
content: &mut RenderTarget<'_>,
overlay: &PageOverlay,
fonts: &FontCollection,
text_fonts: &TextFonts<'_>,
alpha_gs_names: &[String],
image_ids: &HashMap<usize, ImageIds>,
) {
if overlay.boxes.is_empty() {
return;
}
let empty_form_ids: HashMap<NodeId, Ref> = HashMap::new();
let mut pending_forms: Vec<(Ref, Vec<u8>)> = Vec::new();
content.save_state();
let y = overlay.settings.size.height - overlay.clip.y - overlay.clip.height;
content.rect(overlay.clip.x, y, overlay.clip.width, overlay.clip.height);
content.clip_nonzero();
content.end_path();
for b in &overlay.boxes {
render_box(
content,
b,
&overlay.styles,
fonts,
&overlay.settings,
text_fonts,
image_ids,
&overlay.background_images,
alpha_gs_names,
&empty_form_ids,
&mut pending_forms,
);
}
content.restore_state();
}
pub(super) fn render_header_footer_rules(
content: &mut RenderTarget<'_>,
settings: &PageSettings,
header_line: bool,
footer_line: bool,
) {
if !header_line && !footer_line {
return;
}
let x0 = settings.margin.left;
let x1 = settings.size.width - settings.margin.right;
content.save_state();
content.set_stroke_rgb(0.0, 0.0, 0.0);
content.set_line_width(1.0);
if header_line {
let y = to_pdf_y(settings, 0.0);
content.move_to(x0, y);
content.line_to(x1, y);
content.stroke();
}
if footer_line {
let y = to_pdf_y(settings, settings.content_height());
content.move_to(x0, y);
content.line_to(x1, y);
content.stroke();
}
content.restore_state();
}
fn shape_margin_boxes_for_page(
settings: &PageSettings,
fonts: &FontCollection,
page_rules: &[PageRule],
page_number: usize,
total_pages: Option<usize>,
) -> Vec<ShapedMarginBox> {
if page_rules.is_empty() {
return Vec::new();
}
let is_first = page_number == 1;
let is_left = page_number.is_multiple_of(2);
let resolved = resolve_page_rules(page_rules, is_first, is_left);
resolved
.margin_boxes
.iter()
.filter_map(|(area, decls)| {
let content_decl = decls.iter().rev().find_map(|d| match d {
PropertyDeclaration::Content(parts) => Some(parts.clone()),
_ => None,
})?;
let parts = content_decl?;
let text = resolve_margin_box_content(&parts, page_number, total_pages);
if text.is_empty() {
return None;
}
let style = margin_box_style(decls);
let (rect, h_align, v_align) = margin_box_area_rect(*area, settings);
let line = shape_standalone_line(&text, &style, fonts, 0.0, 0.0);
Some(ShapedMarginBox {
rect,
h_align,
v_align,
line,
})
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_margin_boxes(
content: &mut RenderTarget<'_>,
settings: &PageSettings,
fonts: &FontCollection,
page_rules: &[PageRule],
page_number: usize,
total_pages: Option<usize>,
text_fonts: &TextFonts<'_>,
) {
for shaped in shape_margin_boxes_for_page(settings, fonts, page_rules, page_number, total_pages)
{
let mut line = shaped.line;
line.rect.x = match shaped.h_align {
HAlign::Left => shaped.rect.x,
HAlign::Center => shaped.rect.x + (shaped.rect.width - line.rect.width) / 2.0,
HAlign::Right => shaped.rect.x + shaped.rect.width - line.rect.width,
};
line.rect.y = match shaped.v_align {
VAlign::Top => shaped.rect.y,
VAlign::Middle => shaped.rect.y + (shaped.rect.height - line.rect.height) / 2.0,
VAlign::Bottom => shaped.rect.y + shaped.rect.height - line.rect.height,
};
render_line(content, &line, fonts, settings, text_fonts, &[]);
}
}
pub(super) fn collect_margin_box_usage(
settings: &PageSettings,
fonts: &FontCollection,
page_rules: &[PageRule],
page_number: usize,
total_pages: Option<usize>,
usages: &mut [FontUsage],
) {
for shaped in shape_margin_boxes_for_page(settings, fonts, page_rules, page_number, total_pages)
{
collect_line_usage(&shaped.line, fonts, usages);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::Font;
use crate::html;
use crate::layout::{paginate_document, PageSize};
use crate::sink::MemorySink;
use crate::style::BackgroundPosition;
use crate::style::{compute_styles, parse_stylesheet, user_agent_stylesheet, Stylesheet};
const DEJAVU_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fonts/DejaVuSans.ttf");
const CJK_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fonts/NotoSansCJK-Regular.ttc"
);
fn test_fonts() -> FontCollection {
FontCollection::new(vec![
Font::load(DEJAVU_PATH).expect("should load bundled test font")
])
}
#[test]
fn margin_box_area_rect_places_corners_and_strips_relative_to_the_content_area() {
let settings = PageSettings {
size: PageSize {
width: 800.0,
height: 1100.0,
},
margin: EdgeSizes {
top: 80.0,
right: 60.0,
bottom: 80.0,
left: 60.0,
},
};
let content_width = settings.content_width();
let content_height = settings.content_height();
let (top_left_corner, h, v) = margin_box_area_rect(MarginBoxArea::TopLeftCorner, &settings);
assert_eq!(
top_left_corner,
Rect {
x: -60.0,
y: -80.0,
width: 60.0,
height: 80.0
}
);
assert_eq!((h, v), (HAlign::Left, VAlign::Middle));
let (top_center, h, v) = margin_box_area_rect(MarginBoxArea::TopCenter, &settings);
assert_eq!(top_center.y, -80.0);
assert_eq!(top_center.height, 80.0);
assert_eq!(top_center.x, content_width / 3.0);
assert_eq!((h, v), (HAlign::Center, VAlign::Middle));
let (bottom_center, h, v) = margin_box_area_rect(MarginBoxArea::BottomCenter, &settings);
assert_eq!(bottom_center.y, content_height);
assert_eq!(bottom_center.height, 80.0);
assert_eq!((h, v), (HAlign::Center, VAlign::Middle));
let (bottom_right_corner, ..) =
margin_box_area_rect(MarginBoxArea::BottomRightCorner, &settings);
assert_eq!(
bottom_right_corner,
Rect {
x: content_width,
y: content_height,
width: 60.0,
height: 80.0
}
);
let (right_middle, h, v) = margin_box_area_rect(MarginBoxArea::RightMiddle, &settings);
assert_eq!(right_middle.x, content_width);
assert_eq!(right_middle.width, 60.0);
assert_eq!(right_middle.y, content_height / 3.0);
assert_eq!((h, v), (HAlign::Center, VAlign::Middle));
}
#[test]
fn background_tile_rects_defaults_to_intrinsic_size_tiled_from_the_top_left() {
let border_box = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 60.0,
};
let style = ComputedStyle::default();
let rects = background_tile_rects(border_box, &style, (40.0, 30.0));
assert!(rects.iter().all(|r| r.width == 40.0 && r.height == 30.0));
assert!(rects.contains(&Rect {
x: 0.0,
y: 0.0,
width: 40.0,
height: 30.0
}));
assert_eq!(rects.len(), 3 * 2);
}
#[test]
fn quantize_alpha_step_rounds_to_the_nearest_of_21_levels() {
assert_eq!(quantize_alpha_step(1.0), ALPHA_STEPS);
assert_eq!(quantize_alpha_step(0.0), 0);
assert_eq!(quantize_alpha_step(0.3), 6);
assert_eq!(quantize_alpha_step(-0.5), 0);
assert_eq!(quantize_alpha_step(1.5), ALPHA_STEPS);
}
fn content_box_150x80() -> Rect {
Rect {
x: 10.0,
y: 20.0,
width: 150.0,
height: 80.0,
}
}
#[test]
fn object_fit_rect_fill_stretches_to_the_content_box_non_uniformly() {
let content_box = content_box_150x80();
let style = ComputedStyle::default(); let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert_eq!(rect, content_box);
}
#[test]
fn object_fit_rect_cover_scales_up_to_fill_and_overflows_the_shorter_axis() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::Cover,
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert!((rect.width - 150.0).abs() < 0.01);
assert!((rect.height - 112.5).abs() < 0.01);
assert!((rect.y - (content_box.y - (112.5 - 80.0) / 2.0)).abs() < 0.01);
}
#[test]
fn object_fit_rect_contain_scales_down_and_letterboxes() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::Contain,
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert!((rect.width - 320.0 / 3.0).abs() < 0.01);
assert!((rect.height - 80.0).abs() < 0.01);
}
#[test]
fn object_fit_rect_none_uses_intrinsic_size_regardless_of_content_box() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::None,
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert_eq!(rect.width, 32.0);
assert_eq!(rect.height, 24.0);
}
#[test]
fn object_fit_rect_scale_down_behaves_like_none_when_intrinsic_already_fits() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::ScaleDown,
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert_eq!(rect.width, 32.0);
assert_eq!(rect.height, 24.0);
}
#[test]
fn object_fit_rect_scale_down_behaves_like_contain_when_intrinsic_overflows() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::ScaleDown,
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (320.0, 240.0));
assert!((rect.height - 80.0).abs() < 0.01);
assert!(rect.width < content_box.width);
}
#[test]
fn object_fit_rect_object_position_moves_the_image_within_the_content_box() {
let content_box = content_box_150x80();
let style = ComputedStyle {
object_fit: ObjectFit::Contain,
object_position: BackgroundPosition {
horizontal: LengthPercentage::Percentage(1.0),
vertical: LengthPercentage::Percentage(1.0),
},
..Default::default()
};
let rect = object_fit_rect(content_box, &style, (32.0, 24.0));
assert!((rect.x - (content_box.x + content_box.width - rect.width)).abs() < 0.01);
}
#[test]
fn background_tile_rects_cover_scales_up_to_fill_the_box_uniformly() {
let border_box = Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 100.0,
};
let style = ComputedStyle {
background_size: BackgroundSize::Cover,
background_repeat: BackgroundRepeat::NoRepeat,
..ComputedStyle::default()
};
let rects = background_tile_rects(border_box, &style, (100.0, 50.0));
assert_eq!(
rects,
vec![Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 100.0
}]
);
}
#[test]
fn background_tile_rects_contain_scales_down_and_centers_by_default_position() {
let border_box = Rect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 100.0,
};
let style = ComputedStyle {
background_size: BackgroundSize::Contain,
background_repeat: BackgroundRepeat::NoRepeat,
background_position: crate::style::BackgroundPosition {
horizontal: LengthPercentage::Percentage(0.5),
vertical: LengthPercentage::Percentage(0.5),
},
..ComputedStyle::default()
};
let rects = background_tile_rects(border_box, &style, (100.0, 100.0));
assert_eq!(
rects,
vec![Rect {
x: 50.0,
y: 0.0,
width: 100.0,
height: 100.0
}]
);
}
#[test]
fn background_tile_rects_caps_tile_count_per_axis_for_pathological_sizes() {
let border_box = Rect {
x: 0.0,
y: 0.0,
width: 100_000.0,
height: 10.0,
};
let style = ComputedStyle {
background_size: BackgroundSize::WidthHeight(
LengthPercentageOrAuto::LengthPercentage(LengthPercentage::Length(1.0)),
LengthPercentageOrAuto::LengthPercentage(LengthPercentage::Length(10.0)),
),
..ComputedStyle::default()
};
let rects = background_tile_rects(border_box, &style, (1.0, 10.0));
assert_eq!(rects.len(), 200);
}
fn fake_prepared_image(width: f32, height: f32) -> Rc<PreparedImage> {
Rc::new(PreparedImage {
width,
height,
content: super::super::img::PreparedContent::Raster {
color: super::super::img::ImagePlane {
data: Vec::new(),
filter: pdf_writer::Filter::FlateDecode,
color_space: super::super::img::PlaneColorSpace::Rgb,
bits_per_component: 8,
},
alpha: None,
},
})
}
#[test]
fn background_image_no_repeat_draws_a_single_xobject_without_a_clip() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">hello</div>"#);
let author = parse_stylesheet(
r#".box {
width: 200px; height: 100px;
background-image: url("bg.png");
background-repeat: no-repeat;
background-size: 200px 100px;
}"#,
);
let styles = compute_styles(&dom, &ua, &author);
let div = find_tag(&dom, dom.document(), "div").expect("div not found");
let mut background_images = HashMap::new();
background_images.insert(div, fake_prepared_image(40.0, 30.0));
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &background_images, &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert_eq!(count_occurrences(&decompressed, b"re\nW\nn\n"), 0);
assert_eq!(count_occurrences(&decompressed, b" Do\n"), 1);
}
#[test]
fn background_image_repeat_tiles_and_clips_to_the_border_box() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">hello</div>"#);
let author = parse_stylesheet(
r#".box {
width: 100px; height: 60px;
background-image: url("bg.png");
background-repeat: repeat;
}"#,
);
let styles = compute_styles(&dom, &ua, &author);
let div = find_tag(&dom, dom.document(), "div").expect("div not found");
let mut background_images = HashMap::new();
background_images.insert(div, fake_prepared_image(40.0, 30.0));
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &background_images, &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b"re\nW\nn\n") > 0,
"tiling beyond the border box should clip"
);
assert_eq!(count_occurrences(&decompressed, b" Do\n"), 6);
}
fn find_tag(dom: &crate::html::Dom, id: NodeId, tag: &str) -> Option<NodeId> {
if let crate::html::NodeData::Element { name, .. } = &dom.node(id).data {
if &*name.local == tag {
return Some(id);
}
}
dom.children(id).find_map(|child| find_tag(dom, child, tag))
}
fn find_laid_out(b: &LaidOutBox, target: NodeId) -> Option<&LaidOutBox> {
if b.node == Some(target) {
return Some(b);
}
if let LaidOutContent::Blocks(children) = &b.content {
return children.iter().find_map(|c| find_laid_out(c, target));
}
None
}
fn test_fonts_with_cjk() -> FontCollection {
FontCollection::new(vec![
Font::load(DEJAVU_PATH).expect("should load bundled DejaVu test font"),
Font::load_indexed(CJK_PATH, 0).expect("should load bundled CJK test font"),
])
}
fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.filter(|w| *w == needle)
.count()
}
fn decompressed_stream_bytes(pdf_bytes: &[u8]) -> Vec<u8> {
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
let mut out = Vec::new();
let mut i = 0;
while let Some(pos) = find_subslice(&pdf_bytes[i..], b"stream\n") {
let start = i + pos + b"stream\n".len();
let Some(end_rel) = find_subslice(&pdf_bytes[start..], b"\nendstream") else {
break;
};
let end = start + end_rel;
let raw = &pdf_bytes[start..end];
let mut decoder = flate2::read::ZlibDecoder::new(raw);
let mut decompressed = Vec::new();
if std::io::Read::read_to_end(&mut decoder, &mut decompressed).is_ok() {
out.extend_from_slice(&decompressed);
} else {
out.extend_from_slice(raw);
}
out.push(b'\n');
i = end + b"\nendstream".len();
}
out
}
#[test]
fn encodes_a_valid_pdf_with_embedded_font() {
let dom = html::parse(b"<p>Hello, world!</p>");
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
assert!(bytes.starts_with(b"%PDF-"));
assert!(count_occurrences(&bytes, b"%%EOF") > 0);
assert!(count_occurrences(&bytes, b"/Subtype /Type0") > 0);
assert!(count_occurrences(&bytes, b"/Subtype /CIDFontType2") > 0);
assert!(count_occurrences(&bytes, b"/Identity-H") > 0);
assert!(count_occurrences(&bytes, b"/FontFile2") > 0);
assert!(
count_occurrences(&bytes, b"/Type /CMap") > 0,
"ToUnicode CMap should be embedded"
);
assert!(
count_occurrences(&bytes, b"/CMapName /Custom") > 0,
"CMap stream dictionary must carry /CMapName (ISO 32000-1 table 120)"
);
assert!(
count_occurrences(&bytes, b"/CIDSystemInfo") >= 2,
"CMap stream dictionary must carry /CIDSystemInfo (ISO 32000-1 table 120)"
);
assert!(
count_occurrences(&bytes, b"/Ordering (UCS)") > 0,
"ToUnicode CMap /CIDSystemInfo should be Adobe-UCS-0"
);
assert!(
count_occurrences(&bytes, b"/FlateDecode") > 0,
"font stream should be compressed"
);
}
#[test]
fn to_unicode_maps_a_ligature_glyph_to_every_character_it_stands_for() {
let dom = html::parse(b"<p>float</p>");
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &Stylesheet::default());
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b"<0066006C>") > 0,
"the fl ligature glyph should map to both characters"
);
}
#[test]
fn subsetting_keeps_embedded_font_small() {
let dom = html::parse("<p>日本語のテスト</p>".as_bytes());
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts_with_cjk();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let cjk_font_size = std::fs::metadata(CJK_PATH).unwrap().len() as usize;
assert!(
bytes.len() < cjk_font_size / 10,
"subsetted output ({} bytes) should be far smaller than the original CJK font ({} bytes)",
bytes.len(),
cjk_font_size
);
}
#[test]
fn multi_page_document_produces_one_media_box_per_page() {
let mut html_src = String::from("<div>");
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 1,
"expected pagination to produce multiple pages"
);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
assert_eq!(count_occurrences(&bytes, b"/MediaBox"), pages.len());
}
#[test]
fn spread_radii_grow_a_box_shadow_shape_as_the_spec_describes() {
let radii = ((0.0, 0.0), (10.0, 10.0), (2.0, 6.0), (8.0, 8.0));
let (tl, tr, br, bl) = spread_radii(radii, 4.0);
assert_eq!(tl, (0.0, 0.0));
assert_eq!(tr, (14.0, 14.0));
assert_eq!(bl, (12.0, 12.0));
assert_eq!(br, (2.0 + 4.0 * 0.875, 10.0));
let (tl, tr, br, bl) = spread_radii(radii, -4.0);
assert_eq!(
[tl, tr, br, bl],
[(0.0, 0.0), (6.0, 6.0), (0.0, 2.0), (4.0, 4.0)]
);
}
#[test]
fn background_color_adds_fill_drawing_to_content_stream() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_with_bg = html::parse(br#"<div class="box">x</div>"#);
let author_with_bg = parse_stylesheet(".box { background-color: rgb(10, 20, 30); }");
let styles_with = compute_styles(&dom_with_bg, &ua, &author_with_bg);
let pages_with = paginate_document(&dom_with_bg, &styles_with, &fonts, &settings);
let bytes_with = encode_pdf(
&pages_with,
&styles_with,
&HashMap::new(),
&fonts,
&settings,
);
let dom_without_bg = html::parse(br#"<div class="box">x</div>"#);
let styles_without = compute_styles(&dom_without_bg, &ua, &Stylesheet::default());
let pages_without = paginate_document(&dom_without_bg, &styles_without, &fonts, &settings);
let bytes_without = encode_pdf(
&pages_without,
&styles_without,
&HashMap::new(),
&fonts,
&settings,
);
assert!(
bytes_with.len() > bytes_without.len(),
"background-color should add extra drawing operators to the content stream"
);
}
#[test]
fn solid_border_fills_a_mitered_quad_per_side() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_with = html::parse(br#"<div class="box">x</div>"#);
let author_with = parse_stylesheet(".box { border: 2px solid rgb(10, 20, 30); }");
let styles_with = compute_styles(&dom_with, &ua, &author_with);
let pages_with = paginate_document(&dom_with, &styles_with, &fonts, &settings);
let bytes_with = encode_pdf(
&pages_with,
&styles_with,
&HashMap::new(),
&fonts,
&settings,
);
let dom_without = html::parse(br#"<div class="box">x</div>"#);
let styles_without = compute_styles(&dom_without, &ua, &Stylesheet::default());
let pages_without = paginate_document(&dom_without, &styles_without, &fonts, &settings);
let bytes_without = encode_pdf(
&pages_without,
&styles_without,
&HashMap::new(),
&fonts,
&settings,
);
let fill_count_with = count_occurrences(&decompressed_stream_bytes(&bytes_with), b"\nf\n");
let fill_count_without =
count_occurrences(&decompressed_stream_bytes(&bytes_without), b"\nf\n");
assert!(
fill_count_with >= fill_count_without + 4,
"solid border should add 4 filled mitered quads (with={fill_count_with}, without={fill_count_without})"
);
}
#[test]
fn text_decoration_underline_adds_stroke_operator() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_decorated = html::parse(br#"<p class="u">underlined</p>"#);
let author = parse_stylesheet(".u { text-decoration: underline; }");
let styles_decorated = compute_styles(&dom_decorated, &ua, &author);
let pages_decorated =
paginate_document(&dom_decorated, &styles_decorated, &fonts, &settings);
let bytes_decorated = encode_pdf(
&pages_decorated,
&styles_decorated,
&HashMap::new(),
&fonts,
&settings,
);
let dom_plain = html::parse(br#"<p class="u">underlined</p>"#);
let styles_plain = compute_styles(&dom_plain, &ua, &Stylesheet::default());
let pages_plain = paginate_document(&dom_plain, &styles_plain, &fonts, &settings);
let bytes_plain = encode_pdf(
&pages_plain,
&styles_plain,
&HashMap::new(),
&fonts,
&settings,
);
assert!(
count_occurrences(&decompressed_stream_bytes(&bytes_decorated), b"\nS\n")
> count_occurrences(&decompressed_stream_bytes(&bytes_plain), b"\nS\n"),
"underline should add an extra stroke operator to the content stream"
);
}
#[test]
fn double_border_fills_two_bands_per_side() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_with = html::parse(br#"<div class="box">x</div>"#);
let author_with = parse_stylesheet(".box { border: 9px double rgb(0, 0, 0); }");
let styles_with = compute_styles(&dom_with, &ua, &author_with);
let pages_with = paginate_document(&dom_with, &styles_with, &fonts, &settings);
let bytes_with = encode_pdf(
&pages_with,
&styles_with,
&HashMap::new(),
&fonts,
&settings,
);
let dom_without = html::parse(br#"<div class="box">x</div>"#);
let styles_without = compute_styles(&dom_without, &ua, &Stylesheet::default());
let pages_without = paginate_document(&dom_without, &styles_without, &fonts, &settings);
let bytes_without = encode_pdf(
&pages_without,
&styles_without,
&HashMap::new(),
&fonts,
&settings,
);
let fill_count_with = count_occurrences(&decompressed_stream_bytes(&bytes_with), b"\nf\n");
let fill_count_without =
count_occurrences(&decompressed_stream_bytes(&bytes_without), b"\nf\n");
assert!(
fill_count_with >= fill_count_without + 8,
"double border should fill two mitered bands per side"
);
}
#[test]
fn double_border_with_radius_strokes_two_rounded_paths() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">x</div>"#);
let author =
parse_stylesheet(".box { border: 9px double rgb(0, 0, 0); border-radius: 10px; }");
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b" c\n") >= 8,
"double border with radius should draw two rounded stroke paths"
);
assert!(
count_occurrences(&decompressed, b"\nS\n") >= 2,
"double border with radius should stroke twice"
);
}
#[test]
fn dotted_border_uses_round_cap_and_dash_pattern() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">x</div>"#);
let author = parse_stylesheet(".box { border: 1px dotted rgb(0, 0, 0); }");
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let text = String::from_utf8_lossy(&decompressed_stream_bytes(&bytes)).into_owned();
assert!(text.contains(" J\n"), "dotted border should set a line cap");
assert!(
text.contains(" d\n"),
"dotted border should set a dash pattern"
);
}
#[test]
fn uniform_border_radius_draws_curved_path_instead_of_straight_rect() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">x</div>"#);
let author = parse_stylesheet(
".box { border: 2px solid rgb(0, 0, 0); background-color: rgb(200, 200, 200); border-radius: 10px; }",
);
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
let text = String::from_utf8_lossy(&decompressed);
assert!(
count_occurrences(&decompressed, b" c\n") >= 8,
"rounded corners should use cubic bezier curve operators (4 corners x fill+stroke)"
);
assert!(
!text.contains(" re\n"),
"rounded box should not use a plain rectangle"
);
}
fn rounded_box_split_across_pages(box_css: &str) -> Vec<String> {
use crate::layout::{EdgeSizes, PageSize};
let settings = PageSettings {
size: PageSize {
width: 400.0,
height: 200.0,
},
margin: EdgeSizes {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
};
let fonts = test_fonts();
let rows = r#"<div class="row"></div>"#.repeat(8);
let dom = html::parse(format!(r#"<div class="box">{rows}</div>"#).as_bytes());
let author = parse_stylesheet(&format!(
"html, body {{ margin: 0; }} .row {{ height: 50px; }} .box {{ {box_css} }}"
));
let styles = compute_styles(&dom, &user_agent_stylesheet(), &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 3, "the box should break twice");
pages
.iter()
.map(|page| {
let bytes = encode_pdf(
std::slice::from_ref(page),
&styles,
&HashMap::new(),
&fonts,
&settings,
);
String::from_utf8_lossy(&decompressed_stream_bytes(&bytes)).into_owned()
})
.collect()
}
#[test]
fn rounded_border_is_sliced_open_at_page_breaks() {
let pages =
rounded_box_split_across_pages("border: 4px solid rgb(0, 0, 0); border-radius: 8px;");
let [first, middle, last] = &pages[..] else {
unreachable!()
};
for (name, page) in [("first", first), ("last", last)] {
assert_eq!(
count_occurrences(page.as_bytes(), b"\nS\n"),
1,
"the {name} fragment should stroke its border once"
);
assert!(
!page.contains("h\nS\n"),
"the {name} fragment's border should be left open at the break"
);
assert_eq!(count_occurrences(page.as_bytes(), b" c\n"), 2);
}
assert!(first.contains("2 46 m\n2 192 l\n"), "{first}");
assert!(last.contains("398 200 m\n398 154 l\n"), "{last}");
assert_eq!(count_occurrences(middle.as_bytes(), b" c\n"), 0);
assert_eq!(
count_occurrences(middle.as_bytes(), b"\nf\n"),
2,
"{middle}"
);
}
#[test]
fn rounded_double_border_is_sliced_open_at_page_breaks() {
let pages =
rounded_box_split_across_pages("border: 6px double rgb(0, 0, 0); border-radius: 8px;");
for page in [&pages[0], &pages[2]] {
assert_eq!(
count_occurrences(page.as_bytes(), b"\nS\n"),
2,
"both bands of the double border should be stroked"
);
assert!(!page.contains("h\nS\n"));
}
}
#[test]
fn non_uniform_border_with_radius_falls_back_to_straight_edges() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="box">x</div>"#);
let author = parse_stylesheet(
".box { border-style: solid dotted; border-width: 2px; border-color: rgb(0,0,0); border-radius: 10px; }",
);
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b"\nf\n") >= 2,
"the two solid sides should fill mitered quads"
);
assert!(
count_occurrences(&decompressed, b"\nS\n") >= 2,
"the two dotted sides should still stroke a centerline"
);
}
#[test]
fn non_uniform_solid_border_corners_share_exact_miter_vertices() {
use crate::layout::{EdgeSizes, PageSize};
let settings = PageSettings {
size: PageSize {
width: 800.0,
height: 1000.0,
},
margin: EdgeSizes {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
};
let fonts = test_fonts();
let dom = html::parse(br#"<div class="box">x</div>"#);
let author = parse_stylesheet(
"html, body { margin: 0; } \
.box { border-style: solid; border-width: 10px 20px 30px 40px; \
border-color: rgb(255,0,0) rgb(0,255,0) rgb(0,0,255) rgb(255,255,0); \
width: 300px; height: 200px; margin: 0; }",
);
let styles = compute_styles(&dom, &user_agent_stylesheet(), &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let text = String::from_utf8_lossy(&decompressed_stream_bytes(&bytes)).into_owned();
assert_eq!(
count_occurrences(text.as_bytes(), b"360 1000"),
2,
"the top-right outer corner should be shared by the top and right quads"
);
assert_eq!(
count_occurrences(text.as_bytes(), b"340 990"),
2,
"the top-right inner (mitered) corner should be shared by the top and right quads"
);
}
#[test]
fn border_style_none_suppresses_drawing_even_with_nonzero_width() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_with = html::parse(br#"<div class="box">x</div>"#);
let author_with = parse_stylesheet(".box { border-width: 5px; border-style: none; }");
let styles_with = compute_styles(&dom_with, &ua, &author_with);
let pages_with = paginate_document(&dom_with, &styles_with, &fonts, &settings);
let bytes_with = encode_pdf(
&pages_with,
&styles_with,
&HashMap::new(),
&fonts,
&settings,
);
let dom_without = html::parse(br#"<div class="box">x</div>"#);
let styles_without = compute_styles(&dom_without, &ua, &Stylesheet::default());
let pages_without = paginate_document(&dom_without, &styles_without, &fonts, &settings);
let bytes_without = encode_pdf(
&pages_without,
&styles_without,
&HashMap::new(),
&fonts,
&settings,
);
assert_eq!(
bytes_with.len(),
bytes_without.len(),
"border-style: none should suppress drawing regardless of border-width"
);
}
#[test]
fn mixed_script_document_embeds_both_fonts() {
let dom = html::parse("<p>Invoice 請求書</p>".as_bytes());
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts_with_cjk();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
assert_eq!(count_occurrences(&bytes, b"/FontFile2"), 2);
assert_eq!(count_occurrences(&bytes, b"/Subtype /Type0"), 2);
}
#[test]
fn table_cells_render_text_borders_and_backgrounds() {
let dom = html::parse(
br#"<table>
<tr><th colspan="2">Header</th></tr>
<tr><td style="background-color: rgb(200,200,200);">Apple</td><td>100</td></tr>
</table>"#,
);
let ua = user_agent_stylesheet();
let author = parse_stylesheet("td, th { border: 1px solid rgb(0,0,0); }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
let text = String::from_utf8_lossy(&decompressed);
assert_eq!(
count_occurrences(&bytes, b"/FontFile2"),
1,
"all table cell text should use the single loaded font"
);
assert!(
count_occurrences(&decompressed, b"\nf\n") >= 2,
"cell borders/backgrounds should produce fill operators"
);
assert!(
text.contains("0.78431374 0.78431374 0.78431374 rg"),
"the explicit cell background-color should be painted"
);
}
fn page_streams(html_src: &str, css: &str) -> Vec<String> {
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &parse_stylesheet(css));
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
pages
.iter()
.map(|page| {
let bytes = encode_pdf(
std::slice::from_ref(page),
&styles,
&HashMap::new(),
&fonts,
&settings,
);
String::from_utf8_lossy(&decompressed_stream_bytes(&bytes)).into_owned()
})
.collect()
}
fn fills_of(text: &str, rg_line: &str) -> Vec<(usize, Vec<[f32; 4]>)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for (i, _) in lines.iter().enumerate().filter(|(_, l)| **l == rg_line) {
let mut rects = Vec::new();
for line in &lines[i + 1..] {
if let Some(args) = line.strip_suffix(" re") {
let v: Vec<f32> = args.split(' ').map(|n| n.parse().unwrap()).collect();
rects.push([v[0], v[1], v[2], v[3]]);
} else if *line == "f" {
break;
}
}
out.push((i, rects));
}
out
}
#[test]
fn row_group_backgrounds_are_painted_behind_their_rows_and_cells() {
let html_src = r##"<table>
<thead><tr><th>H</th><th>H</th></tr></thead>
<tbody>
<tr><td>a</td><td>a</td></tr>
<tr class="row"><td>b</td><td class="cell">b</td></tr>
</tbody>
<tfoot bgcolor="#0000ff"><tr><td>f</td><td>f</td></tr></tfoot>
</table>"##;
let css = "table { border-spacing: 0 } td, th { height: 30px } \
thead { background: rgb(255, 0, 0) } \
tbody { background: rgb(0, 255, 0) } \
tr.row { background: rgb(255, 255, 0) } \
td.cell { background: rgb(0, 255, 255) }";
let streams = page_streams(html_src, css);
assert_eq!(streams.len(), 1);
let text = &streams[0];
let thead = fills_of(text, "1 0 0 rg");
let tbody = fills_of(text, "0 1 0 rg");
let tfoot = fills_of(text, "0 0 1 rg");
let row = fills_of(text, "1 1 0 rg");
let cell = fills_of(text, "0 1 1 rg");
for (name, fills, cells) in [
("thead", &thead, 2),
("tbody", &tbody, 4),
("tfoot", &tfoot, 2),
] {
assert_eq!(fills.len(), 1, "{name} should be painted once:\n{text}");
assert_eq!(fills[0].1.len(), cells, "{name} covers each of its cells");
}
let tops = |fills: &[(usize, Vec<[f32; 4]>)]| -> Vec<f32> {
fills[0].1.iter().map(|r| r[1] + r[3]).collect()
};
assert!(tbody[0].1.iter().all(|r| (r[3] - 30.0).abs() < 0.01));
let thead_bottom = thead[0].1[0][1];
let tfoot_top = tops(&tfoot)[0];
assert!(tops(&tbody).iter().all(|&t| t <= thead_bottom + 0.01));
assert!(tbody[0].1.iter().all(|r| r[1] >= tfoot_top - 0.01));
let last_group = [&thead, &tbody, &tfoot]
.iter()
.map(|f| f[0].0)
.max()
.unwrap();
assert_eq!(row.len(), 1, "the row background is still painted");
assert_eq!(cell.len(), 1, "the cell background is still painted");
assert!(last_group < row[0].0 && row[0].0 < cell[0].0, "{text}");
}
#[test]
fn row_group_background_leaves_the_border_spacing_to_the_table() {
let html_src = r#"<table><tbody>
<tr><td>a</td><td>a</td></tr>
<tr><td>b</td><td>b</td></tr>
</tbody></table>"#;
let css = "table { border-spacing: 8px } td { width: 40px; height: 40px } \
tbody { background: rgb(0, 255, 0) }";
let streams = page_streams(html_src, css);
let fills = fills_of(&streams[0], "0 1 0 rg");
assert_eq!(fills.len(), 1);
let rects = &fills[0].1;
assert_eq!(rects.len(), 4, "one rectangle per cell: {rects:?}");
let mut xs: Vec<f32> = rects.iter().map(|r| r[0]).collect();
xs.sort_by(f32::total_cmp);
xs.dedup();
let mut ys: Vec<f32> = rects.iter().map(|r| r[1]).collect();
ys.sort_by(f32::total_cmp);
ys.dedup();
assert_eq!(xs.len(), 2);
assert_eq!(ys.len(), 2);
assert!(
(xs[1] - xs[0] - (rects[0][2] + 8.0)).abs() < 0.01,
"{rects:?}"
);
assert!(
(ys[1] - ys[0] - (rects[0][3] + 8.0)).abs() < 0.01,
"{rects:?}"
);
}
#[test]
fn row_group_background_on_a_split_table_covers_only_the_rows_on_each_page() {
let rows: String = (0..60).map(|i| format!("<tr><td>{i}</td></tr>")).collect();
let html_src =
format!("<table><thead><tr><th>H</th></tr></thead><tbody>{rows}</tbody></table>");
let css = "table { border-spacing: 0 } td, th { height: 40px } \
thead { background: rgb(255, 0, 0) } \
tbody { background: rgb(0, 255, 0) }";
let streams = page_streams(&html_src, css);
assert!(
streams.len() >= 3,
"the table should split over several pages"
);
let settings = PageSettings::default();
let content_bottom = settings.margin.bottom;
let mut body_cells = 0;
for (page, text) in streams.iter().enumerate() {
let thead = fills_of(text, "1 0 0 rg");
let tbody = fills_of(text, "0 1 0 rg");
assert_eq!(
thead.len(),
1,
"page {page}: the thead repeats with its background"
);
assert_eq!(tbody.len(), 1, "page {page}");
let rects = &tbody[0].1;
let thead_bottom = thead[0].1[0][1];
assert!(
rects.iter().all(|r| (r[3] - 40.0).abs() < 0.01),
"page {page}"
);
for (i, r) in rects.iter().enumerate() {
let expected_top = thead_bottom - 40.0 * i as f32;
assert!(
(r[1] + r[3] - expected_top).abs() < 0.01,
"page {page}: {rects:?}"
);
}
assert!(
rects.iter().all(|r| r[1] >= content_bottom - 0.01),
"page {page}"
);
body_cells += rects.len();
}
assert_eq!(body_cells, 60, "every body row is painted exactly once");
}
fn fill_operator_count(html_src: &str, css: &str) -> usize {
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(css);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
count_occurrences(&decompressed_stream_bytes(&bytes), b"\nf\n")
}
#[test]
fn empty_cells_hide_suppresses_decoration_for_empty_cells_in_separate_mode() {
let html_src = r#"<table><tr><td>Apple</td><td></td></tr></table>"#;
let base_css = "td { border: 1px solid black; background-color: rgb(200,200,200); }";
let shown = fill_operator_count(html_src, base_css);
let hidden = fill_operator_count(
html_src,
&format!("{base_css} table {{ empty-cells: hide; }}"),
);
assert!(
hidden < shown,
"hiding the empty cell should remove its border/background fills \
(shown={shown}, hidden={hidden})"
);
}
#[test]
fn a_cell_holding_only_a_no_break_space_does_not_count_as_empty() {
let css = "td { border: 1px solid black; background-color: rgb(200,200,200); } \
table { empty-cells: hide; }";
let truly_empty =
fill_operator_count(r#"<table><tr><td>Apple</td><td></td></tr></table>"#, css);
let with_nbsp =
fill_operator_count("<table><tr><td>Apple</td><td>\u{a0}</td></tr></table>", css);
assert!(
with_nbsp > truly_empty,
"a cell with should keep its decoration \
(nbsp={with_nbsp}, empty={truly_empty})"
);
}
#[test]
fn empty_cells_hide_has_no_effect_when_border_collapse_is_collapse() {
let html_src = r#"<table><tr><td>Apple</td><td></td></tr></table>"#;
let base_css = "td { border: 1px solid black; background-color: rgb(200,200,200); } \
table { border-collapse: collapse; }";
let without_hide = fill_operator_count(html_src, base_css);
let with_hide = fill_operator_count(
html_src,
&format!("{base_css} table {{ empty-cells: hide; }}"),
);
assert_eq!(
without_hide, with_hide,
"empty-cells: hide should be a no-op under border-collapse: collapse"
);
}
#[test]
fn empty_cells_hide_can_be_set_on_an_individual_cell() {
let html_src = r#"<table><tr><td>Apple</td><td class="empty"></td></tr></table>"#;
let base_css = "td { border: 1px solid black; background-color: rgb(200,200,200); }";
let shown = fill_operator_count(html_src, base_css);
let hidden = fill_operator_count(
html_src,
&format!("{base_css} .empty {{ empty-cells: hide; }}"),
);
assert!(
hidden < shown,
"hiding via a per-cell override should remove that cell's fills \
(shown={shown}, hidden={hidden})"
);
}
#[test]
fn border_collapse_avoids_drawing_a_double_thick_border_at_a_shared_edge() {
let html_src = r#"<table><tr><td>a</td><td>b</td></tr></table>"#;
let base_css = "body { margin: 0; } td { border: 1px solid black; }";
let separate = fill_operator_count(html_src, base_css);
let collapse = fill_operator_count(
html_src,
&format!("{base_css} table {{ border-collapse: collapse; }}"),
);
assert_eq!(
separate, 8,
"each cell should draw all 4 sides independently in separate mode"
);
assert_eq!(
collapse, 7,
"collapse should merge the shared edge into a single draw (8-1=7): {collapse}"
);
}
#[test]
fn border_collapse_uses_the_neighbors_border_when_own_side_declares_none() {
let html_src = r#"<table><tr><td class="a">a</td><td class="b">b</td></tr></table>"#;
let css = "body { margin: 0; } \
table { border-collapse: collapse; } \
.a { border: none; } \
.b { border: 2px solid black; }";
let fills = fill_operator_count(html_src, css);
assert_eq!(
fills, 4,
"the shared edge should still be drawn using the neighbor's border spec: {fills}"
);
}
#[test]
fn resolve_border_conflict_prefers_the_wider_border() {
let wide = (
3.0,
BorderStyle::Solid,
RgbaColor {
red: 255,
green: 0,
blue: 0,
alpha: 255.0,
},
);
let narrow = (
1.0,
BorderStyle::Solid,
RgbaColor {
red: 0,
green: 0,
blue: 255,
alpha: 255.0,
},
);
assert_eq!(resolve_border_conflict(wide, narrow), wide);
assert_eq!(resolve_border_conflict(narrow, wide), wide);
}
#[test]
fn resolve_border_conflict_prefers_a_stronger_style_when_widths_tie() {
let solid = (
1.0,
BorderStyle::Solid,
RgbaColor {
red: 0,
green: 0,
blue: 0,
alpha: 255.0,
},
);
let dotted = (
1.0,
BorderStyle::Dotted,
RgbaColor {
red: 0,
green: 0,
blue: 0,
alpha: 255.0,
},
);
let double = (
1.0,
BorderStyle::Double,
RgbaColor {
red: 0,
green: 0,
blue: 0,
alpha: 255.0,
},
);
assert_eq!(resolve_border_conflict(solid, dotted), solid);
assert_eq!(resolve_border_conflict(double, solid), double);
}
#[test]
fn resolve_border_conflict_ignores_a_declared_width_when_style_is_none() {
let none_but_wide = (
10.0,
BorderStyle::None,
RgbaColor {
red: 0,
green: 0,
blue: 0,
alpha: 255.0,
},
);
let thin_solid = (
1.0,
BorderStyle::Solid,
RgbaColor {
red: 0,
green: 0,
blue: 0,
alpha: 255.0,
},
);
let a = border_edge(none_but_wide.0, none_but_wide.1, none_but_wide.2);
let b = border_edge(thin_solid.0, thin_solid.1, thin_solid.2);
assert_eq!(resolve_border_conflict(a, b), b);
}
#[test]
fn word_boundary_across_a_font_switch_gets_an_actual_text_space_marker() {
let dom = html::parse("<p>Invoice 請求書</p>".as_bytes());
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &Stylesheet::default());
let fonts = test_fonts_with_cjk();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
assert!(
count_occurrences(&decompressed_stream_bytes(&bytes), b"/ActualText") > 0,
"a word boundary spanning a font switch should get an ActualText space marker"
);
}
#[test]
fn single_word_does_not_insert_an_actual_text_marker() {
let dom = html::parse(b"<p>hello</p>");
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &Stylesheet::default());
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
assert_eq!(
count_occurrences(&decompressed_stream_bytes(&bytes), b"/ActualText"),
0,
"a single word with no boundary needs no ActualText marker"
);
}
#[test]
fn letter_spacing_emits_a_tc_operator_with_the_resolved_value() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<p class="s">spaced</p>"#);
let author = parse_stylesheet(".s { letter-spacing: 3px; }");
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let stream = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&stream, b" Tc\n") > 0,
"letter-spacing should emit a Tc operator"
);
assert!(
count_occurrences(&stream, b"3 Tc\n") > 0,
"the Tc operand should match the resolved letter-spacing value"
);
}
#[test]
fn write_document_writes_pdf_bytes_to_sink() {
let dom = html::parse(b"<p>hi</p>");
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = write_document(
&pages,
&styles,
&HashMap::new(),
&fonts,
&settings,
MemorySink::new(),
)
.unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn list_item_marker_glyphs_are_embedded_in_the_font_subset() {
let dom = html::parse(br#"<ol><li>apple</li></ol>"#);
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b"<0031>") > 0,
"the marker's '1' glyph (from the \"1.\" decimal marker) should be \
embedded in the ToUnicode CMap"
);
}
#[test]
fn generated_content_glyphs_are_embedded_in_the_font_subset() {
let dom = html::parse(br#"<div><h2>intro</h2></div>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
"div { counter-reset: section; } \
h2 { counter-increment: section; } \
h2::before { content: counter(section) \". \"; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert!(
count_occurrences(&decompressed, b"<0031>") > 0,
"the counter()-generated '1' glyph should be embedded in the ToUnicode CMap"
);
}
#[test]
fn border_side_colors_shade_inset_and_outset_by_top_left_vs_bottom_right() {
let color = RgbaColor {
red: 51,
green: 102,
blue: 204,
alpha: 1.0,
};
let light = lighten(color, SHADE_AMOUNT);
let dark = darken(color, SHADE_AMOUNT);
assert_ne!(light, dark);
for (side, expect_dark) in [
(BorderSideKind::Top, true),
(BorderSideKind::Left, true),
(BorderSideKind::Right, false),
(BorderSideKind::Bottom, false),
] {
let colors = border_side_colors(BorderStyle::Inset, side, color);
let expected = if expect_dark { dark } else { light };
assert_eq!(colors.outer, expected, "inset outer for {side:?}");
assert_eq!(colors.inner, expected, "inset inner for {side:?}");
let outset_colors = border_side_colors(BorderStyle::Outset, side, color);
let outset_expected = if expect_dark { light } else { dark };
assert_eq!(
outset_colors.outer, outset_expected,
"outset outer for {side:?}"
);
}
}
#[test]
fn border_side_colors_groove_and_ridge_split_outer_and_inner_bands() {
let color = RgbaColor {
red: 51,
green: 102,
blue: 204,
alpha: 1.0,
};
let light = lighten(color, SHADE_AMOUNT);
let dark = darken(color, SHADE_AMOUNT);
let top_groove = border_side_colors(BorderStyle::Groove, BorderSideKind::Top, color);
assert_eq!(top_groove.outer, dark);
assert_eq!(top_groove.inner, light);
let right_groove = border_side_colors(BorderStyle::Groove, BorderSideKind::Right, color);
assert_eq!(right_groove.outer, light);
assert_eq!(right_groove.inner, dark);
let top_ridge = border_side_colors(BorderStyle::Ridge, BorderSideKind::Top, color);
assert_eq!(top_ridge.outer, light);
assert_eq!(top_ridge.inner, dark);
}
#[test]
fn border_side_colors_solid_uses_the_same_color_for_both_bands() {
let color = RgbaColor {
red: 1,
green: 2,
blue: 3,
alpha: 1.0,
};
let colors = border_side_colors(BorderStyle::Solid, BorderSideKind::Top, color);
assert_eq!(colors.outer, color);
assert_eq!(colors.inner, color);
}
#[test]
fn outline_adds_drawing_without_affecting_layout() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom_without = html::parse(br#"<div class="box">x</div>"#);
let styles_without = compute_styles(&dom_without, &ua, &Stylesheet::default());
let pages_without = paginate_document(&dom_without, &styles_without, &fonts, &settings);
let bytes_without = encode_pdf(
&pages_without,
&styles_without,
&HashMap::new(),
&fonts,
&settings,
);
let dom_with = html::parse(br#"<div class="box">x</div>"#);
let author_with = parse_stylesheet(".box { outline: 4px solid rgb(255, 0, 0); }");
let styles_with = compute_styles(&dom_with, &ua, &author_with);
let pages_with = paginate_document(&dom_with, &styles_with, &fonts, &settings);
let bytes_with = encode_pdf(
&pages_with,
&styles_with,
&HashMap::new(),
&fonts,
&settings,
);
let fill_count_with = count_occurrences(&decompressed_stream_bytes(&bytes_with), b"\nf\n");
let fill_count_without =
count_occurrences(&decompressed_stream_bytes(&bytes_without), b"\nf\n");
assert!(
fill_count_with >= fill_count_without + 4,
"outline should add 4 filled mitered quads outside the border-box"
);
let div_without = find_tag(&dom_without, dom_without.document(), "div").unwrap();
let div_with = find_tag(&dom_with, dom_with.document(), "div").unwrap();
let box_without = pages_without[0]
.boxes
.iter()
.find_map(|b| find_laid_out(b, div_without))
.unwrap();
let box_with = pages_with[0]
.boxes
.iter()
.find_map(|b| find_laid_out(b, div_with))
.unwrap();
assert_eq!(box_without.layout.content, box_with.layout.content);
}
#[test]
fn overflow_hidden_emits_a_clip_path_and_visible_does_not() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
for (css, should_clip) in [
(
".box { overflow: hidden; width: 50px; height: 50px; }",
true,
),
(
".box { overflow: scroll; width: 50px; height: 50px; }",
true,
),
(".box { overflow: auto; width: 50px; height: 50px; }", true),
(
".box { overflow: visible; width: 50px; height: 50px; }",
false,
),
(".box { width: 50px; height: 50px; }", false),
] {
let dom = html::parse(br#"<div class="box"><p>hello</p></div>"#);
let styles = compute_styles(&dom, &ua, &parse_stylesheet(css));
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
let has_clip = count_occurrences(&decompressed, b"re\nW\nn\n") > 0;
assert_eq!(has_clip, should_clip, "css={css}");
}
}
#[test]
fn visibility_hidden_skips_own_decoration_but_still_renders_a_visible_descendant() {
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(br#"<div class="outer"><p class="inner">shown</p></div>"#);
let author = parse_stylesheet(
".outer { visibility: hidden; background-color: rgb(255, 0, 0); } \
.inner { visibility: visible; }",
);
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let bytes = encode_pdf(&pages, &styles, &HashMap::new(), &fonts, &settings);
let decompressed = decompressed_stream_bytes(&bytes);
assert_eq!(
count_occurrences(&decompressed, b"1 0 0 rg"),
0,
"hidden outer's red background should not be painted"
);
assert!(
count_occurrences(&decompressed, b"TJ") > 0,
"visible descendant's text should still be painted"
);
}
#[test]
fn paint_order_sorts_by_z_index_and_falls_back_to_document_order() {
let dom = html::parse(
br#"<div>
<p class="a" style="position: relative; z-index: 2;">a</p>
<p class="b" style="position: relative; z-index: -1;">b</p>
<p class="c">c</p>
<p class="d" style="z-index: 5;">d</p>
</div>"#,
);
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &Stylesheet::default());
let fonts = test_fonts();
let tree = crate::layout::build_box_tree(&dom, &styles);
let laid = crate::layout::layout_document(&tree, &styles, &fonts, 800.0);
let div_node = find_tag(&dom, dom.document(), "div").expect("div not found");
let div_box = find_laid_out(&laid, div_node).expect("div box not found");
let LaidOutContent::Blocks(children) = &div_box.content else {
panic!("expected the div's own children");
};
let ordered = paint_order(children, &styles);
let text_of = |b: &LaidOutBox| -> String {
let LaidOutContent::Inline(lines) = &b.content else {
panic!("expected inline content");
};
lines[0].runs[0].text.clone()
};
let order: Vec<String> = ordered.iter().map(|b| text_of(b)).collect();
assert_eq!(order, vec!["b", "c", "d", "a"]);
}
#[test]
fn paint_order_puts_floats_above_in_flow_blocks() {
let dom = html::parse(
br#"<div>
<p class="f" style="float: left; width: 100px;">f</p>
<p class="c">c</p>
</div>"#,
);
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &Stylesheet::default());
let fonts = test_fonts();
let tree = crate::layout::build_box_tree(&dom, &styles);
let laid = crate::layout::layout_document(&tree, &styles, &fonts, 800.0);
let div_node = find_tag(&dom, dom.document(), "div").expect("div not found");
let div_box = find_laid_out(&laid, div_node).expect("div box not found");
let LaidOutContent::Blocks(children) = &div_box.content else {
panic!("expected the div's own children");
};
let ordered = paint_order(children, &styles);
let text_of = |b: &LaidOutBox| -> String {
let LaidOutContent::Inline(lines) = &b.content else {
panic!("expected inline content");
};
lines[0].runs[0].text.clone()
};
let order: Vec<String> = ordered.iter().map(|b| text_of(b)).collect();
assert_eq!(order, vec!["c", "f"]);
}
fn link_areas_of(html_src: &str, css: &str) -> Vec<LinkArea> {
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(css);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
let mut out = Vec::new();
for page in &pages {
for b in &page.boxes {
collect_link_areas(b, &settings, &mut out);
}
}
out
}
#[test]
fn a_link_produces_one_area_per_line() {
let areas = link_areas_of(
r#"<p><a href="https://example.com">link text</a></p>"#,
"body { margin: 0; }",
);
assert_eq!(areas.len(), 1);
assert_eq!(&*areas[0].href, "https://example.com");
assert!(areas[0].x1 > areas[0].x0, "{areas:?}");
assert!(areas[0].y1 > areas[0].y0, "{areas:?}");
}
#[test]
fn text_outside_the_link_is_not_part_of_the_area() {
let areas = link_areas_of(
r#"<p>before <a href="https://example.com">link</a> after</p>"#,
"body { margin: 0; }",
);
assert_eq!(areas.len(), 1);
assert!(areas[0].x0 > 0.0, "{areas:?}");
}
#[test]
fn a_link_broken_across_lines_produces_one_area_per_line() {
let areas = link_areas_of(
r#"<p><a href="https://example.com">word word word word word word word word word word word word word word word word</a></p>"#,
"body { margin: 0; } p { width: 120px; }",
);
assert!(
areas.len() > 1,
"expected several line areas, got {areas:?}"
);
assert!(areas.iter().all(|a| &*a.href == "https://example.com"));
assert!(areas[0].y0 > areas[1].y0, "{areas:?}");
}
#[test]
fn two_different_links_on_one_line_produce_two_areas() {
let areas = link_areas_of(
r#"<p><a href="https://a.example">a</a> <a href="https://b.example">b</a></p>"#,
"body { margin: 0; }",
);
assert_eq!(areas.len(), 2);
assert_eq!(&*areas[0].href, "https://a.example");
assert_eq!(&*areas[1].href, "https://b.example");
}
#[test]
fn a_javascript_href_is_not_turned_into_a_link() {
let areas = link_areas_of(
r#"<p><a href="javascript:alert(1)">click</a></p>"#,
"body { margin: 0; }",
);
assert!(areas.is_empty(), "{areas:?}");
}
#[test]
fn an_anchor_without_href_is_not_a_link() {
let areas = link_areas_of(r#"<p><a name="x">anchor</a></p>"#, "body { margin: 0; }");
assert!(areas.is_empty(), "{areas:?}");
}
#[test]
fn an_anchor_with_a_box_of_its_own_is_linked_over_its_border_box() {
for (html_src, href) in [
(
r#"<a style="display: block" href="https://e.com/block">block</a>"#,
"https://e.com/block",
),
(
r#"<a style="display: flex" href="https://e.com/flex">flex <span>x</span></a>"#,
"https://e.com/flex",
),
(
r#"<p><a style="display: inline-block" href="https://e.com/ib">ib</a></p>"#,
"https://e.com/ib",
),
(
r#"<p><a style="display: inline-flex" href="https://e.com/if">if</a></p>"#,
"https://e.com/if",
),
(
r#"<div style="display: flex">text <a href="https://e.com/item">item</a></div>"#,
"https://e.com/item",
),
] {
let areas = link_areas_of(html_src, "body { margin: 0; }");
assert_eq!(areas.len(), 1, "{html_src}: {areas:?}");
assert_eq!(&*areas[0].href, href);
assert!(areas[0].x1 > areas[0].x0 && areas[0].y1 > areas[0].y0);
}
}
#[test]
fn a_block_anchor_area_is_its_border_box() {
let areas = link_areas_of(
r#"<a href="https://e.com" style="display: block; width: 100px; height: 40px; padding: 5px; border: 2px solid; margin-left: 10px">x</a>"#,
"body { margin: 0; }",
);
assert_eq!(areas.len(), 1);
let settings = PageSettings::default();
let area = &areas[0];
assert!(
(area.x0 - (settings.margin.left + 10.0)).abs() < 0.01,
"{area:?}"
);
assert!((area.x1 - area.x0 - 114.0).abs() < 0.01, "{area:?}");
assert!((area.y1 - area.y0 - 54.0).abs() < 0.01, "{area:?}");
assert!(
(area.y1 - to_pdf_y(&settings, 0.0)).abs() < 0.01,
"{area:?}"
);
}
#[test]
fn a_box_inside_an_inline_anchor_joins_the_area_of_its_text() {
let areas = link_areas_of(
r#"<p>see <a href="https://e.com">the <span style="display: inline-block; width: 30px; height: 30px"></span> mark</a> here</p>"#,
"body { margin: 0; }",
);
assert_eq!(areas.len(), 1, "{areas:?}");
assert!(areas[0].y1 - areas[0].y0 >= 30.0, "{areas:?}");
let alone = link_areas_of(
r#"<p><a href="https://e.com"><span style="display: inline-block; width: 30px; height: 30px"></span></a></p>"#,
"body { margin: 0; }",
);
assert_eq!(alone.len(), 1, "{alone:?}");
assert!((alone[0].x1 - alone[0].x0 - 30.0).abs() < 0.01, "{alone:?}");
}
#[test]
fn a_block_anchor_split_across_pages_is_linked_on_every_page() {
let paragraphs = "<p>line</p>".repeat(120);
let html_src =
format!(r#"<a style="display: block" href="https://e.com">{paragraphs}</a>"#);
let dom = html::parse(html_src.as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &parse_stylesheet(""));
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &test_fonts(), &settings);
assert!(pages.len() > 1, "the anchor should split across pages");
for (i, page) in pages.iter().enumerate() {
let mut areas = Vec::new();
for b in &page.boxes {
collect_link_areas(b, &settings, &mut areas);
}
assert_eq!(areas.len(), 1, "page {i}: {areas:?}");
}
}
#[test]
fn internal_anchor_targets_are_detected_by_their_hash() {
assert_eq!(internal_anchor_target("#section-1"), Some("section-1"));
assert_eq!(internal_anchor_target("#"), None);
assert_eq!(internal_anchor_target("https://example.com/#frag"), None);
}
#[test]
fn destination_names_are_sanitised_for_pdf_names() {
assert_eq!(anchor_destination_name("sec1"), "a_sec1");
assert_eq!(anchor_destination_name("sec 1"), "a_sec_1");
assert_eq!(anchor_destination_name("日本語"), "a____");
assert_eq!(anchor_destination_name("a-b_c"), "a_a-b_c");
}
#[test]
fn anchor_positions_are_collected_per_page() {
let dom = html::parse(
br#"<p id="top">top</p><p style="break-before: page;" id="second">second</p>"#,
);
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &parse_stylesheet("body { margin: 0; }"));
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 2, "the test document should span two pages");
let anchor_names: HashMap<NodeId, String> = crate::html::collect_anchor_targets(&dom)
.into_iter()
.map(|(node, id)| (node, anchor_destination_name(&id)))
.collect();
let mut first_page = Vec::new();
for b in &pages[0].boxes {
collect_anchor_positions(b, &anchor_names, &settings, &mut first_page);
}
let mut second_page = Vec::new();
for b in &pages[1].boxes {
collect_anchor_positions(b, &anchor_names, &settings, &mut second_page);
}
assert_eq!(
first_page
.iter()
.map(|(n, ..)| n.as_str())
.collect::<Vec<_>>(),
vec!["a_top"]
);
assert_eq!(
second_page
.iter()
.map(|(n, ..)| n.as_str())
.collect::<Vec<_>>(),
vec!["a_second"]
);
}
}