use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::sync::OnceLock;
use image::{DynamicImage, RgbaImage};
pub use image::ImageFormat;
use tiny_skia::{
FillRule, Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform as SkTransform,
};
use crate::error::{OfdError, Result};
use crate::model::graphics::{
CompositeObject, CtCgTransform, CtColor, CtVectorG, ImageObject, PageBlock, PathObject,
TextObject, parse_deltas,
};
use crate::model::resource::{CtColorSpace, CtDrawParam};
use crate::types::{StBox, StLoc, StRefId, parent_dir, resolve_path};
use crate::{LoadedDocument, OfdReader, PageObject, PageRef};
use std::io::{Read, Seek};
const MAX_DIMENSION: u32 = 20_000;
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub dpi: f64,
pub background: Option<[u8; 4]>,
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
dpi: 150.0,
background: Some([255, 255, 255, 255]),
}
}
}
impl RenderOptions {
pub fn with_dpi(dpi: f64) -> Self {
RenderOptions {
dpi,
..Default::default()
}
}
pub fn background(mut self, background: Option<[u8; 4]>) -> Self {
self.background = background;
self
}
}
const MM_PER_INCH: f64 = 25.4;
struct FontRes {
data: Option<Vec<u8>>,
index: u32,
}
static SYSTEM_FONTS: OnceLock<fontdb::Database> = OnceLock::new();
fn system_fonts() -> &'static fontdb::Database {
SYSTEM_FONTS.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
db
})
}
const CJK_FALLBACK_FAMILIES: &[&str] = &[
"Noto Sans CJK SC",
"Source Han Sans SC",
"Noto Sans SC",
"Microsoft YaHei",
"微软雅黑",
"SimSun",
"宋体",
"WenQuanYi Micro Hei",
"WenQuanYi Zen Hei",
"Noto Serif CJK SC",
];
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0x3000..=0x303F | 0xFF00..=0xFFEF | 0x2E80..=0x2EFF )
}
fn lookup_system_font(
name: &str,
family: &str,
bold: bool,
italic: bool,
) -> Option<(Vec<u8>, u32)> {
let db = system_fonts();
let style = if italic {
fontdb::Style::Italic
} else {
fontdb::Style::Normal
};
let weight = if bold {
fontdb::Weight::BOLD
} else {
fontdb::Weight::NORMAL
};
let query_family = |fam: &str| -> Option<(Vec<u8>, u32)> {
let fam = fam.trim();
if fam.is_empty() {
return None;
}
let q = fontdb::Query {
families: &[fontdb::Family::Name(fam)],
weight,
style,
stretch: fontdb::Stretch::Normal,
};
db.query(&q)
.and_then(|id| db.with_face_data(id, |data, index| (data.to_vec(), index)))
};
for cand in [name, family] {
if let Some(data) = query_family(cand) {
return Some(data);
}
}
if name.chars().chain(family.chars()).any(is_cjk) {
for fam in CJK_FALLBACK_FAMILIES {
if let Some(data) = query_family(fam) {
return Some(data);
}
}
}
let q = fontdb::Query {
families: &[fontdb::Family::SansSerif],
weight,
style,
stretch: fontdb::Stretch::Normal,
};
db.query(&q)
.and_then(|id| db.with_face_data(id, |data, index| (data.to_vec(), index)))
}
#[derive(Default)]
struct DocResources {
color_spaces: HashMap<u64, CtColorSpace>,
draw_params: HashMap<u64, CtDrawParam>,
fonts: HashMap<u64, FontRes>,
media: HashMap<u64, Vec<u8>>,
vector_gs: HashMap<u64, CtVectorG>,
default_cs: Option<u64>,
}
#[derive(Debug, Clone, Copy)]
struct Mat {
a: f64,
b: f64,
c: f64,
d: f64,
e: f64,
f: f64,
}
impl Mat {
fn identity() -> Self {
Mat {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: 0.0,
}
}
fn translate(x: f64, y: f64) -> Self {
Mat {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: x,
f: y,
}
}
fn scale(sx: f64, sy: f64) -> Self {
Mat {
a: sx,
b: 0.0,
c: 0.0,
d: sy,
e: 0.0,
f: 0.0,
}
}
fn rotate(theta: f64) -> Self {
let (s, c) = theta.sin_cos();
Mat {
a: c,
b: s,
c: -s,
d: c,
e: 0.0,
f: 0.0,
}
}
fn from_array(v: &[f64]) -> Option<Self> {
if v.len() == 6 {
Some(Mat {
a: v[0],
b: v[1],
c: v[2],
d: v[3],
e: v[4],
f: v[5],
})
} else {
None
}
}
fn mul(self, rhs: Mat) -> Mat {
Mat {
a: self.a * rhs.a + self.c * rhs.b,
b: self.b * rhs.a + self.d * rhs.b,
c: self.a * rhs.c + self.c * rhs.d,
d: self.b * rhs.c + self.d * rhs.d,
e: self.a * rhs.e + self.c * rhs.f + self.e,
f: self.b * rhs.e + self.d * rhs.f + self.f,
}
}
fn to_skia(self) -> SkTransform {
SkTransform::from_row(
self.a as f32,
self.b as f32,
self.c as f32,
self.d as f32,
self.e as f32,
self.f as f32,
)
}
}
type Rgba = [u8; 4];
impl<R: Read + Seek> OfdReader<R> {
pub fn render_page(
&mut self,
doc: &LoadedDocument,
page_index: usize,
options: &RenderOptions,
) -> Result<Pixmap> {
let pages = doc.pages();
let page_ref = pages
.get(page_index)
.ok_or_else(|| OfdError::Render(format!("page index {page_index} out of range")))?
.clone();
let page = self.load_page(doc, &page_ref)?;
let mut resources = self.build_resources(doc)?;
let page_res: Vec<StLoc> = page.page_res.clone();
for loc in &page_res {
self.load_res_into(&doc.base, loc, &mut resources);
}
let mut bg_templates: Vec<PageObject> = Vec::new();
let mut fg_templates: Vec<PageObject> = Vec::new();
for tref in &page.templates {
let Some(tpl) = doc
.template_pages()
.iter()
.find(|t| t.id == tref.template_id.as_id())
.cloned()
else {
continue;
};
let zorder = tref
.z_order
.clone()
.or_else(|| tpl.z_order.clone())
.unwrap_or_else(|| "Background".to_string());
let Ok(tpl_page) = self.load_template(doc, &tpl) else {
continue;
};
if zorder.eq_ignore_ascii_case("Foreground") {
fg_templates.push(tpl_page);
} else {
bg_templates.push(tpl_page);
}
}
let area = page
.area
.as_ref()
.or(doc.document.common_data.page_area.as_ref())
.map(|a| a.physical_box)
.unwrap_or(StBox::A4_MM);
let scale = options.dpi / MM_PER_INCH;
let width_px = ((area.width * scale).round() as i64).max(1);
let height_px = ((area.height * scale).round() as i64).max(1);
if width_px > MAX_DIMENSION as i64 || height_px > MAX_DIMENSION as i64 {
return Err(OfdError::Render(format!(
"rendered size {width_px}x{height_px} exceeds limit {MAX_DIMENSION}"
)));
}
let mut pixmap = Pixmap::new(width_px as u32, height_px as u32)
.ok_or_else(|| OfdError::Render("failed to allocate pixmap".to_string()))?;
if let Some([r, g, b, a]) = options.background {
pixmap.fill(tiny_skia::Color::from_rgba8(r, g, b, a));
}
let page_to_device =
Mat::translate(-area.x * scale, -area.y * scale).mul(Mat::scale(scale, scale));
for tpl in &bg_templates {
render_page_object(&mut pixmap, &resources, page_to_device, tpl);
}
render_page_object(&mut pixmap, &resources, page_to_device, &page);
for tpl in &fg_templates {
render_page_object(&mut pixmap, &resources, page_to_device, tpl);
}
self.render_annotations(doc, &page_ref, &resources, page_to_device, &mut pixmap);
Ok(pixmap)
}
fn render_annotations(
&mut self,
doc: &LoadedDocument,
page_ref: &PageRef,
res: &DocResources,
page_to_device: Mat,
pixmap: &mut Pixmap,
) {
let Some(loc) = doc.document.annotations.clone() else {
return;
};
let path = resolve_path(&doc.base, &loc);
let Ok(annotations) = self.package.parse::<crate::Annotations>(&path) else {
return;
};
let ann_dir = parent_dir(&path).to_string();
let page_id = page_ref.id.value();
for pg in &annotations.pages {
if pg.page_id.value() != page_id {
continue;
}
let file_path = resolve_path(&ann_dir, &pg.file_loc);
let Ok(page_annot) = self.package.parse::<crate::PageAnnot>(&file_path) else {
continue;
};
for annot in &page_annot.annots {
if annot.visible == Some(false) {
continue;
}
let Some(app) = &annot.appearance else {
continue;
};
let app_to_device =
page_to_device.mul(Mat::translate(app.boundary.x, app.boundary.y));
for obj in &app.objects {
render_block(pixmap, res, app_to_device, obj, None);
}
}
}
}
pub fn render_page_to_image(
&mut self,
doc: &LoadedDocument,
page_index: usize,
options: &RenderOptions,
) -> Result<RgbaImage> {
let pixmap = self.render_page(doc, page_index, options)?;
Ok(pixmap_to_image(&pixmap))
}
pub fn render_page_to_bytes(
&mut self,
doc: &LoadedDocument,
page_index: usize,
options: &RenderOptions,
format: ImageFormat,
) -> Result<Vec<u8>> {
let img = self.render_page_to_image(doc, page_index, options)?;
encode_image(img, format)
}
pub fn render_page_to_file<P: AsRef<Path>>(
&mut self,
doc: &LoadedDocument,
page_index: usize,
options: &RenderOptions,
path: P,
) -> Result<()> {
let path = path.as_ref();
let format = format_from_path(path).ok_or_else(|| {
OfdError::Render(format!(
"cannot infer image format from path: {}",
path.display()
))
})?;
let bytes = self.render_page_to_bytes(doc, page_index, options, format)?;
std::fs::write(path, bytes)?;
Ok(())
}
fn build_resources(&mut self, doc: &LoadedDocument) -> Result<DocResources> {
let mut res = DocResources {
default_cs: doc.document.common_data.default_cs.map(|id| id.value()),
..Default::default()
};
let locs: Vec<StLoc> = doc
.public_res()
.iter()
.chain(doc.document_res().iter())
.cloned()
.collect();
for loc in &locs {
self.load_res_into(&doc.base, loc, &mut res);
}
Ok(res)
}
fn load_res_into(&mut self, base_dir: &str, loc: &StLoc, res: &mut DocResources) {
let res_path = resolve_path(base_dir, loc);
let Ok(parsed) = self.package.parse::<crate::Res>(&res_path) else {
return;
};
let res_dir = parent_dir(&res_path).to_string();
let data_base = resolve_path(&res_dir, &parsed.base_loc);
for cs in parsed.color_spaces() {
res.color_spaces.insert(cs.id.value(), cs.clone());
}
for dp in parsed.draw_params() {
res.draw_params.insert(dp.id.value(), dp.clone());
}
for font in parsed.fonts() {
let embedded = font
.font_file
.as_ref()
.map(|ff| resolve_path(&data_base, ff))
.and_then(|p| self.package.read(&p).ok());
let res_font = match embedded {
Some(data) => FontRes {
data: Some(data),
index: 0,
},
None => {
let (data, index) = lookup_system_font(
&font.font_name,
font.family_name.as_deref().unwrap_or(""),
font.bold.unwrap_or(false),
font.italic.unwrap_or(false),
)
.map(|(d, i)| (Some(d), i))
.unwrap_or((None, 0));
FontRes { data, index }
}
};
res.fonts.insert(font.id.value(), res_font);
}
for mm in parsed.multi_medias() {
let p = resolve_path(&data_base, &mm.media_file);
if let Ok(bytes) = self.package.read(&p) {
res.media.insert(mm.id.value(), bytes);
}
}
for vg in parsed.composite_graphic_units() {
res.vector_gs.insert(vg.id.value(), vg.clone());
}
}
}
fn render_page_object(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
page: &PageObject,
) {
let Some(content) = &page.content else {
return;
};
let mut layers: Vec<_> = content.layers.iter().collect();
layers.sort_by_key(|l| match l.layer_type.as_deref() {
Some("Background") => 0,
Some("Foreground") => 2,
_ => 1, });
for layer in layers {
let layer_dp = resolve_draw_param(res, layer.draw_param);
for obj in &layer.objects {
render_block(pixmap, res, page_to_device, obj, layer_dp.as_ref());
}
}
}
fn render_block(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
block: &PageBlock,
inherited: Option<&CtDrawParam>,
) {
match block {
PageBlock::Path(p) => render_path(pixmap, res, page_to_device, p, inherited),
PageBlock::Text(t) => render_text(pixmap, res, page_to_device, t, inherited),
PageBlock::Image(i) => render_image(pixmap, res, page_to_device, i),
PageBlock::Block(g) => {
for obj in &g.objects {
render_block(pixmap, res, page_to_device, obj, inherited);
}
}
PageBlock::Composite(c) => render_composite(pixmap, res, page_to_device, c, 0, inherited),
}
}
const MAX_COMPOSITE_DEPTH: u32 = 16;
fn render_composite(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
obj: &CompositeObject,
depth: u32,
inherited: Option<&CtDrawParam>,
) {
if depth >= MAX_COMPOSITE_DEPTH {
return;
}
let Some(vg) = res.vector_gs.get(&obj.resource_id.value()) else {
return;
};
let Some(content) = &vg.content else {
return;
};
let inner_to_device = object_to_device(
page_to_device,
&obj.boundary,
obj.ctm.as_ref().map(|a| a.as_slice()),
);
for block in &content.objects {
render_block_at_depth(pixmap, res, inner_to_device, block, depth + 1, inherited);
}
}
fn render_block_at_depth(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
block: &PageBlock,
depth: u32,
inherited: Option<&CtDrawParam>,
) {
match block {
PageBlock::Composite(c) => {
render_composite(pixmap, res, page_to_device, c, depth, inherited)
}
PageBlock::Block(g) => {
for obj in &g.objects {
render_block_at_depth(pixmap, res, page_to_device, obj, depth, inherited);
}
}
_ => render_block(pixmap, res, page_to_device, block, inherited),
}
}
fn object_to_device(page_to_device: Mat, boundary: &StBox, ctm: Option<&[f64]>) -> Mat {
let m = ctm.and_then(Mat::from_array).unwrap_or_else(Mat::identity);
page_to_device
.mul(Mat::translate(boundary.x, boundary.y))
.mul(m)
}
fn resolve_draw_param(res: &DocResources, id: Option<StRefId>) -> Option<CtDrawParam> {
let mut cur = res.draw_params.get(&id?.value())?.clone();
let mut visited = vec![cur.id.value()];
while let Some(rid) = cur.relative.map(|r| r.value()) {
if visited.contains(&rid) {
break;
}
let Some(parent) = res.draw_params.get(&rid) else {
break;
};
visited.push(rid);
fill_missing_draw_param(&mut cur, parent);
cur.relative = parent.relative;
}
Some(cur)
}
fn fill_missing_draw_param(dst: &mut CtDrawParam, src: &CtDrawParam) {
dst.line_width = dst.line_width.or(src.line_width);
dst.join = dst.join.take().or_else(|| src.join.clone());
dst.cap = dst.cap.take().or_else(|| src.cap.clone());
dst.miter_limit = dst.miter_limit.or(src.miter_limit);
dst.dash_offset = dst.dash_offset.or(src.dash_offset);
dst.dash_pattern = dst.dash_pattern.take().or_else(|| src.dash_pattern.clone());
dst.fill_color = dst.fill_color.take().or_else(|| src.fill_color.clone());
dst.stroke_color = dst.stroke_color.take().or_else(|| src.stroke_color.clone());
}
fn effective_draw_param(
res: &DocResources,
id: Option<StRefId>,
inherited: Option<&CtDrawParam>,
) -> Option<CtDrawParam> {
match (resolve_draw_param(res, id), inherited) {
(Some(mut own), Some(parent)) => {
fill_missing_draw_param(&mut own, parent);
Some(own)
}
(Some(own), None) => Some(own),
(None, Some(parent)) => Some(parent.clone()),
(None, None) => None,
}
}
fn render_path(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
obj: &PathObject,
inherited: Option<&CtDrawParam>,
) {
let Some(data) = &obj.abbreviated_data else {
return;
};
let Some(path) = build_path(data) else {
return;
};
let transform = object_to_device(
page_to_device,
&obj.boundary,
obj.ctm.as_ref().map(|a| a.as_slice()),
)
.to_skia();
let dp = effective_draw_param(res, obj.draw_param, inherited);
let dp = dp.as_ref();
let fill = obj.fill.unwrap_or(false);
let stroke = obj.stroke.unwrap_or(true);
let obj_alpha = obj.alpha;
if fill {
let color = obj
.fill_color
.as_ref()
.or_else(|| dp.and_then(|d| d.fill_color.as_ref()))
.map(|c| resolve_color(res, c, obj_alpha))
.unwrap_or([0, 0, 0, alpha_or_opaque(obj_alpha)]);
let mut paint = Paint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
let rule = match obj.rule.as_deref() {
Some("Even-Odd") | Some("EvenOdd") => FillRule::EvenOdd,
_ => FillRule::Winding,
};
pixmap.fill_path(&path, &paint, rule, transform, None);
}
if stroke {
let color = obj
.stroke_color
.as_ref()
.or_else(|| dp.and_then(|d| d.stroke_color.as_ref()))
.map(|c| resolve_color(res, c, obj_alpha))
.unwrap_or([0, 0, 0, alpha_or_opaque(obj_alpha)]);
let width = obj
.line_width
.or_else(|| dp.and_then(|d| d.line_width))
.unwrap_or(0.353);
let mut paint = Paint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
let cap = obj
.cap
.as_deref()
.or_else(|| dp.and_then(|d| d.cap.as_deref()));
let join = obj
.join
.as_deref()
.or_else(|| dp.and_then(|d| d.join.as_deref()));
let miter = obj
.miter_limit
.or_else(|| dp.and_then(|d| d.miter_limit))
.unwrap_or(4.234);
let dash = obj
.dash_pattern
.as_ref()
.or_else(|| dp.and_then(|d| d.dash_pattern.as_ref()))
.map(|p| p.as_slice().iter().map(|&v| v as f32).collect::<Vec<_>>())
.filter(|p| p.len() >= 2 && p.iter().any(|&v| v > 0.0))
.and_then(|p| {
let off = obj
.dash_offset
.or_else(|| dp.and_then(|d| d.dash_offset))
.unwrap_or(0.0) as f32;
tiny_skia::StrokeDash::new(p, off)
});
let stroke_style = Stroke {
width: width.max(f64::MIN_POSITIVE) as f32,
line_cap: match cap {
Some("Round") => tiny_skia::LineCap::Round,
Some("Square") => tiny_skia::LineCap::Square,
_ => tiny_skia::LineCap::Butt,
},
line_join: match join {
Some("Round") => tiny_skia::LineJoin::Round,
Some("Bevel") => tiny_skia::LineJoin::Bevel,
_ => tiny_skia::LineJoin::Miter,
},
miter_limit: miter as f32,
dash,
};
pixmap.stroke_path(&path, &paint, &stroke_style, transform, None);
}
}
fn build_path(data: &str) -> Option<tiny_skia::Path> {
let normalized = data.replace(',', " ");
let mut tokens = normalized.split_whitespace().peekable();
let mut pb = PathBuilder::new();
let mut started = false;
let mut cur = (0.0_f64, 0.0_f64);
let mut start = (0.0_f64, 0.0_f64);
fn take(
tokens: &mut std::iter::Peekable<std::str::SplitWhitespace>,
n: usize,
) -> Option<Vec<f64>> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
let t = tokens.next()?;
v.push(t.parse::<f64>().ok()?);
}
Some(v)
}
while let Some(tok) = tokens.next() {
match tok {
"S" | "M" => {
if let Some(v) = take(&mut tokens, 2) {
pb.move_to(v[0] as f32, v[1] as f32);
cur = (v[0], v[1]);
start = cur;
started = true;
}
}
"L" => {
if started && let Some(v) = take(&mut tokens, 2) {
pb.line_to(v[0] as f32, v[1] as f32);
cur = (v[0], v[1]);
}
}
"Q" => {
if started && let Some(v) = take(&mut tokens, 4) {
pb.quad_to(v[0] as f32, v[1] as f32, v[2] as f32, v[3] as f32);
cur = (v[2], v[3]);
}
}
"B" => {
if started && let Some(v) = take(&mut tokens, 6) {
pb.cubic_to(
v[0] as f32,
v[1] as f32,
v[2] as f32,
v[3] as f32,
v[4] as f32,
v[5] as f32,
);
cur = (v[4], v[5]);
}
}
"A" => {
if started && let Some(v) = take(&mut tokens, 7) {
let end = (v[5], v[6]);
append_arc(
&mut pb,
cur,
v[0],
v[1],
v[2],
v[3] != 0.0,
v[4] != 0.0,
end,
);
cur = end;
}
}
"C" => {
if started {
pb.close();
cur = start;
}
}
_ => {}
}
}
pb.finish()
}
#[allow(clippy::too_many_arguments)]
fn append_arc(
pb: &mut PathBuilder,
start: (f64, f64),
rx: f64,
ry: f64,
angle_deg: f64,
large_arc: bool,
sweep: bool,
end: (f64, f64),
) {
let (x1, y1) = start;
let (x2, y2) = end;
let mut rx = rx.abs();
let mut ry = ry.abs();
if rx == 0.0 || ry == 0.0 || (x1 == x2 && y1 == y2) {
pb.line_to(x2 as f32, y2 as f32);
return;
}
let phi = (angle_deg % 360.0).to_radians();
let (sin_p, cos_p) = phi.sin_cos();
let dx = (x1 - x2) / 2.0;
let dy = (y1 - y2) / 2.0;
let x1p = cos_p * dx + sin_p * dy;
let y1p = -sin_p * dx + cos_p * dy;
let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if lambda > 1.0 {
let s = lambda.sqrt();
rx *= s;
ry *= s;
}
let num = (rx * rx) * (ry * ry) - (rx * rx) * (y1p * y1p) - (ry * ry) * (x1p * x1p);
let den = (rx * rx) * (y1p * y1p) + (ry * ry) * (x1p * x1p);
let mut coef = if den > 0.0 {
(num / den).max(0.0).sqrt()
} else {
0.0
};
if large_arc == sweep {
coef = -coef;
}
let cxp = coef * (rx * y1p) / ry;
let cyp = -coef * (ry * x1p) / rx;
let cx = cos_p * cxp - sin_p * cyp + (x1 + x2) / 2.0;
let cy = sin_p * cxp + cos_p * cyp + (y1 + y2) / 2.0;
let ux = (x1p - cxp) / rx;
let uy = (y1p - cyp) / ry;
let vx = (-x1p - cxp) / rx;
let vy = (-y1p - cyp) / ry;
let angle = |ux: f64, uy: f64, vx: f64, vy: f64| -> f64 {
let dot = ux * vx + uy * vy;
let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
let mut a = (dot / len).clamp(-1.0, 1.0).acos();
if ux * vy - uy * vx < 0.0 {
a = -a;
}
a
};
let theta1 = angle(1.0, 0.0, ux, uy);
let mut dtheta = angle(ux, uy, vx, vy);
if !sweep && dtheta > 0.0 {
dtheta -= 2.0 * std::f64::consts::PI;
} else if sweep && dtheta < 0.0 {
dtheta += 2.0 * std::f64::consts::PI;
}
let segments = (dtheta.abs() / (std::f64::consts::PI / 2.0))
.ceil()
.max(1.0) as usize;
let delta = dtheta / segments as f64;
let t = (4.0 / 3.0) * (delta / 4.0).tan();
let mut th = theta1;
let point = |th: f64| -> (f64, f64) {
let (s, c) = th.sin_cos();
let ex = rx * c;
let ey = ry * s;
(cx + cos_p * ex - sin_p * ey, cy + sin_p * ex + cos_p * ey)
};
let deriv = |th: f64| -> (f64, f64) {
let (s, c) = th.sin_cos();
let ex = -rx * s;
let ey = ry * c;
(cos_p * ex - sin_p * ey, sin_p * ex + cos_p * ey)
};
for _ in 0..segments {
let th2 = th + delta;
let (px1, py1) = point(th);
let (px2, py2) = point(th2);
let (d1x, d1y) = deriv(th);
let (d2x, d2y) = deriv(th2);
let c1 = (px1 + t * d1x, py1 + t * d1y);
let c2 = (px2 - t * d2x, py2 - t * d2y);
pb.cubic_to(
c1.0 as f32,
c1.1 as f32,
c2.0 as f32,
c2.1 as f32,
px2 as f32,
py2 as f32,
);
th = th2;
}
}
struct PlacedGlyph {
gid: ttf_parser::GlyphId,
origin: (f64, f64),
}
fn render_text(
pixmap: &mut Pixmap,
res: &DocResources,
page_to_device: Mat,
obj: &TextObject,
inherited: Option<&CtDrawParam>,
) {
let Some(font) = res.fonts.get(&obj.font.value()) else {
return;
};
let Some(data) = &font.data else {
return;
};
let Ok(face) = ttf_parser::Face::parse(data, font.index) else {
return;
};
let units_per_em = face.units_per_em() as f64;
if units_per_em <= 0.0 {
return;
}
let dp = effective_draw_param(res, obj.draw_param, inherited);
let dp = dp.as_ref();
let fill = obj.fill.unwrap_or(true);
let stroke = obj.stroke.unwrap_or(false);
if !fill && !stroke {
return;
}
let h_scale = obj.h_scale.unwrap_or(1.0);
let scale_x = obj.size / units_per_em * h_scale;
let scale_y = obj.size / units_per_em;
let char_dir = obj.char_direction.unwrap_or(0) as f64;
let transform = object_to_device(
page_to_device,
&obj.boundary,
obj.ctm.as_ref().map(|a| a.as_slice()),
)
.to_skia();
let placed = place_glyphs(obj, |ch| face.glyph_index(ch));
let mut pb = PathBuilder::new();
for g in &placed {
let glyph_mat = glyph_to_object(g.origin.0, g.origin.1, scale_x, scale_y, char_dir);
let mut outliner = Outliner {
pb: &mut pb,
m: glyph_mat,
};
face.outline_glyph(g.gid, &mut outliner);
}
let Some(path) = pb.finish() else {
return;
};
if fill {
let color = obj
.fill_color
.as_ref()
.or_else(|| dp.and_then(|d| d.fill_color.as_ref()))
.map(|c| resolve_color(res, c, obj.alpha))
.unwrap_or([0, 0, 0, alpha_or_opaque(obj.alpha)]);
let mut paint = Paint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
pixmap.fill_path(&path, &paint, FillRule::Winding, transform, None);
}
if stroke {
let Some(color) = obj
.stroke_color
.as_ref()
.or_else(|| dp.and_then(|d| d.stroke_color.as_ref()))
.map(|c| resolve_color(res, c, obj.alpha))
.filter(|c| c[3] > 0)
else {
return;
};
let mut paint = Paint::default();
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
let width = dp.and_then(|d| d.line_width).unwrap_or(0.353);
let stroke_style = Stroke {
width: width.max(f64::MIN_POSITIVE) as f32,
..Default::default()
};
pixmap.stroke_path(&path, &paint, &stroke_style, transform, None);
}
}
fn text_code_points(obj: &TextObject) -> Vec<(f64, f64)> {
let mut points: Vec<(f64, f64)> = Vec::new();
let mut inherited_x = 0.0_f64;
let mut inherited_y = 0.0_f64;
for tc in &obj.text_codes {
let Some(text) = &tc.text else { continue };
let start_x = tc.x.unwrap_or(inherited_x);
let start_y = tc.y.unwrap_or(inherited_y);
inherited_x = start_x;
inherited_y = start_y;
let dx = tc.delta_x.as_deref().map(parse_deltas).unwrap_or_default();
let dy = tc.delta_y.as_deref().map(parse_deltas).unwrap_or_default();
let (mut cx, mut cy) = (start_x, start_y);
for (i, _) in text.chars().enumerate() {
if i > 0 {
cx += dx
.get(i - 1)
.copied()
.unwrap_or_else(|| dx.last().copied().unwrap_or(0.0));
cy += dy
.get(i - 1)
.copied()
.unwrap_or_else(|| dy.last().copied().unwrap_or(0.0));
}
points.push((cx, cy));
}
}
points
}
fn place_glyphs(
obj: &TextObject,
cmap: impl Fn(char) -> Option<ttf_parser::GlyphId>,
) -> Vec<PlacedGlyph> {
let points = text_code_points(obj);
let chars: Vec<char> = obj
.text_codes
.iter()
.filter_map(|tc| tc.text.as_deref())
.flat_map(|t| t.chars())
.collect();
let transforms: HashMap<usize, &CtCgTransform> = obj
.cg_transforms
.iter()
.filter(|t| t.code_position >= 0)
.map(|t| (t.code_position as usize, t))
.collect();
let mut out = Vec::with_capacity(chars.len());
let mut i = 0;
while i < chars.len() {
if let Some(t) = transforms.get(&i) {
let code_count = t.code_count.unwrap_or(1).max(1) as usize;
if let Some(glyphs) = &t.glyphs {
for (j, &g) in glyphs.as_slice().iter().enumerate() {
let pi = i + j.min(code_count.saturating_sub(1));
if let Some(&origin) = points.get(pi) {
out.push(PlacedGlyph {
gid: ttf_parser::GlyphId(g as u16),
origin,
});
}
}
}
i += code_count;
} else {
if let Some(gid) = cmap(chars[i])
&& let Some(&origin) = points.get(i)
{
out.push(PlacedGlyph { gid, origin });
}
i += 1;
}
}
out
}
fn glyph_to_object(cx: f64, cy: f64, scale_x: f64, scale_y: f64, char_dir_deg: f64) -> Mat {
Mat::translate(cx, cy)
.mul(Mat::rotate(char_dir_deg.to_radians()))
.mul(Mat::scale(scale_x, -scale_y))
}
struct Outliner<'a> {
pb: &'a mut PathBuilder,
m: Mat,
}
impl Outliner<'_> {
fn map(&self, x: f32, y: f32) -> (f32, f32) {
let (xf, yf) = (x as f64, y as f64);
(
(self.m.a * xf + self.m.c * yf + self.m.e) as f32,
(self.m.b * xf + self.m.d * yf + self.m.f) as f32,
)
}
}
impl ttf_parser::OutlineBuilder for Outliner<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let (px, py) = self.map(x, y);
self.pb.move_to(px, py);
}
fn line_to(&mut self, x: f32, y: f32) {
let (px, py) = self.map(x, y);
self.pb.line_to(px, py);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let (cx1, cy1) = self.map(x1, y1);
let (px, py) = self.map(x, y);
self.pb.quad_to(cx1, cy1, px, py);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let (cx1, cy1) = self.map(x1, y1);
let (cx2, cy2) = self.map(x2, y2);
let (px, py) = self.map(x, y);
self.pb.cubic_to(cx1, cy1, cx2, cy2, px, py);
}
fn close(&mut self) {
self.pb.close();
}
}
fn render_image(pixmap: &mut Pixmap, res: &DocResources, page_to_device: Mat, obj: &ImageObject) {
let Some(bytes) = res.media.get(&obj.resource_id.value()) else {
return;
};
let Ok(decoded) = image::load_from_memory(bytes) else {
return;
};
let rgba = decoded.to_rgba8();
let (iw, ih) = (rgba.width(), rgba.height());
if iw == 0 || ih == 0 {
return;
}
let Some(src) = rgba_to_pixmap(&rgba) else {
return;
};
let unit_obj = obj
.ctm
.as_ref()
.and_then(|a| Mat::from_array(a.as_slice()))
.unwrap_or_else(|| Mat::scale(obj.boundary.width, obj.boundary.height));
let unit_to_device = page_to_device
.mul(Mat::translate(obj.boundary.x, obj.boundary.y))
.mul(unit_obj);
let pixel_to_device = unit_to_device.mul(Mat::scale(1.0 / iw as f64, 1.0 / ih as f64));
let opacity = obj.alpha.map(|a| a as f32 / 255.0).unwrap_or(1.0);
let paint = PixmapPaint {
opacity,
..Default::default()
};
pixmap.draw_pixmap(0, 0, src.as_ref(), &paint, pixel_to_device.to_skia(), None);
}
fn rgba_to_pixmap(img: &RgbaImage) -> Option<Pixmap> {
let mut pm = Pixmap::new(img.width(), img.height())?;
let dst = pm.data_mut();
for (i, px) in img.pixels().enumerate() {
let [r, g, b, a] = px.0;
let af = a as u16;
dst[i * 4] = (r as u16 * af / 255) as u8;
dst[i * 4 + 1] = (g as u16 * af / 255) as u8;
dst[i * 4 + 2] = (b as u16 * af / 255) as u8;
dst[i * 4 + 3] = a;
}
Some(pm)
}
fn pixmap_to_image(pixmap: &Pixmap) -> RgbaImage {
let (w, h) = (pixmap.width(), pixmap.height());
let mut out = RgbaImage::new(w, h);
for (i, px) in pixmap.pixels().iter().enumerate() {
let c = px.demultiply();
let x = (i as u32) % w;
let y = (i as u32) / w;
out.put_pixel(x, y, image::Rgba([c.red(), c.green(), c.blue(), c.alpha()]));
}
out
}
fn encode_image(img: RgbaImage, format: ImageFormat) -> Result<Vec<u8>> {
let mut buf = Cursor::new(Vec::new());
match format {
ImageFormat::Jpeg => {
DynamicImage::ImageRgba8(img)
.to_rgb8()
.write_to(&mut buf, format)?;
}
_ => {
img.write_to(&mut buf, format)?;
}
}
Ok(buf.into_inner())
}
fn format_from_path(path: &Path) -> Option<ImageFormat> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
image_format_from_ext(&ext)
}
pub fn image_format_from_ext(ext: &str) -> Option<ImageFormat> {
match ext.to_ascii_lowercase().as_str() {
"png" => Some(ImageFormat::Png),
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
"bmp" => Some(ImageFormat::Bmp),
"tif" | "tiff" => Some(ImageFormat::Tiff),
"gif" => Some(ImageFormat::Gif),
"webp" => Some(ImageFormat::WebP),
_ => None,
}
}
fn alpha_or_opaque(alpha: Option<u8>) -> u8 {
alpha.unwrap_or(255)
}
fn resolve_color(res: &DocResources, color: &CtColor, obj_alpha: Option<u8>) -> Rgba {
let values: Vec<f64> = color
.value
.as_ref()
.map(|v| v.as_slice().to_vec())
.unwrap_or_default();
let cs_id = color.color_space.map(|c| c.value()).or(res.default_cs);
let cs = cs_id.and_then(|id| res.color_spaces.get(&id));
let bits = cs.and_then(|c| c.bits_per_component).unwrap_or(8);
let max = ((1u64 << bits.min(16)) - 1) as f64;
let cs_type = cs.map(|c| c.cs_type.as_str()).unwrap_or("RGB");
let norm = |i: usize| -> f64 { values.get(i).copied().unwrap_or(0.0) / max };
let (r, g, b) = if values.is_empty() {
(0.0, 0.0, 0.0)
} else {
match cs_type {
"Gray" => {
let v = norm(0);
(v, v, v)
}
"CMYK" => {
let (c, m, y, k) = (norm(0), norm(1), norm(2), norm(3));
(
(1.0 - c) * (1.0 - k),
(1.0 - m) * (1.0 - k),
(1.0 - y) * (1.0 - k),
)
}
_ => (norm(0), norm(1), norm(2)),
}
};
let color_alpha = color.alpha.unwrap_or(255) as f64;
let obj_a = obj_alpha.unwrap_or(255) as f64;
let a = (color_alpha * obj_a / 255.0).round() as u8;
[
(r.clamp(0.0, 1.0) * 255.0).round() as u8,
(g.clamp(0.0, 1.0) * 255.0).round() as u8,
(b.clamp(0.0, 1.0) * 255.0).round() as u8,
a,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::graphics::CtColor;
use crate::types::StId;
fn dp(id: u64, relative: Option<u64>) -> CtDrawParam {
CtDrawParam {
id: StId(id),
relative: relative.map(StId),
..Default::default()
}
}
fn color(component: f64) -> CtColor {
CtColor {
value: Some(StArray(vec![component])),
..Default::default()
}
}
fn resources_with(params: Vec<CtDrawParam>) -> DocResources {
let mut res = DocResources::default();
for p in params {
res.draw_params.insert(p.id.value(), p);
}
res
}
fn resolve_for(res: &DocResources, id: u64) -> CtDrawParam {
resolve_draw_param(res, Some(StRefId(id))).expect("draw param exists")
}
#[test]
fn draw_param_inherits_missing_attrs_via_relative() {
let mut parent = dp(1, None);
parent.fill_color = Some(color(0.5));
parent.line_width = Some(2.0);
let mut child = dp(2, Some(1));
child.stroke_color = Some(color(0.9));
let res = resources_with(vec![parent, child]);
let flat = resolve_for(&res, 2);
assert!(flat.fill_color.is_some());
assert_eq!(flat.line_width, Some(2.0));
assert!(flat.stroke_color.is_some());
}
#[test]
fn draw_param_inherits_across_multiple_levels() {
let mut grandparent = dp(1, None);
grandparent.fill_color = Some(color(0.5));
let parent = dp(2, Some(1));
let parent_id = parent.id.value();
assert_eq!(parent_id, 2);
let child = dp(3, Some(2));
let res = resources_with(vec![grandparent, parent, child]);
let flat = resolve_for(&res, 3);
assert!(flat.fill_color.is_some());
}
#[test]
fn draw_param_relative_cycle_terminates() {
let res = resources_with(vec![dp(1, Some(2)), dp(2, Some(1))]);
let flat = resolve_for(&res, 2);
assert_eq!(flat.id.value(), 2);
}
#[test]
fn effective_draw_param_falls_back_to_layer() {
let mut layer = dp(4, None);
layer.fill_color = Some(color(0.6));
let res = resources_with(vec![layer.clone()]);
let eff = effective_draw_param(&res, None, Some(&layer)).expect("inherited param");
assert!(eff.fill_color.is_some());
}
#[test]
fn effective_draw_param_object_overrides_layer() {
let mut layer = dp(4, None);
layer.fill_color = Some(color(0.6));
let mut own = dp(2, None);
own.fill_color = Some(color(0.1));
own.line_width = None; let mut layer_with_lw = layer.clone();
layer_with_lw.line_width = Some(3.0);
let res = resources_with(vec![own.clone(), layer_with_lw.clone()]);
let eff =
effective_draw_param(&res, Some(StRefId(2)), Some(&layer_with_lw)).expect("param");
assert_eq!(
eff.fill_color
.as_ref()
.and_then(|c| c.value.as_ref())
.map(|v| v.as_slice()[0]),
Some(0.1)
);
assert_eq!(eff.line_width, Some(3.0));
}
#[test]
fn arc_bulges_like_a_semicircle() {
let cw = build_path("S 0 0 A 5 5 0 1 1 10 0").expect("path");
let b = cw.bounds();
assert!(
b.top() < -4.5,
"clockwise semicircle should bulge up (-y), got {}",
b.top()
);
assert!(b.bottom() < 0.5, "stays on one side of the chord");
assert!(
(b.left()).abs() < 0.5 && (b.right() - 10.0).abs() < 0.5,
"span ≈ diameter"
);
assert!((b.height() - 5.0).abs() < 0.5, "bulge ≈ radius");
let ccw = build_path("S 0 0 A 5 5 0 1 0 10 0").expect("path");
let b2 = ccw.bounds();
assert!(
b2.bottom() > 4.5,
"counter-clockwise should bulge down (+y), got {}",
b2.bottom()
);
}
#[test]
fn arc_with_zero_radius_degenerates_to_line() {
let path = build_path("S 0 0 A 0 0 0 0 0 10 0").expect("path");
let b = path.bounds();
assert!(b.height() < 0.001, "degenerate arc must be flat");
}
fn glyph_pt(cx: f64, cy: f64, scale: f64, char_dir: f64, fx: f64, fy: f64) -> (f64, f64) {
let m = glyph_to_object(cx, cy, scale, scale, char_dir);
(m.a * fx + m.c * fy + m.e, m.b * fx + m.d * fy + m.f)
}
#[test]
fn char_direction_rotates_glyph_clockwise() {
let approx = |a: f64, b: f64| (a - b).abs() < 1e-9;
let (x, y) = glyph_pt(0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
assert!(approx(x, 0.0) && approx(y, -1.0), "0° up→up: {x},{y}");
let (x, y) = glyph_pt(0.0, 0.0, 1.0, 90.0, 0.0, 1.0);
assert!(approx(x, 1.0) && approx(y, 0.0), "90° up→right: {x},{y}");
let (x, y) = glyph_pt(0.0, 0.0, 1.0, 180.0, 0.0, 1.0);
assert!(approx(x, 0.0) && approx(y, 1.0), "180° up→down: {x},{y}");
let (x, y) = glyph_pt(0.0, 0.0, 1.0, 270.0, 0.0, 1.0);
assert!(approx(x, -1.0) && approx(y, 0.0), "270° up→left: {x},{y}");
}
#[test]
fn glyph_origin_translates_to_pen_position() {
let (x, y) = glyph_pt(12.0, 34.0, 2.0, 90.0, 0.0, 0.0);
assert!((x - 12.0).abs() < 1e-9 && (y - 34.0).abs() < 1e-9);
}
use crate::model::graphics::{CtCgTransform, TextCode, TextObject};
use crate::types::StArray;
fn text_code(x: Option<f64>, y: Option<f64>, dx: &str, text: &str) -> TextCode {
TextCode {
x,
y,
delta_x: (!dx.is_empty()).then(|| dx.to_string()),
delta_y: None,
text: Some(text.to_string()),
}
}
#[test]
fn text_points_advance_by_delta_x() {
let obj = TextObject {
text_codes: vec![text_code(Some(0.0), Some(25.0), "10 10", "ABC")],
..Default::default()
};
let pts = text_code_points(&obj);
assert_eq!(pts, vec![(0.0, 25.0), (10.0, 25.0), (20.0, 25.0)]);
}
#[test]
fn text_points_inherit_xy_from_previous_text_code() {
let obj = TextObject {
text_codes: vec![
text_code(Some(5.0), Some(7.0), "", "A"),
text_code(None, None, "", "B"),
],
..Default::default()
};
let pts = text_code_points(&obj);
assert_eq!(pts, vec![(5.0, 7.0), (5.0, 7.0)]);
}
#[test]
fn place_glyphs_uses_cmap_without_transform() {
let obj = TextObject {
text_codes: vec![text_code(Some(0.0), Some(0.0), "10", "AB")],
..Default::default()
};
let placed = place_glyphs(&obj, |ch| Some(ttf_parser::GlyphId(ch as u16)));
assert_eq!(placed.len(), 2);
assert_eq!(placed[0].gid.0, b'A' as u16);
assert_eq!(placed[0].origin, (0.0, 0.0));
assert_eq!(placed[1].gid.0, b'B' as u16);
assert_eq!(placed[1].origin, (10.0, 0.0));
}
#[test]
fn place_glyphs_applies_ligature_transform() {
let obj = TextObject {
text_codes: vec![text_code(Some(0.0), Some(0.0), "10", "fi")],
cg_transforms: vec![CtCgTransform {
code_position: 0,
code_count: Some(2),
glyph_count: Some(1),
glyphs: Some(StArray(vec![192])),
}],
..Default::default()
};
let placed = place_glyphs(&obj, |_| {
panic!("CMAP must not be used inside transform range")
});
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].gid.0, 192);
assert_eq!(placed[0].origin, (0.0, 0.0));
}
#[test]
fn place_glyphs_mixes_transform_and_cmap() {
let obj = TextObject {
text_codes: vec![text_code(Some(0.0), Some(0.0), "10 10", "fix")],
cg_transforms: vec![CtCgTransform {
code_position: 0,
code_count: Some(2),
glyph_count: Some(1),
glyphs: Some(StArray(vec![192])),
}],
..Default::default()
};
let placed = place_glyphs(&obj, |ch| Some(ttf_parser::GlyphId(ch as u16)));
assert_eq!(placed.len(), 2);
assert_eq!(placed[0].gid.0, 192);
assert_eq!(placed[0].origin, (0.0, 0.0));
assert_eq!(placed[1].gid.0, b'x' as u16);
assert_eq!(placed[1].origin, (20.0, 0.0));
}
#[test]
fn build_path_handles_all_commands() {
let p = build_path("M 0,0 L 10 0 Q 10 5 5 10 B 4 4 2 2 0 10 A 3 3 0 0 1 0 0 C");
assert!(p.is_some());
}
#[test]
fn build_path_edge_cases() {
assert!(build_path("L 1 1").is_none());
assert!(build_path("M 0 0 L 1").is_none());
assert!(build_path("M 0 0 Z 9 L 5 5").is_some());
assert!(build_path("").is_none());
}
fn cs(id: u64, ty: &str, bits: Option<u32>) -> CtColorSpace {
CtColorSpace {
id: StId(id),
cs_type: ty.to_string(),
bits_per_component: bits,
profile: None,
}
}
fn res_with_cs(spaces: Vec<CtColorSpace>, default_cs: Option<u64>) -> DocResources {
let mut res = DocResources {
default_cs,
..Default::default()
};
for c in spaces {
res.color_spaces.insert(c.id.value(), c);
}
res
}
fn colored(values: Vec<f64>, cs_id: Option<u64>, alpha: Option<u8>) -> CtColor {
CtColor {
value: Some(StArray(values)),
color_space: cs_id.map(StRefId),
alpha,
..Default::default()
}
}
#[test]
fn resolve_color_rgb_default_when_no_space() {
let res = DocResources::default();
let c = resolve_color(&res, &colored(vec![255.0, 0.0, 0.0], None, None), None);
assert_eq!(c, [255, 0, 0, 255]);
}
#[test]
fn resolve_color_gray_and_bits() {
let res = res_with_cs(vec![cs(1, "Gray", Some(4))], None);
let c = resolve_color(&res, &colored(vec![15.0], Some(1), None), None);
assert_eq!(c, [255, 255, 255, 255]);
}
#[test]
fn resolve_color_cmyk() {
let res = res_with_cs(vec![cs(2, "CMYK", None)], None);
let c = resolve_color(
&res,
&colored(vec![0.0, 0.0, 0.0, 255.0], Some(2), None),
None,
);
assert_eq!(&c[..3], &[0, 0, 0]);
}
#[test]
fn resolve_color_uses_default_cs_and_alpha_multiplies() {
let res = res_with_cs(vec![cs(7, "Gray", None)], Some(7));
let c = resolve_color(&res, &colored(vec![255.0], None, Some(255)), Some(128));
assert_eq!(c, [255, 255, 255, 128]);
}
#[test]
fn resolve_color_empty_values_is_black() {
let res = DocResources::default();
let c = resolve_color(
&res,
&CtColor {
value: None,
..Default::default()
},
None,
);
assert_eq!(c, [0, 0, 0, 255]);
}
#[test]
fn mat_from_array_and_ops() {
assert!(Mat::from_array(&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).is_some());
assert!(Mat::from_array(&[1.0, 2.0]).is_none());
let m = Mat::translate(10.0, 20.0).mul(Mat::scale(2.0, 2.0));
assert!((m.a - 2.0).abs() < 1e-9 && (m.e - 10.0).abs() < 1e-9);
let sk = Mat::identity().to_skia();
assert_eq!(sk.sx, 1.0);
let r = Mat::rotate(std::f64::consts::FRAC_PI_2);
assert!(r.a.abs() < 1e-9);
}
#[test]
fn is_cjk_and_alpha_helpers() {
assert!(is_cjk('字'));
assert!(is_cjk(',')); assert!(!is_cjk('A'));
assert_eq!(alpha_or_opaque(None), 255);
assert_eq!(alpha_or_opaque(Some(10)), 10);
}
#[test]
fn image_format_mapping() {
for (ext, _) in [
("png", ()),
("JPG", ()),
("jpeg", ()),
("bmp", ()),
("tiff", ()),
("tif", ()),
("gif", ()),
("webp", ()),
] {
assert!(image_format_from_ext(ext).is_some(), "{ext}");
}
assert!(image_format_from_ext("xyz").is_none());
assert!(format_from_path(Path::new("a/b.PNG")).is_some());
assert!(format_from_path(Path::new("noext")).is_none());
}
#[test]
fn lookup_system_font_finds_something() {
let db = system_fonts();
let fam = db
.faces()
.next()
.and_then(|f| f.families.first().map(|(n, _)| n.clone()));
if let Some(fam) = fam {
assert!(lookup_system_font(&fam, "", false, false).is_some());
}
let _ = lookup_system_font("宋体", "宋体", true, false);
let _ = lookup_system_font("ZZ-No-Such-Font", "", false, true);
let _ = lookup_system_font("", "", false, false);
}
#[test]
fn pixmap_image_roundtrip_and_encode() {
let mut img = RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
img.put_pixel(1, 1, image::Rgba([0, 255, 0, 128]));
let pm = rgba_to_pixmap(&img).unwrap();
let back = pixmap_to_image(&pm);
assert_eq!(back.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert!(
!encode_image(img.clone(), ImageFormat::Png)
.unwrap()
.is_empty()
);
assert!(!encode_image(img, ImageFormat::Jpeg).unwrap().is_empty());
}
#[test]
fn render_options_builders() {
let o = RenderOptions::with_dpi(300.0).background(Some([1, 2, 3, 4]));
assert_eq!(o.dpi, 300.0);
assert_eq!(o.background, Some([1, 2, 3, 4]));
}
}