use std::collections::HashMap;
use std::rc::Rc;
use skia_safe::{
path_effect::PathEffect, pdf, Color4f, Data, FontMgr, Paint, Path, PathBuilder, PathFillType,
};
use crate::render::dimension::Pt;
use crate::render::error::RenderError;
use crate::render::fonts;
use crate::render::layout::draw_command::{
DrawCommand, LayoutedPage, ResolvedDashPattern, ResolvedFill, ResolvedLineCap,
ResolvedLineJoin, ResolvedStroke,
};
use crate::render::resolve::drawing_color::Rgba;
use crate::render::resolve::images::MediaEntry;
use crate::render::resolve::shape_geometry::{PathVerb, SubPath};
use crate::render::skia_conv::{to_color4f, to_line, to_point, to_rect, to_size};
const IMAGE_TARGET_DPI: f32 = 150.0;
const IMAGE_DPI_SCALE: f32 = IMAGE_TARGET_DPI / 72.0;
pub fn render_to_pdf(pages: &[LayoutedPage], font_mgr: &FontMgr) -> Result<Vec<u8>, RenderError> {
let mut pdf_bytes: Vec<u8> = Vec::new();
let pdf_metadata = pdf::Metadata {
encoding_quality: Some(85),
..Default::default()
};
let mut doc = pdf::new_document(&mut pdf_bytes, Some(&pdf_metadata));
let mut font_cache = fonts::FontCache::new();
let mut image_cache: HashMap<*const [u8], skia_safe::Image> = HashMap::new();
for page in pages {
let mut on_page = doc.begin_page(to_size(page.page_size), None);
{
let canvas = on_page.canvas();
render_page(canvas, page, font_mgr, &mut font_cache, &mut image_cache);
}
doc = on_page.end_page();
}
doc.close();
Ok(pdf_bytes)
}
fn render_page(
canvas: &skia_safe::Canvas,
page: &LayoutedPage,
font_mgr: &FontMgr,
font_cache: &mut fonts::FontCache,
image_cache: &mut HashMap<*const [u8], skia_safe::Image>,
) {
for cmd in &page.commands {
match cmd {
DrawCommand::Text {
position,
text,
font_family,
char_spacing,
font_size,
bold,
italic,
color,
} => {
let font = font_cache.get(font_mgr, font_family, *font_size, *bold, *italic);
log::trace!(
"[paint] '{}' → font='{}' size={:.1}pt bold={} italic={}",
&text[..text.len().min(30)],
font.typeface().family_name(),
font_size.raw(),
bold,
italic,
);
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color4f(to_color4f(*color), None);
if char_spacing.abs() > Pt::ZERO {
let char_count = text.chars().count();
let glyphs = font.text_to_glyphs_vec(&**text);
let batch_widths = if glyphs.len() == char_count {
let mut widths = vec![0f32; glyphs.len()];
font.get_widths(&glyphs, &mut widths);
Some(widths)
} else {
None
};
let mut cursor = *position;
let mut buf = [0u8; 4];
for (i, ch) in text.chars().enumerate() {
let s = ch.encode_utf8(&mut buf);
let w = if let Some(ref widths) = batch_widths {
widths[i]
} else {
font.measure_str(&*s, None).0
};
canvas.draw_str(&*s, to_point(cursor), font, &paint);
cursor.x += Pt::new(w) + *char_spacing;
}
} else {
canvas.draw_str(text, to_point(*position), font, &paint);
}
}
DrawCommand::Underline { line, color, width }
| DrawCommand::Line { line, color, width } => {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_stroke(true);
paint.set_stroke_width(f32::from(*width));
paint.set_color4f(to_color4f(*color), None);
let (start, end) = to_line(*line);
canvas.draw_line(start, end, &paint);
}
DrawCommand::Image { rect, image_data } => {
let ptr_key: *const [u8] = Rc::as_ptr(&image_data.data);
if let Some(image) = image_cache.get(&ptr_key) {
canvas.draw_image_rect(image, None, to_rect(*rect), &Paint::default());
} else {
let decoded = decode_image(image_data);
if let Some(image) = decoded {
let image = downsample_if_oversize(image, *rect);
canvas.draw_image_rect(&image, None, to_rect(*rect), &Paint::default());
image_cache.insert(ptr_key, image);
} else {
let magic = &image_data.data[..image_data.data.len().min(4)];
log::warn!(
"[paint] unsupported image format {:?} — could not decode {} bytes \
(magic: {:02x?}); image will be blank",
image_data.format,
image_data.data.len(),
magic,
);
}
}
}
DrawCommand::Rect { rect, color } => {
let mut paint = Paint::default();
paint.set_anti_alias(false);
paint.set_color4f(to_color4f(*color), None);
canvas.draw_rect(to_rect(*rect), &paint);
}
DrawCommand::LinkAnnotation { rect, url } => {
let mut url_bytes = url.as_bytes().to_vec();
url_bytes.push(0);
let url_data = Data::new_copy(&url_bytes);
canvas.annotate_rect_with_url(to_rect(*rect), &url_data);
}
DrawCommand::InternalLink { rect, destination } => {
let mut name_bytes = destination.as_bytes().to_vec();
name_bytes.push(0);
let name_data = Data::new_copy(&name_bytes);
canvas.annotate_link_to_destination(to_rect(*rect), &name_data);
}
DrawCommand::NamedDestination { position, name } => {
let mut name_bytes = name.as_bytes().to_vec();
name_bytes.push(0);
let name_data = Data::new_copy(&name_bytes);
canvas.annotate_named_destination(to_point(*position), &name_data);
}
DrawCommand::Path {
origin,
rotation,
flip_h,
flip_v,
extent,
paths,
fill,
stroke,
effects: _,
} => {
canvas.save();
canvas.translate((origin.x.raw(), origin.y.raw()));
let cx = extent.width.raw() / 2.0;
let cy = extent.height.raw() / 2.0;
let rot_deg = rotation.raw() as f32 / 60_000.0;
if *flip_h || *flip_v || rot_deg != 0.0 {
canvas.translate((cx, cy));
if rot_deg != 0.0 {
canvas.rotate(rot_deg, None);
}
let sx = if *flip_h { -1.0 } else { 1.0 };
let sy = if *flip_v { -1.0 } else { 1.0 };
if sx != 1.0 || sy != 1.0 {
canvas.scale((sx, sy));
}
canvas.translate((-cx, -cy));
}
let skia_path = build_skia_path(paths);
if let Some(paint) = fill_to_paint(fill) {
canvas.draw_path(&skia_path, &paint);
}
if let Some(stroke) = stroke.as_ref() {
let paint = stroke_to_paint(stroke);
let strokable = build_skia_path_stroked_only(paths);
canvas.draw_path(&strokable, &paint);
}
canvas.restore();
}
}
}
}
fn build_skia_path(paths: &[SubPath]) -> Path {
let mut builder = PathBuilder::new();
builder.set_fill_type(PathFillType::Winding);
for sub in paths {
emit_subpath(&mut builder, sub);
}
builder.snapshot()
}
fn build_skia_path_stroked_only(paths: &[SubPath]) -> Path {
let mut builder = PathBuilder::new();
for sub in paths {
if sub.stroked {
emit_subpath(&mut builder, sub);
}
}
builder.snapshot()
}
fn emit_subpath(builder: &mut PathBuilder, sub: &SubPath) {
let mut last_pt: (f32, f32) = (0.0, 0.0);
for verb in &sub.verbs {
match verb {
PathVerb::MoveTo(p) => {
let pt = (p.x.raw(), p.y.raw());
builder.move_to(pt);
last_pt = pt;
}
PathVerb::LineTo(p) => {
let pt = (p.x.raw(), p.y.raw());
builder.line_to(pt);
last_pt = pt;
}
PathVerb::QuadTo(c, p) => {
let pt = (p.x.raw(), p.y.raw());
builder.quad_to((c.x.raw(), c.y.raw()), pt);
last_pt = pt;
}
PathVerb::CubicTo(c1, c2, p) => {
let pt = (p.x.raw(), p.y.raw());
builder.cubic_to((c1.x.raw(), c1.y.raw()), (c2.x.raw(), c2.y.raw()), pt);
last_pt = pt;
}
PathVerb::ArcTo {
radii,
start_angle,
swing_angle,
} => {
let (cx, cy) = last_pt;
let (wr, hr) = (radii.width.raw(), radii.height.raw());
let oval = skia_safe::Rect::from_xywh(cx - wr, cy - hr, wr * 2.0, hr * 2.0);
let start_deg = start_angle.raw() as f32 / 60_000.0;
let sweep_deg = swing_angle.raw() as f32 / 60_000.0;
builder.arc_to(oval, start_deg, sweep_deg, false);
let end_rad = (start_deg + sweep_deg).to_radians();
last_pt = (cx + wr * end_rad.cos(), cy + hr * end_rad.sin());
}
PathVerb::Close => {
builder.close();
}
}
}
}
fn fill_to_paint(fill: &ResolvedFill) -> Option<Paint> {
match fill {
ResolvedFill::None => None,
ResolvedFill::Solid(color) => {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(skia_safe::PaintStyle::Fill);
paint.set_color4f(rgba_to_color4f(*color), None);
Some(paint)
}
ResolvedFill::Gradient(_) => {
log::warn!("paint: gradient fill not yet rendered (Tier 2)");
None
}
ResolvedFill::Blip(_) => {
log::warn!("paint: blip fill not yet rendered (Tier 2)");
None
}
ResolvedFill::Pattern(_) => {
log::warn!("paint: pattern fill not yet rendered (Tier 3)");
None
}
}
}
fn stroke_to_paint(stroke: &ResolvedStroke) -> Paint {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(stroke.width.raw());
paint.set_color4f(rgba_to_color4f(stroke.color), None);
paint.set_stroke_cap(match stroke.cap {
ResolvedLineCap::Butt => skia_safe::PaintCap::Butt,
ResolvedLineCap::Round => skia_safe::PaintCap::Round,
ResolvedLineCap::Square => skia_safe::PaintCap::Square,
});
paint.set_stroke_join(match stroke.join {
ResolvedLineJoin::Round => skia_safe::PaintJoin::Round,
ResolvedLineJoin::Bevel => skia_safe::PaintJoin::Bevel,
ResolvedLineJoin::Miter => skia_safe::PaintJoin::Miter,
});
if let ResolvedDashPattern::Dashes(dashes) = &stroke.dash {
if !dashes.is_empty() {
let floats: Vec<f32> = dashes.iter().map(|p| p.raw()).collect();
if let Some(effect) = PathEffect::dash(&floats, 0.0) {
paint.set_path_effect(effect);
}
}
}
paint
}
fn rgba_to_color4f(c: Rgba) -> Color4f {
Color4f::new(c.r, c.g, c.b, c.a)
}
fn decode_image(entry: &MediaEntry) -> Option<skia_safe::Image> {
use crate::model::ImageFormat;
match entry.format {
ImageFormat::Emf => crate::render::emf::decode_emf_bitmap(&entry.data),
_ => skia_safe::Image::from_encoded(Data::new_copy(&entry.data)),
}
}
fn downsample_if_oversize(
image: skia_safe::Image,
rect: crate::render::geometry::PtRect,
) -> skia_safe::Image {
use skia_safe::CubicResampler;
use skia_safe::{AlphaType, ColorType, ImageInfo, SamplingOptions};
let target_w = (rect.size.width.raw() * IMAGE_DPI_SCALE).ceil() as i32;
let target_h = (rect.size.height.raw() * IMAGE_DPI_SCALE).ceil() as i32;
if image.width() > target_w && image.height() > target_h && target_w > 0 && target_h > 0 {
log::debug!(
"[paint] downsampling image {}×{} → {}×{} (display {:.0}×{:.0}pt @ {:.0} DPI)",
image.width(),
image.height(),
target_w,
target_h,
rect.size.width.raw(),
rect.size.height.raw(),
IMAGE_TARGET_DPI,
);
let info = ImageInfo::new(
(target_w, target_h),
ColorType::RGBA8888,
AlphaType::Opaque,
None,
);
let sampling = SamplingOptions::from(CubicResampler::mitchell());
if let Some(mut surface) = skia_safe::surfaces::raster(&info, None, None) {
let dst = skia_safe::Rect::from_iwh(target_w, target_h);
surface.canvas().draw_image_rect_with_sampling_options(
&image,
None,
dst,
sampling,
&Paint::default(),
);
surface.image_snapshot()
} else {
image
}
} else {
image
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::geometry::{PtOffset, PtSize};
use crate::render::resolve::color::RgbColor;
use std::rc::Rc;
fn test_font_mgr() -> FontMgr {
FontMgr::new()
}
#[test]
fn render_text_command_produces_pdf() {
let font_mgr = test_font_mgr();
let page = LayoutedPage {
commands: vec![DrawCommand::Text {
position: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
text: "Hello world".into(),
font_family: Rc::from("Helvetica"),
char_spacing: Pt::ZERO,
font_size: Pt::new(12.0),
bold: false,
italic: false,
color: RgbColor::BLACK,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], &font_mgr).expect("render_to_pdf must succeed");
assert!(pdf_bytes.len() > 100, "PDF output must be non-trivial");
assert_eq!(&pdf_bytes[..5], b"%PDF-", "output must be valid PDF");
}
#[test]
fn render_text_with_char_spacing_produces_pdf() {
let font_mgr = test_font_mgr();
let page = LayoutedPage {
commands: vec![DrawCommand::Text {
position: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
text: "Spaced".into(),
font_family: Rc::from("Helvetica"),
char_spacing: Pt::new(2.0),
font_size: Pt::new(14.0),
bold: true,
italic: false,
color: RgbColor::BLACK,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], &font_mgr).expect("render_to_pdf must succeed");
assert!(pdf_bytes.len() > 100);
assert_eq!(&pdf_bytes[..5], b"%PDF-");
}
#[test]
fn render_empty_text_produces_pdf() {
let font_mgr = test_font_mgr();
let page = LayoutedPage {
commands: vec![DrawCommand::Text {
position: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
text: Rc::from(""),
font_family: Rc::from("Helvetica"),
char_spacing: Pt::ZERO,
font_size: Pt::new(12.0),
bold: false,
italic: false,
color: RgbColor::BLACK,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], &font_mgr).expect("empty text must not panic");
assert_eq!(&pdf_bytes[..5], b"%PDF-");
}
#[test]
fn render_path_solid_filled_rect() {
use crate::model::dimension::Dimension;
use crate::model::PathFillMode;
use crate::render::layout::draw_command::{
ResolvedDashPattern, ResolvedFill, ResolvedLineCap, ResolvedLineJoin, ResolvedStroke,
};
use crate::render::resolve::drawing_color::Rgba;
use crate::render::resolve::shape_geometry::{PathVerb, SubPath};
let verbs = vec![
PathVerb::MoveTo(PtOffset::new(Pt::ZERO, Pt::ZERO)),
PathVerb::LineTo(PtOffset::new(Pt::new(100.0), Pt::ZERO)),
PathVerb::LineTo(PtOffset::new(Pt::new(100.0), Pt::new(50.0))),
PathVerb::LineTo(PtOffset::new(Pt::ZERO, Pt::new(50.0))),
PathVerb::Close,
];
let page = LayoutedPage {
commands: vec![DrawCommand::Path {
origin: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
rotation: Dimension::new(0),
flip_h: false,
flip_v: false,
extent: PtSize::new(Pt::new(100.0), Pt::new(50.0)),
paths: vec![SubPath {
verbs,
fill_mode: PathFillMode::Norm,
stroked: true,
}],
fill: ResolvedFill::Solid(Rgba {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
stroke: Some(ResolvedStroke {
width: Pt::new(1.0),
color: Rgba::BLACK,
dash: ResolvedDashPattern::Solid,
cap: ResolvedLineCap::Butt,
join: ResolvedLineJoin::Miter,
}),
effects: vec![],
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf = render_to_pdf(&[page], &test_font_mgr()).expect("render path");
assert_eq!(&pdf[..5], b"%PDF-");
}
#[test]
fn render_path_dashed_line() {
use crate::model::dimension::Dimension;
use crate::model::PathFillMode;
use crate::render::layout::draw_command::{
ResolvedDashPattern, ResolvedFill, ResolvedLineCap, ResolvedLineJoin, ResolvedStroke,
};
use crate::render::resolve::drawing_color::Rgba;
use crate::render::resolve::shape_geometry::{PathVerb, SubPath};
let page = LayoutedPage {
commands: vec![DrawCommand::Path {
origin: PtOffset::new(Pt::new(50.0), Pt::new(50.0)),
rotation: Dimension::new(0),
flip_h: false,
flip_v: false,
extent: PtSize::new(Pt::new(100.0), Pt::new(0.0)),
paths: vec![SubPath {
verbs: vec![
PathVerb::MoveTo(PtOffset::new(Pt::ZERO, Pt::ZERO)),
PathVerb::LineTo(PtOffset::new(Pt::new(100.0), Pt::ZERO)),
],
fill_mode: PathFillMode::None,
stroked: true,
}],
fill: ResolvedFill::None,
stroke: Some(ResolvedStroke {
width: Pt::new(2.0),
color: Rgba {
r: 0.85,
g: 0.6,
b: 0.2,
a: 1.0,
},
dash: ResolvedDashPattern::Dashes(vec![Pt::new(6.0), Pt::new(3.0)]),
cap: ResolvedLineCap::Round,
join: ResolvedLineJoin::Round,
}),
effects: vec![],
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf = render_to_pdf(&[page], &test_font_mgr()).expect("render dashed line");
assert_eq!(&pdf[..5], b"%PDF-");
}
#[test]
fn render_unicode_text_produces_pdf() {
let font_mgr = test_font_mgr();
let page = LayoutedPage {
commands: vec![DrawCommand::Text {
position: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
text: "Ärzte für Ökologie — 日本語".into(),
font_family: Rc::from("Helvetica"),
char_spacing: Pt::ZERO,
font_size: Pt::new(11.0),
bold: false,
italic: false,
color: RgbColor::BLACK,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], &font_mgr).expect("unicode text must not panic");
assert_eq!(&pdf_bytes[..5], b"%PDF-");
}
}