use std::collections::HashMap;
use std::rc::Rc;
use rustc_hash::FxHashMap;
use skia_safe::{
canvas::SrcRectConstraint, path_effect::PathEffect, pdf, BlurStyle, Color4f, Data, MaskFilter,
Paint, Path, PathBuilder, PathFillType, TextBlob,
};
use crate::render::dimension::Pt;
use crate::render::emoji::raster::EmojiRasterizer;
use crate::render::error::RenderError;
use crate::render::fonts::{self, FontRegistry};
use crate::render::layout::draw_command::{
DrawCommand, LayoutedPage, ResolvedDashPattern, ResolvedEffect, 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 = 300.0;
const IMAGE_DPI_SCALE: f32 = IMAGE_TARGET_DPI / 72.0;
type CropKey = Option<(i32, i32, i32, i32)>;
type ImageCacheKey = (*const [u8], i32, i32, CropKey);
type ImageCache = HashMap<ImageCacheKey, skia_safe::Image>;
fn downsample_target(rect: crate::render::geometry::PtRect) -> (i32, i32) {
(
(rect.size.width.raw() * IMAGE_DPI_SCALE).ceil() as i32,
(rect.size.height.raw() * IMAGE_DPI_SCALE).ceil() as i32,
)
}
fn quantize_crop(crop: &crate::render::geometry::PtRect) -> (i32, i32, i32, i32) {
let q = |v: f32| (v * 100_000.0).round() as i32;
(
q(crop.origin.x.raw()),
q(crop.origin.y.raw()),
q(crop.size.width.raw()),
q(crop.size.height.raw()),
)
}
pub fn render_to_pdf(
pages: &[LayoutedPage],
registry: &FontRegistry,
) -> 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: ImageCache = HashMap::new();
let mut emoji_rasterizer = EmojiRasterizer::default();
let mut blob_cache: FxHashMap<usize, FxHashMap<Box<str>, TextBlob>> = FxHashMap::default();
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,
registry,
&mut font_cache,
&mut image_cache,
&mut emoji_rasterizer,
&mut blob_cache,
);
}
doc = on_page.end_page();
}
doc.close();
Ok(pdf_bytes)
}
fn render_page(
canvas: &skia_safe::Canvas,
page: &LayoutedPage,
registry: &FontRegistry,
font_cache: &mut fonts::FontCache,
image_cache: &mut ImageCache,
emoji_rasterizer: &mut EmojiRasterizer,
blob_cache: &mut FxHashMap<usize, FxHashMap<Box<str>, TextBlob>>,
) {
let mut text_paint = Paint::default();
text_paint.set_anti_alias(true);
let mut stroke_paint = Paint::default();
stroke_paint.set_anti_alias(true);
stroke_paint.set_stroke(true);
let mut rect_paint = Paint::default();
rect_paint.set_anti_alias(false);
let default_paint = Paint::default();
for cmd in &page.commands {
match cmd {
DrawCommand::Text {
position,
text,
font_family,
char_spacing,
font_size,
bold,
italic,
color,
text_scale,
} => {
let (slot, base_font) =
font_cache.get_indexed(registry, font_family, *font_size, *bold, *italic);
let scaled_font;
let (font, blob_slot): (&skia_safe::Font, Option<usize>) =
if (*text_scale - 1.0).abs() > f32::EPSILON {
let mut f = base_font.clone();
f.set_scale_x(*text_scale);
scaled_font = f;
(&scaled_font, None)
} else {
(base_font, Some(slot))
};
log::trace!(
"[paint] '{}' → font='{}' size={:.1}pt bold={} italic={} scale={:.2}",
&text[..text.len().min(30)],
font.typeface().family_name(),
font_size.raw(),
bold,
italic,
text_scale,
);
text_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, &text_paint);
cursor.x += Pt::new(w) + *char_spacing;
}
} else if let Some(slot) = blob_slot {
let inner = blob_cache.entry(slot).or_default();
if let Some(blob) = inner.get(&**text) {
canvas.draw_text_blob(blob, to_point(*position), &text_paint);
} else if let Some(blob) = TextBlob::from_str(&**text, font) {
canvas.draw_text_blob(&blob, to_point(*position), &text_paint);
inner.insert(Box::from(&**text), blob);
}
} else {
canvas.draw_str(text, to_point(*position), font, &text_paint);
}
}
DrawCommand::Underline { line, color, width }
| DrawCommand::Line { line, color, width } => {
stroke_paint.set_stroke_width(f32::from(*width));
stroke_paint.set_color4f(to_color4f(*color), None);
let (start, end) = to_line(*line);
canvas.draw_line(start, end, &stroke_paint);
}
DrawCommand::Image {
rect,
image_data,
src_rect,
} => {
let resolved = match src_rect {
None => Some((*rect, None)),
Some(sr) => resolve_src_padding(*rect, sr).map(|(d, c)| (d, Some(c))),
};
if let Some((draw_rect, crop)) = resolved {
let dst = to_rect(draw_rect);
let (target_w, target_h) = downsample_target(draw_rect);
let key = (
Rc::as_ptr(&image_data.data),
target_w,
target_h,
crop.as_ref().map(quantize_crop),
);
if let Some(image) = image_cache.get(&key) {
canvas.draw_image_rect(image, None, dst, &default_paint);
} else if let Some(decoded) = decode_image(image_data) {
match &crop {
Some(crop) => match prepare_cropped(&decoded, crop, draw_rect) {
Some(image) => {
canvas.draw_image_rect(&image, None, dst, &default_paint);
image_cache.insert(key, image);
}
None => {
let src = src_pixel_rect(&decoded, crop);
canvas.draw_image_rect(
&decoded,
Some((&src, SrcRectConstraint::Strict)),
dst,
&default_paint,
);
}
},
None => {
let image = downsample_if_oversize(decoded, draw_rect);
canvas.draw_image_rect(&image, None, dst, &default_paint);
image_cache.insert(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::EmojiCluster {
rect,
text,
typeface,
size,
presentation,
structure,
} => {
use crate::render::emoji::cluster::EmojiCluster;
use skia_safe::{CubicResampler, SamplingOptions};
let cluster = EmojiCluster {
text: text.as_str(),
presentation: *presentation,
structure: *structure,
};
let img = emoji_rasterizer.rasterize(&cluster, typeface, *size, rect.size);
let sampling = SamplingOptions::from(CubicResampler::mitchell());
canvas.draw_image_rect_with_sampling_options(
&img.image,
None,
to_rect(*rect),
sampling,
&default_paint,
);
}
DrawCommand::Rect { rect, color } => {
rect_paint.set_color4f(to_color4f(*color), None);
canvas.draw_rect(to_rect(*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);
let strokable = build_skia_path_stroked_only(paths);
for effect in effects {
paint_effect(
canvas,
effect,
fill,
stroke.as_ref(),
&skia_path,
&strokable,
);
}
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);
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 paint_effect(
canvas: &skia_safe::Canvas,
effect: &ResolvedEffect,
fill: &ResolvedFill,
stroke: Option<&ResolvedStroke>,
shape_path: &Path,
strokable_path: &Path,
) {
match effect {
ResolvedEffect::OuterShadow {
blur_radius,
offset,
color,
} => {
let sigma = (blur_radius.raw() * 0.5).max(0.0);
let mask = if sigma > 0.0 {
MaskFilter::blur(BlurStyle::Normal, sigma, None)
} else {
None
};
canvas.save();
canvas.translate((offset.x.raw(), offset.y.raw()));
if !matches!(fill, ResolvedFill::None) {
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);
if let Some(m) = mask.clone() {
paint.set_mask_filter(m);
}
canvas.draw_path(shape_path, &paint);
}
if let Some(s) = stroke {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(s.width.raw());
paint.set_stroke_cap(match s.cap {
ResolvedLineCap::Butt => skia_safe::PaintCap::Butt,
ResolvedLineCap::Round => skia_safe::PaintCap::Round,
ResolvedLineCap::Square => skia_safe::PaintCap::Square,
});
paint.set_stroke_join(match s.join {
ResolvedLineJoin::Round => skia_safe::PaintJoin::Round,
ResolvedLineJoin::Bevel => skia_safe::PaintJoin::Bevel,
ResolvedLineJoin::Miter => skia_safe::PaintJoin::Miter,
});
paint.set_color4f(rgba_to_color4f(*color), None);
if let Some(m) = mask.clone() {
paint.set_mask_filter(m);
}
canvas.draw_path(strokable_path, &paint);
}
canvas.restore();
}
}
}
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, target_h) = downsample_target(rect);
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
}
}
fn src_pixel_rect(
image: &skia_safe::Image,
crop: &crate::render::geometry::PtRect,
) -> skia_safe::Rect {
let (iw, ih) = (image.width() as f32, image.height() as f32);
skia_safe::Rect::from_xywh(
crop.origin.x.raw() * iw,
crop.origin.y.raw() * ih,
crop.size.width.raw() * iw,
crop.size.height.raw() * ih,
)
}
fn prepare_cropped(
image: &skia_safe::Image,
src_rect: &crate::render::geometry::PtRect,
display: crate::render::geometry::PtRect,
) -> Option<skia_safe::Image> {
use skia_safe::{AlphaType, ColorType, CubicResampler, ImageInfo, SamplingOptions};
let src = src_pixel_rect(image, src_rect);
let (disp_w, disp_h) = downsample_target(display);
let target_w = disp_w.min(src.width().round() as i32).max(1);
let target_h = disp_h.min(src.height().round() as i32).max(1);
let alpha = if image.alpha_type() == AlphaType::Opaque {
AlphaType::Opaque
} else {
AlphaType::Premul
};
let info = ImageInfo::new((target_w, target_h), ColorType::RGBA8888, alpha, None);
let sampling = SamplingOptions::from(CubicResampler::mitchell());
let 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,
Some((&src, SrcRectConstraint::Strict)),
dst,
sampling,
&Paint::default(),
);
Some(surface.image_snapshot())
}
fn resolve_src_padding(
dst: crate::render::geometry::PtRect,
src: &crate::render::geometry::PtRect,
) -> Option<(
crate::render::geometry::PtRect,
crate::render::geometry::PtRect,
)> {
use crate::render::geometry::PtRect;
let (sx0, sy0) = (src.origin.x.raw(), src.origin.y.raw());
let (sw, sh) = (src.size.width.raw(), src.size.height.raw());
if sw <= 0.0 || sh <= 0.0 {
return None;
}
let (sx1, sy1) = (sx0 + sw, sy0 + sh);
let (cx0, cy0) = (sx0.max(0.0), sy0.max(0.0));
let (cx1, cy1) = (sx1.min(1.0), sy1.min(1.0));
if cx1 <= cx0 || cy1 <= cy0 {
return None; }
let (tx0, tx1) = ((cx0 - sx0) / sw, (cx1 - sx0) / sw);
let (ty0, ty1) = ((cy0 - sy0) / sh, (cy1 - sy0) / sh);
let (dx, dy) = (dst.origin.x.raw(), dst.origin.y.raw());
let (dw, dh) = (dst.size.width.raw(), dst.size.height.raw());
let inset_dst = PtRect::from_xywh(
Pt::new(dx + tx0 * dw),
Pt::new(dy + ty0 * dh),
Pt::new((tx1 - tx0) * dw),
Pt::new((ty1 - ty0) * dh),
);
let crop = PtRect::from_xywh(
Pt::new(cx0),
Pt::new(cy0),
Pt::new(cx1 - cx0),
Pt::new(cy1 - cy0),
);
Some((inset_dst, crop))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::geometry::{PtOffset, PtSize};
use crate::render::resolve::color::RgbColor;
use skia_safe::FontMgr;
use std::rc::Rc;
fn test_font_mgr() -> FontMgr {
FontMgr::new()
}
fn test_registry() -> FontRegistry {
FontRegistry::new(test_font_mgr())
}
#[test]
fn render_text_command_produces_pdf() {
let registry = test_registry();
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,
text_scale: 1.0,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], ®istry).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 registry = test_registry();
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,
text_scale: 1.0,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], ®istry).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 registry = test_registry();
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,
text_scale: 1.0,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], ®istry).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_registry()).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_registry()).expect("render dashed line");
assert_eq!(&pdf[..5], b"%PDF-");
}
#[test]
fn render_unicode_text_produces_pdf() {
let registry = test_registry();
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,
text_scale: 1.0,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
};
let pdf_bytes = render_to_pdf(&[page], ®istry).expect("unicode text must not panic");
assert_eq!(&pdf_bytes[..5], b"%PDF-");
}
fn rect(w: f32, h: f32) -> crate::render::geometry::PtRect {
crate::render::geometry::PtRect::from_xywh(Pt::ZERO, Pt::ZERO, Pt::new(w), Pt::new(h))
}
#[test]
fn downsample_target_scales_points_to_target_dpi() {
assert_eq!(downsample_target(rect(72.0, 36.0)), (300, 150));
}
#[test]
fn quantize_crop_distinguishes_crops_but_matches_spec_identical_ones() {
let a = crate::render::geometry::PtRect::from_xywh(
Pt::new(0.25),
Pt::new(0.25),
Pt::new(0.5),
Pt::new(0.5),
);
let b = crate::render::geometry::PtRect::from_xywh(
Pt::new(0.1),
Pt::new(0.1),
Pt::new(0.8),
Pt::new(0.8),
);
assert_ne!(quantize_crop(&a), quantize_crop(&b));
assert_eq!(quantize_crop(&a), quantize_crop(&a));
assert_eq!(quantize_crop(&a), (25000, 25000, 50000, 50000));
}
fn solid_image(w: i32, h: i32) -> skia_safe::Image {
use skia_safe::{AlphaType, ColorType, ImageInfo};
let info = ImageInfo::new((w, h), ColorType::RGBA8888, AlphaType::Opaque, None);
let mut surface = skia_safe::surfaces::raster(&info, None, None).expect("raster surface");
surface.canvas().clear(Color4f::new(0.1, 0.2, 0.3, 1.0));
surface.image_snapshot()
}
#[test]
fn prepare_cropped_embeds_only_the_visible_region_not_the_whole_image() {
let src = solid_image(1000, 1000);
let crop = crate::render::geometry::PtRect::from_xywh(
Pt::new(0.45),
Pt::new(0.45),
Pt::new(0.1),
Pt::new(0.1),
);
let out = prepare_cropped(&src, &crop, rect(72.0, 72.0)).expect("prepare");
assert_eq!((out.width(), out.height()), (100, 100));
}
#[test]
fn prepare_cropped_downsamples_a_high_res_crop_to_the_display_target() {
let src = solid_image(4000, 4000);
let crop = crate::render::geometry::PtRect::from_xywh(
Pt::new(0.25),
Pt::new(0.25),
Pt::new(0.5),
Pt::new(0.5),
);
let out = prepare_cropped(&src, &crop, rect(72.0, 72.0)).expect("prepare");
assert_eq!((out.width(), out.height()), (300, 300));
}
fn xywh(x: f32, y: f32, w: f32, h: f32) -> crate::render::geometry::PtRect {
crate::render::geometry::PtRect::from_xywh(Pt::new(x), Pt::new(y), Pt::new(w), Pt::new(h))
}
#[test]
fn resolve_src_padding_is_a_no_op_for_an_in_bounds_crop() {
let dst = rect(100.0, 50.0);
let crop = xywh(0.1, 0.2, 0.5, 0.6);
let (out_dst, out_crop) = resolve_src_padding(dst, &crop).expect("visible");
assert_eq!(out_dst.origin.x.raw(), 0.0);
assert_eq!(out_dst.origin.y.raw(), 0.0);
assert_eq!(out_dst.size.width.raw(), 100.0);
assert_eq!(out_dst.size.height.raw(), 50.0);
assert!((out_crop.origin.x.raw() - 0.1).abs() < 1e-5);
assert!((out_crop.size.width.raw() - 0.5).abs() < 1e-5);
}
#[test]
fn resolve_src_padding_insets_dst_and_clamps_crop_for_negative_insets() {
let dst = xywh(0.0, 0.0, 100.0, 100.0);
let src = xywh(-0.2, 0.0, 1.4, 1.0);
let (out_dst, out_crop) = resolve_src_padding(dst, &src).expect("visible");
assert!((out_crop.origin.x.raw() - 0.0).abs() < 1e-5);
assert!((out_crop.size.width.raw() - 1.0).abs() < 1e-5);
assert!((out_dst.origin.x.raw() - 100.0 * 0.2 / 1.4).abs() < 1e-3);
assert!((out_dst.size.width.raw() - 100.0 * 1.0 / 1.4).abs() < 1e-3);
assert_eq!(out_dst.origin.y.raw(), 0.0);
assert_eq!(out_dst.size.height.raw(), 100.0);
}
#[test]
fn resolve_src_padding_returns_none_when_region_is_wholly_outside() {
let dst = rect(100.0, 100.0);
let src = xywh(1.5, 0.0, 0.3, 1.0);
assert!(resolve_src_padding(dst, &src).is_none());
}
}