use std::collections::HashMap;
use std::f64::consts::PI;
use image::codecs::webp::WebPEncoder;
use image::{ExtendedColorType, ImageEncoder};
use tiny_skia::{self, FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
use ttf_parser::OutlineBuilder;
use crate::font::DEFAULT_FONT;
use crate::ir::Color;
use crate::scene::{Anchor, Prim, Scene};
const MAX_PNG_AREA_PIXELS: u64 = 64_000_000;
pub const MAX_WEBP_AREA_PIXELS: u64 = MAX_PNG_AREA_PIXELS / 3;
const MAX_WEBP_AXIS: u32 = 16_384;
struct RasterLimits {
max_area: u64,
max_axis: Option<u32>,
output: &'static str,
}
const PNG_LIMITS: RasterLimits = RasterLimits {
max_area: MAX_PNG_AREA_PIXELS,
max_axis: None,
output: "raster",
};
const WEBP_LIMITS: RasterLimits = RasterLimits {
max_area: MAX_WEBP_AREA_PIXELS,
max_axis: Some(MAX_WEBP_AXIS),
output: "WebP",
};
impl RasterLimits {
fn check(&self, w: u32, h: u32, area: u64) -> Result<(), String> {
if let Some(max_axis) = self.max_axis
&& (w > max_axis || h > max_axis)
{
return Err(format!(
"{} output {w}×{h} px exceeds the per-axis limit of {max_axis} px",
self.output
));
}
if area > self.max_area {
return Err(format!(
"{} output {w}×{h} px ({area} pixels) exceeds the area limit of {} px",
self.output, self.max_area
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PngCompression {
Fast,
#[default]
Balanced,
High,
}
impl PngCompression {
fn params(self) -> (png::Compression, png::FilterType, bool) {
match self {
Self::Fast => (png::Compression::Fast, png::FilterType::Sub, false),
Self::Balanced => (png::Compression::Fast, png::FilterType::Paeth, true),
Self::High => (png::Compression::Default, png::FilterType::Paeth, true),
}
}
}
pub fn render_chart_to_png(
spec: &crate::ir::ChartSpec,
scale: f32,
font_bytes: &[u8],
) -> Result<Vec<u8>, String> {
render_chart_to_png_with(spec, scale, font_bytes, PngCompression::default())
}
pub fn render_chart_to_png_with(
spec: &crate::ir::ChartSpec,
scale: f32,
font_bytes: &[u8],
compression: PngCompression,
) -> Result<Vec<u8>, String> {
let face =
ttf_parser::Face::parse(font_bytes, 0).map_err(|e| format!("font parse failed: {e}"))?;
let measurer = crate::text::TextMeasurer::new(font_bytes)
.map_err(|e| format!("text measurer init failed: {e}"))?;
let scene = crate::layout::build_scene(spec, &measurer);
scene_to_png_with_face(&scene, scale, &face, compression)
}
pub fn render_chart_to_png_default(
spec: &crate::ir::ChartSpec,
scale: f32,
) -> Result<Vec<u8>, String> {
render_chart_to_png(spec, scale, DEFAULT_FONT)
}
pub fn render_chart_to_webp(
spec: &crate::ir::ChartSpec,
scale: f32,
font_bytes: &[u8],
) -> Result<Vec<u8>, String> {
let face =
ttf_parser::Face::parse(font_bytes, 0).map_err(|e| format!("font parse failed: {e}"))?;
let measurer = crate::text::TextMeasurer::new(font_bytes)
.map_err(|e| format!("text measurer init failed: {e}"))?;
let scene = crate::layout::build_scene(spec, &measurer);
let mut pixmap = scene_to_pixmap(&scene, scale, &face, &WEBP_LIMITS)?;
if !all_pixels_opaque(&scene, &pixmap, scale) {
demultiply_in_place(&mut pixmap);
}
encode_pixmap_webp(&pixmap)
}
fn encode_pixmap_webp(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
WebPEncoder::new_lossless(&mut buf)
.write_image(
pixmap.data(),
pixmap.width(),
pixmap.height(),
ExtendedColorType::Rgba8,
)
.map_err(|e| format!("WebP encode failed: {e}"))?;
Ok(buf)
}
pub fn scene_to_png(scene: &Scene, scale: f32, font_bytes: &[u8]) -> Result<Vec<u8>, String> {
let face =
ttf_parser::Face::parse(font_bytes, 0).map_err(|e| format!("font parse failed: {e}"))?;
scene_to_png_with_face(scene, scale, &face, PngCompression::default())
}
const STAMP_MIN_RUN: usize = 128;
const STAMP_MAX_DEVICE_R: f64 = 64.0;
fn scene_to_pixmap(
scene: &Scene,
scale: f32,
face: &ttf_parser::Face<'_>,
limits: &RasterLimits,
) -> Result<Pixmap, String> {
scene_to_pixmap_with(scene, scale, face, limits, STAMP_MIN_RUN)
}
fn scene_to_pixmap_with(
scene: &Scene,
scale: f32,
face: &ttf_parser::Face<'_>,
limits: &RasterLimits,
min_run: usize,
) -> Result<Pixmap, String> {
let scale = if scale > 0.0 { scale } else { 1.0 };
let w = (scene.width as f32 * scale).round().max(1.0) as u32;
let h = (scene.height as f32 * scale).round().max(1.0) as u32;
let area = w as u64 * h as u64;
limits.check(w, h, area)?;
let mut pixmap = Pixmap::new(w, h)
.ok_or_else(|| format!("Pixmap allocation failed: invalid dimensions {w}x{h}"))?;
let transform = Transform::from_scale(scale, scale);
let mut glyph_cache: HashMap<ttf_parser::GlyphId, Option<tiny_skia::Path>> = HashMap::new();
let mut i = 0;
while i < scene.items.len() {
let run = uniform_circle_run_len(&scene.items, i);
let stampable = run >= min_run
&& matches!(
&scene.items[i],
Prim::Circle { r, stroke_width, .. }
if *r > 0.0
&& (*r + stroke_width.max(0.0) / 2.0) * scale as f64 <= STAMP_MAX_DEVICE_R
);
if stampable {
if let Prim::Circle {
r,
fill,
stroke,
stroke_width,
..
} = &scene.items[i]
{
let key = MarkerKey {
r: *r,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
};
let set = build_stamp_set(&key, scale);
for it in &scene.items[i..i + run] {
if let Prim::Circle { cx, cy, .. } = it {
blit_stamp(&mut pixmap, &set, *cx as f32 * scale, *cy as f32 * scale);
}
}
}
i += run;
} else if run > 0 {
for prim in &scene.items[i..i + run] {
render_prim(&mut pixmap, prim, transform, face, &mut glyph_cache);
}
i += run;
} else {
render_prim(
&mut pixmap,
&scene.items[i],
transform,
face,
&mut glyph_cache,
);
i += 1;
}
}
Ok(pixmap)
}
fn scene_to_png_with_face(
scene: &Scene,
scale: f32,
face: &ttf_parser::Face<'_>,
compression: PngCompression,
) -> Result<Vec<u8>, String> {
let mut pixmap = scene_to_pixmap(scene, scale, face, &PNG_LIMITS)?;
let skip = all_pixels_opaque(scene, &pixmap, scale);
encode_png_fast(&mut pixmap, compression, skip)
}
fn all_pixels_opaque(scene: &Scene, pixmap: &Pixmap, scale: f32) -> bool {
if !scene.has_opaque_background() {
return false;
}
let scale = if scale > 0.0 { scale } else { 1.0 };
(pixmap.width() as f32) <= scene.width as f32 * scale
&& (pixmap.height() as f32) <= scene.height as f32 * scale
}
fn demultiply_in_place(pixmap: &mut Pixmap) {
for chunk in pixmap.data_mut().chunks_exact_mut(4) {
let a = chunk[3];
if a != 0 && a != 255 {
if let Some(px) =
tiny_skia::PremultipliedColorU8::from_rgba(chunk[0], chunk[1], chunk[2], a)
{
let c = px.demultiply();
chunk[0] = c.red();
chunk[1] = c.green();
chunk[2] = c.blue();
chunk[3] = c.alpha();
}
}
}
}
fn encode_png_fast(
pixmap: &mut Pixmap,
compression: PngCompression,
skip_demultiply: bool,
) -> Result<Vec<u8>, String> {
if !skip_demultiply {
demultiply_in_place(pixmap);
}
encode_rgba_png(pixmap.data(), pixmap.width(), pixmap.height(), compression)
.map_err(|e| format!("PNG encode failed: {e}"))
}
fn encode_rgba_png(
rgba: &[u8],
width: u32,
height: u32,
compression: PngCompression,
) -> Result<Vec<u8>, png::EncodingError> {
let (comp, filter, adaptive) = compression.params();
let mut data = Vec::new();
{
let mut encoder = png::Encoder::new(&mut data, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_compression(comp);
encoder.set_filter(filter);
if adaptive {
encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive);
}
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba)?;
}
Ok(data)
}
fn uniform_circle_run_len(items: &[Prim], start: usize) -> usize {
let Prim::Circle {
r,
fill,
stroke,
stroke_width,
..
} = &items[start]
else {
return 0;
};
let mut n = 0;
for it in &items[start..] {
match it {
Prim::Circle {
r: r2,
fill: f2,
stroke: s2,
stroke_width: w2,
..
}
if r2 == r && f2 == fill && s2 == stroke && w2 == stroke_width =>
{
n += 1
}
_ => break,
}
}
n
}
const STAMP_B: u32 = 8;
#[derive(Clone, Copy, PartialEq)]
struct MarkerKey {
r: f64,
fill: Color,
stroke: Color,
stroke_width: f64,
}
struct StampSet {
stamps: Vec<Pixmap>,
pad: i32,
b: u32,
}
fn build_stamp_set(key: &MarkerKey, scale: f32) -> StampSet {
let r_dev = key.r as f32 * scale;
let stroke_dev = key.stroke_width as f32 * scale;
let pad = ((r_dev.max(0.0) + stroke_dev.max(0.0) / 2.0).ceil() as i32 + 2).max(2);
let size = (2 * pad + 2) as u32;
let anchor = pad as f32;
let mut stamps = Vec::with_capacity((STAMP_B * STAMP_B) as usize);
for sy in 0..STAMP_B {
for sx in 0..STAMP_B {
let mut pm = Pixmap::new(size, size).expect("stamp pixmap サイズは正");
let cx = anchor + sx as f32 / STAMP_B as f32;
let cy = anchor + sy as f32 / STAMP_B as f32;
if let Some(path) = PathBuilder::from_circle(cx, cy, r_dev) {
pm.fill_path(
&path,
&solid_paint(key.fill),
FillRule::Winding,
Transform::identity(),
None,
);
if key.stroke_width > 0.0 {
pm.stroke_path(
&path,
&solid_paint(key.stroke),
&make_stroke(key.stroke_width * scale as f64),
Transform::identity(),
None,
);
}
}
stamps.push(pm);
}
}
StampSet {
stamps,
pad,
b: STAMP_B,
}
}
fn pick_stamp(set: &StampSet, cx_dev: f32, cy_dev: f32) -> (i32, i32, usize) {
let mut ix = cx_dev.floor() as i32;
let mut kx = ((cx_dev - ix as f32) * set.b as f32).round() as i32;
if kx as u32 == set.b {
kx = 0;
ix += 1;
}
let mut iy = cy_dev.floor() as i32;
let mut ky = ((cy_dev - iy as f32) * set.b as f32).round() as i32;
if ky as u32 == set.b {
ky = 0;
iy += 1;
}
let kx = kx.clamp(0, set.b as i32 - 1);
let ky = ky.clamp(0, set.b as i32 - 1);
let idx = (ky as u32 * set.b + kx as u32) as usize;
(ix, iy, idx)
}
fn blit_stamp(pm: &mut Pixmap, set: &StampSet, cx_dev: f32, cy_dev: f32) {
if !cx_dev.is_finite() || !cy_dev.is_finite() {
return;
}
let (ix, iy, idx) = pick_stamp(set, cx_dev, cy_dev);
let stamp = &set.stamps[idx];
let ss = stamp.width() as i32;
let src = stamp.data();
let ox = ix as i64 - set.pad as i64;
let oy = iy as i64 - set.pad as i64;
let w = pm.width() as i64;
let h = pm.height() as i64;
let dst = pm.data_mut();
for sy in 0..ss {
let dy = oy + sy as i64;
if dy < 0 || dy >= h {
continue;
}
for sx in 0..ss {
let dx = ox + sx as i64;
if dx < 0 || dx >= w {
continue;
}
let si = ((sy * ss + sx) * 4) as usize;
let s_a = src[si + 3];
if s_a == 0 {
continue;
}
let di = ((dy * w + dx) * 4) as usize;
let inv = 255 - s_a as u32;
for c in 0..4 {
let s_c = src[si + c] as u32;
let d_c = dst[di + c] as u32;
dst[di + c] = (s_c + (d_c * inv + 127) / 255) as u8;
}
}
}
}
fn render_prim(
pixmap: &mut Pixmap,
prim: &Prim,
transform: Transform,
face: &ttf_parser::Face<'_>,
cache: &mut HashMap<ttf_parser::GlyphId, Option<tiny_skia::Path>>,
) {
match prim {
Prim::Rect { x, y, w, h, fill } => {
let Some(rect) = Rect::from_xywh(*x as f32, *y as f32, *w as f32, *h as f32) else {
return;
};
let path = PathBuilder::from_rect(rect);
let mut paint = solid_paint(*fill);
paint.anti_alias = false;
pixmap.fill_path(&path, &paint, FillRule::Winding, transform, None);
}
Prim::Line {
x1,
y1,
x2,
y2,
stroke,
stroke_width,
} => {
let mut b = PathBuilder::new();
b.move_to(*x1 as f32, *y1 as f32);
b.line_to(*x2 as f32, *y2 as f32);
let Some(path) = b.finish() else { return };
pixmap.stroke_path(
&path,
&solid_paint(*stroke),
&make_stroke(*stroke_width),
transform,
None,
);
}
Prim::Polyline {
points,
stroke,
stroke_width,
} => {
if points.len() < 2 {
return;
}
let mut b = PathBuilder::new();
for (i, &(px, py)) in points.iter().enumerate() {
if i == 0 {
b.move_to(px as f32, py as f32);
} else {
b.line_to(px as f32, py as f32);
}
}
let Some(path) = b.finish() else { return };
pixmap.stroke_path(
&path,
&solid_paint(*stroke),
&make_stroke(*stroke_width),
transform,
None,
);
}
Prim::Path {
d,
fill,
stroke,
stroke_width,
} => {
let Some(path) = parse_path_data(d) else {
return;
};
if let Some(fill_color) = fill {
pixmap.fill_path(
&path,
&solid_paint(*fill_color),
FillRule::Winding,
transform,
None,
);
}
if let Some(stroke_color) = stroke {
pixmap.stroke_path(
&path,
&solid_paint(*stroke_color),
&make_stroke(*stroke_width),
transform,
None,
);
}
}
Prim::GradientPath {
d,
x0,
x1,
stop0,
stop1,
} => {
let Some(path) = parse_path_data(d) else {
return;
};
use tiny_skia::{GradientStop, LinearGradient, Point, Shader, SpreadMode};
let shader = LinearGradient::new(
Point::from_xy(*x0 as f32, 0.0),
Point::from_xy(*x1 as f32, 0.0),
vec![
GradientStop::new(0.0, to_skia_color(stop0)),
GradientStop::new(1.0, to_skia_color(stop1)),
],
SpreadMode::Pad,
Transform::identity(),
);
let paint = Paint {
shader: shader.unwrap_or_else(|| Shader::SolidColor(to_skia_color(stop1))),
..Default::default()
};
pixmap.fill_path(&path, &paint, FillRule::Winding, transform, None);
}
Prim::Circle {
cx,
cy,
r,
fill,
stroke,
stroke_width,
} => {
let Some(path) = PathBuilder::from_circle(*cx as f32, *cy as f32, *r as f32) else {
return;
};
pixmap.fill_path(
&path,
&solid_paint(*fill),
FillRule::Winding,
transform,
None,
);
if *stroke_width > 0.0 {
pixmap.stroke_path(
&path,
&solid_paint(*stroke),
&make_stroke(*stroke_width),
transform,
None,
);
}
}
Prim::Text {
x,
y,
size,
anchor,
fill,
content,
rotate_deg: _, } => {
render_text(
pixmap, *x, *y, *size, *anchor, *fill, content, face, transform, cache,
);
}
}
}
struct GlyphSinkNorm<'a> {
builder: &'a mut PathBuilder,
}
impl OutlineBuilder for GlyphSinkNorm<'_> {
fn move_to(&mut self, x: f32, y: f32) {
self.builder.move_to(x, -y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.builder.line_to(x, -y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.builder.quad_to(x1, -y1, x, -y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.builder.cubic_to(x1, -y1, x2, -y2, x, -y);
}
fn close(&mut self) {
self.builder.close();
}
}
fn build_canonical_glyph_path(
face: &ttf_parser::Face<'_>,
gid: ttf_parser::GlyphId,
) -> Option<tiny_skia::Path> {
let mut builder = PathBuilder::new();
{
let mut sink = GlyphSinkNorm {
builder: &mut builder,
};
face.outline_glyph(gid, &mut sink)?;
}
builder.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_text(
pixmap: &mut Pixmap,
x: f64,
y: f64,
size: f64,
anchor: Anchor,
fill: Color,
content: &str,
face: &ttf_parser::Face<'_>,
transform: Transform,
cache: &mut HashMap<ttf_parser::GlyphId, Option<tiny_skia::Path>>,
) {
if content.is_empty() {
return;
}
let upem = face.units_per_em() as f32;
let glyph_scale = size as f32 / upem;
let total_width: f32 = content
.chars()
.filter_map(|ch| face.glyph_index(ch))
.filter_map(|gid| face.glyph_hor_advance(gid))
.map(|adv| adv as f32 * glyph_scale)
.sum();
let start_x = match anchor {
Anchor::Start => x as f32,
Anchor::Middle => x as f32 - total_width / 2.0,
Anchor::End => x as f32 - total_width,
};
let baseline_y = y as f32;
let paint = solid_paint(fill);
let mut cursor_x = start_x;
for ch in content.chars() {
let Some(gid) = face.glyph_index(ch) else {
continue;
};
let Some(adv_raw) = face.glyph_hor_advance(gid) else {
continue;
};
let adv = adv_raw as f32 * glyph_scale;
let entry = cache
.entry(gid)
.or_insert_with(|| build_canonical_glyph_path(face, gid));
if let Some(path) = entry.as_ref() {
let glyph_transform = transform.pre_concat(
Transform::from_translate(cursor_x, baseline_y)
.pre_concat(Transform::from_scale(glyph_scale, glyph_scale)),
);
pixmap.fill_path(path, &paint, FillRule::Winding, glyph_transform, None);
}
cursor_x += adv;
}
}
fn parse_path_data(d: &str) -> Option<tiny_skia::Path> {
let mut b = PathBuilder::new();
let mut tokens = d.split_ascii_whitespace();
let mut cur = [0.0_f64; 2];
while let Some(tok) = tokens.next() {
match tok {
"M" => {
let x = tokens.next()?.parse::<f64>().ok()?;
let y = tokens.next()?.parse::<f64>().ok()?;
b.move_to(x as f32, y as f32);
cur = [x, y];
}
"L" => {
let x = tokens.next()?.parse::<f64>().ok()?;
let y = tokens.next()?.parse::<f64>().ok()?;
b.line_to(x as f32, y as f32);
cur = [x, y];
}
"C" => {
let x1 = tokens.next()?.parse::<f64>().ok()?;
let y1 = tokens.next()?.parse::<f64>().ok()?;
let x2 = tokens.next()?.parse::<f64>().ok()?;
let y2 = tokens.next()?.parse::<f64>().ok()?;
let x = tokens.next()?.parse::<f64>().ok()?;
let y = tokens.next()?.parse::<f64>().ok()?;
b.cubic_to(
x1 as f32, y1 as f32, x2 as f32, y2 as f32, x as f32, y as f32,
);
cur = [x, y];
}
"A" => {
let rx = tokens.next()?.parse::<f64>().ok()?;
let ry = tokens.next()?.parse::<f64>().ok()?;
let phi = tokens.next()?.parse::<f64>().ok()?;
let laf_tok = tokens.next()?;
let swf_tok = tokens.next()?;
let laf = laf_tok.parse::<u8>().ok()?;
let swf = swf_tok.parse::<u8>().ok()?;
let x = tokens.next()?.parse::<f64>().ok()?;
let y = tokens.next()?.parse::<f64>().ok()?;
arc_to_bezier(
&mut b,
rx,
ry,
phi,
laf != 0,
swf != 0,
x,
y,
cur[0],
cur[1],
);
cur = [x, y];
}
"Z" => {
b.close();
}
_ => return None,
}
}
b.finish()
}
#[allow(clippy::too_many_arguments)]
fn arc_to_bezier(
b: &mut PathBuilder,
mut rx: f64,
mut ry: f64,
phi_deg: f64,
large_arc: bool,
sweep: bool,
ex: f64,
ey: f64,
sx: f64,
sy: f64,
) {
if rx.abs() < 1e-10 || ry.abs() < 1e-10 {
b.line_to(ex as f32, ey as f32);
return;
}
if (sx - ex).abs() < 1e-10 && (sy - ey).abs() < 1e-10 {
return;
}
let phi = phi_deg.to_radians();
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let dx = (sx - ex) * 0.5;
let dy = (sy - ey) * 0.5;
let x1p = cos_phi * dx + sin_phi * dy;
let y1p = -sin_phi * dx + cos_phi * dy;
rx = rx.abs();
ry = ry.abs();
let x1p2 = x1p * x1p;
let y1p2 = y1p * y1p;
let rx2 = rx * rx;
let ry2 = ry * ry;
let lambda = x1p2 / rx2 + y1p2 / ry2;
if lambda > 1.0 {
let s = lambda.sqrt();
rx *= s;
ry *= s;
}
let rx2 = rx * rx;
let ry2 = ry * ry;
let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
let den = rx2 * y1p2 + ry2 * x1p2;
let sq = if den < 1e-10 { 0.0 } else { (num / den).sqrt() };
let sign = if large_arc == sweep {
-1.0_f64
} else {
1.0_f64
};
let cxp = sign * sq * rx * y1p / ry;
let cyp = sign * sq * -ry * x1p / rx;
let mx = (sx + ex) * 0.5;
let my = (sy + ey) * 0.5;
let cx = cos_phi * cxp - sin_phi * cyp + mx;
let cy = sin_phi * cxp + cos_phi * cyp + my;
let theta1 = vec_angle(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
let mut dtheta = vec_angle(
(x1p - cxp) / rx,
(y1p - cyp) / ry,
(-x1p - cxp) / rx,
(-y1p - cyp) / ry,
);
if !sweep && dtheta > 0.0 {
dtheta -= 2.0 * PI;
} else if sweep && dtheta < 0.0 {
dtheta += 2.0 * PI;
}
let n = ((dtheta.abs() / (PI / 2.0)).ceil() as u32).max(1);
let d = dtheta / n as f64;
let mut theta = theta1;
for _ in 0..n {
arc_segment(b, cx, cy, rx, ry, phi, theta, d);
theta += d;
}
}
#[allow(clippy::too_many_arguments)]
fn arc_segment(
b: &mut PathBuilder,
cx: f64,
cy: f64,
rx: f64,
ry: f64,
phi: f64,
theta: f64,
d: f64,
) {
let alpha = (d / 4.0).tan() * 4.0 / 3.0;
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let cos_t1 = theta.cos();
let sin_t1 = theta.sin();
let theta2 = theta + d;
let cos_t2 = theta2.cos();
let sin_t2 = theta2.sin();
let p1 = (rx * cos_t1, ry * sin_t1);
let dp1 = (alpha * (-rx * sin_t1), alpha * (ry * cos_t1));
let p2 = (rx * cos_t2, ry * sin_t2);
let dp2 = (alpha * (-rx * sin_t2), alpha * (ry * cos_t2));
let to_world = |lx: f64, ly: f64| -> (f32, f32) {
(
(cos_phi * lx - sin_phi * ly + cx) as f32,
(sin_phi * lx + cos_phi * ly + cy) as f32,
)
};
let (cp1x, cp1y) = to_world(p1.0 + dp1.0, p1.1 + dp1.1);
let (cp2x, cp2y) = to_world(p2.0 - dp2.0, p2.1 - dp2.1);
let (p2x, p2y) = to_world(p2.0, p2.1);
b.cubic_to(cp1x, cp1y, cp2x, cp2y, p2x, p2y);
}
fn vec_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 angle = (dot / len).clamp(-1.0, 1.0).acos();
if ux * vy - uy * vx < 0.0 {
-angle
} else {
angle
}
}
fn to_skia_color(color: &Color) -> tiny_skia::Color {
let alpha = (color.a * 255.0).round() as u8;
tiny_skia::Color::from_rgba8(color.r, color.g, color.b, alpha)
}
fn solid_paint(color: Color) -> Paint<'static> {
let mut paint = Paint::default();
paint.set_color(to_skia_color(&color));
paint
}
fn make_stroke(width: f64) -> Stroke {
Stroke {
width: width as f32,
..Stroke::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::DEFAULT_FONT;
use crate::frontend::chartjs;
fn bar_spec() -> crate::ir::ChartSpec {
chartjs::parse(
r#"{"type":"bar","data":{"labels":["A","B","C"],"datasets":[{"label":"売上","data":[10,20,30]}]}}"#,
false,
)
.unwrap()
}
fn opaque_bar_spec() -> crate::ir::ChartSpec {
chartjs::parse(
r##"{"type":"bar","data":{"labels":["A","B","C"],"datasets":[{"label":"売上","data":[10,20,30]}]},"options":{"theme":{"backgroundColor":"#ffffff"}}}"##,
false,
)
.unwrap()
}
#[test]
fn rasterizes_to_valid_png() {
let png = render_chart_to_png(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
assert_eq!(&png[0..4], &[0x89, b'P', b'N', b'G']);
assert!(png.len() > 100);
}
#[test]
fn scale_increases_size() {
let small = render_chart_to_png(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
let large = render_chart_to_png(&bar_spec(), 2.0, DEFAULT_FONT).unwrap();
assert!(large.len() > small.len());
}
#[test]
fn non_positive_scale_falls_back_to_one() {
let normal = render_chart_to_png(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
let fallback = render_chart_to_png(&bar_spec(), 0.0, DEFAULT_FONT).unwrap();
assert_eq!(&fallback[0..4], &[0x89, b'P', b'N', b'G']);
assert_eq!(normal.len(), fallback.len());
}
#[test]
fn png_is_byte_deterministic() {
let a = render_chart_to_png(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
let b = render_chart_to_png(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
assert_eq!(a, b, "直接描画の出力は決定的でなければならない");
}
#[test]
fn encode_png_fast_matches_tiny_skia_byte_for_byte() {
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let measurer = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = crate::layout::build_scene(&bar_spec(), &measurer);
let mut pixmap = scene_to_pixmap(&scene, 1.0, &face, &PNG_LIMITS).unwrap();
let expected = pixmap.encode_png().unwrap();
let actual = encode_png_fast(&mut pixmap, PngCompression::Fast, false).unwrap();
assert_eq!(
actual, expected,
"fast PNG encode は tiny-skia encode_png とバイト一致でなければならない"
);
}
#[test]
fn encode_png_fast_opaque_bg_skip_matches_tiny_skia_byte_for_byte() {
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let measurer = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = crate::layout::build_scene(&opaque_bar_spec(), &measurer);
assert!(scene.has_opaque_background());
let mut pixmap = scene_to_pixmap(&scene, 1.0, &face, &PNG_LIMITS).unwrap();
let expected = pixmap.encode_png().unwrap();
let actual = encode_png_fast(&mut pixmap, PngCompression::Fast, true).unwrap();
assert_eq!(
actual, expected,
"opaque skip 経路は tiny-skia encode_png とバイト一致でなければならない"
);
}
#[test]
fn encode_png_fast_wrong_skip_on_partial_alpha_diverges() {
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let measurer = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = crate::layout::build_scene(&bar_spec(), &measurer);
let mut pixmap = scene_to_pixmap(&scene, 1.0, &face, &PNG_LIMITS).unwrap();
let expected = pixmap.encode_png().unwrap(); let wrongly_skipped = encode_png_fast(&mut pixmap, PngCompression::Fast, true).unwrap();
assert_ne!(
wrongly_skipped, expected,
"部分α内容で skip すると出力は食い違うはず(demultiply は load-bearing)"
);
}
#[test]
fn all_pixels_opaque_decision() {
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let measurer = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
let op = crate::layout::build_scene(&opaque_bar_spec(), &measurer);
let pm1 = scene_to_pixmap(&op, 1.0, &face, &PNG_LIMITS).unwrap();
assert!(all_pixels_opaque(&op, &pm1, 1.0), "opaque+整数は skip 可");
let pm2 = scene_to_pixmap(&op, 2.0, &face, &PNG_LIMITS).unwrap();
assert!(
all_pixels_opaque(&op, &pm2, 2.0),
"opaque+整数(2x)は skip 可"
);
let round_up_scale = 1.0 + 0.5 / op.width as f32;
let pmf = scene_to_pixmap(&op, round_up_scale, &face, &PNG_LIMITS).unwrap();
assert!(
!all_pixels_opaque(&op, &pmf, round_up_scale),
"丸め上げは skip 不可"
);
let non = crate::layout::build_scene(&bar_spec(), &measurer);
let pmn = scene_to_pixmap(&non, 1.0, &face, &PNG_LIMITS).unwrap();
assert!(
!all_pixels_opaque(&non, &pmn, 1.0),
"非 opaque は skip 不可"
);
}
#[test]
fn encode_rgba_png_rejects_mismatched_buffer() {
let result = encode_rgba_png(&[0u8; 4], 10, 10, PngCompression::Fast);
assert!(result.is_err());
}
#[test]
fn compression_modes_are_pixel_identical_and_smaller() {
let spec = bar_spec();
let fast =
render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, PngCompression::Fast).unwrap();
let balanced =
render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, PngCompression::Balanced).unwrap();
let high =
render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, PngCompression::High).unwrap();
let pf = Pixmap::decode_png(&fast).unwrap();
let pb = Pixmap::decode_png(&balanced).unwrap();
let ph = Pixmap::decode_png(&high).unwrap();
assert_eq!(
pf.data(),
pb.data(),
"Balanced は Fast とピクセル一致でなければならない"
);
assert_eq!(
pf.data(),
ph.data(),
"High は Fast とピクセル一致でなければならない"
);
assert!(
balanced.len() < fast.len(),
"Balanced は Fast より小さいはず"
);
assert!(high.len() < fast.len(), "High は Fast より小さいはず");
}
#[test]
fn default_render_is_balanced_compression() {
let spec = bar_spec();
let default = render_chart_to_png(&spec, 1.0, DEFAULT_FONT).unwrap();
let balanced =
render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, PngCompression::Balanced).unwrap();
assert_eq!(
default, balanced,
"既定は Balanced とバイト一致でなければならない"
);
assert_eq!(PngCompression::default(), PngCompression::Balanced);
}
#[test]
fn compression_modes_are_deterministic() {
let spec = bar_spec();
for c in [
PngCompression::Fast,
PngCompression::Balanced,
PngCompression::High,
] {
let a = render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, c).unwrap();
let b = render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, c).unwrap();
assert_eq!(a, b, "{c:?} は決定的でなければならない");
}
}
#[test]
fn with_invalid_font_is_err() {
assert!(render_chart_to_png(&bar_spec(), 1.0, b"not a font").is_err());
}
#[test]
fn oversized_area_is_err() {
let mut spec = bar_spec();
spec.width = 8001.0;
spec.height = 8001.0;
let err = render_chart_to_png(&spec, 1.0, DEFAULT_FONT);
assert!(err.is_err());
assert!(err.unwrap_err().contains("exceeds"));
}
#[test]
fn pie_chart_renders() {
let spec = chartjs::parse(
r#"{"type":"pie","data":{"labels":["X","Y"],"datasets":[{"data":[40,60]}]}}"#,
false,
)
.unwrap();
let png = render_chart_to_png(&spec, 1.0, DEFAULT_FONT).unwrap();
assert_eq!(&png[0..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn line_chart_with_tension_renders() {
let spec = chartjs::parse(
r#"{"type":"line","data":{"labels":["A","B","C"],"datasets":[{"data":[1,3,2],"tension":0.4}]}}"#,
false,
)
.unwrap();
let png = render_chart_to_png(&spec, 1.0, DEFAULT_FONT).unwrap();
assert_eq!(&png[0..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn radar_chart_renders() {
let spec = chartjs::parse(
r#"{"type":"radar","data":{"labels":["A","B","C"],"datasets":[{"data":[10,20,15]}]}}"#,
false,
)
.unwrap();
let png = render_chart_to_png(&spec, 1.0, DEFAULT_FONT).unwrap();
assert_eq!(&png[0..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn parse_path_data_m_l_z() {
let path = parse_path_data("M 10 20 L 30 40 Z");
assert!(path.is_some(), "M/L/Z パスはパース可能");
}
#[test]
fn parse_path_data_cubic() {
let path = parse_path_data("M 0 0 C 10 5 20 5 30 0");
assert!(path.is_some(), "cubic bézier パスはパース可能");
}
#[test]
fn parse_path_data_arc() {
let path = parse_path_data("M 100 50 A 50 50 0 0 1 150 100");
assert!(path.is_some(), "arc パスはパース可能");
}
#[test]
fn parse_path_data_unknown_command_is_none() {
assert!(
parse_path_data("M 0 0 Q 5 5 10 0").is_none(),
"Q コマンドは非対応"
);
}
#[test]
fn rasterizes_to_valid_webp() {
let webp = render_chart_to_webp(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
assert_eq!(&webp[0..4], b"RIFF");
assert_eq!(&webp[8..12], b"WEBP");
assert!(webp.len() > 100);
}
#[test]
fn webp_scale_increases_size() {
let small = render_chart_to_webp(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
let large = render_chart_to_webp(&bar_spec(), 2.0, DEFAULT_FONT).unwrap();
assert!(large.len() > small.len());
}
#[test]
fn webp_is_byte_deterministic() {
let a = render_chart_to_webp(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
let b = render_chart_to_webp(&bar_spec(), 1.0, DEFAULT_FONT).unwrap();
assert_eq!(a, b, "WebP 出力は決定的でなければならない");
}
#[test]
fn webp_opaque_bg_skip_matches_full_demultiply_byte_for_byte() {
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let measurer = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
let scene = crate::layout::build_scene(&opaque_bar_spec(), &measurer);
assert!(scene.has_opaque_background());
let mut ref_pm = scene_to_pixmap(&scene, 1.0, &face, &WEBP_LIMITS).unwrap();
let skip_pm = ref_pm.clone();
demultiply_in_place(&mut ref_pm);
let expected = encode_pixmap_webp(&ref_pm).unwrap();
let actual = encode_pixmap_webp(&skip_pm).unwrap();
assert_eq!(
actual, expected,
"opaque WebP skip 経路は全 demultiply 経路とバイト一致でなければならない"
);
}
#[test]
fn webp_and_png_decode_to_identical_pixels() {
for (name, spec) in [("non_opaque", bar_spec()), ("opaque", opaque_bar_spec())] {
let webp = render_chart_to_webp(&spec, 1.0, DEFAULT_FONT).unwrap();
let png =
render_chart_to_png_with(&spec, 1.0, DEFAULT_FONT, PngCompression::Fast).unwrap();
let webp_rgba = image::load_from_memory_with_format(&webp, image::ImageFormat::WebP)
.unwrap()
.to_rgba8();
let png_pm = tiny_skia::Pixmap::decode_png(&png).unwrap();
assert_eq!(webp_rgba.width(), png_pm.width(), "{name} width");
assert_eq!(webp_rgba.height(), png_pm.height(), "{name} height");
let webp_premul: Vec<u8> = webp_rgba
.pixels()
.flat_map(|p| {
let pm = tiny_skia::ColorU8::from_rgba(p[0], p[1], p[2], p[3]).premultiply();
[pm.red(), pm.green(), pm.blue(), pm.alpha()]
})
.collect();
assert_eq!(
webp_premul.as_slice(),
png_pm.data(),
"{name}: WebP と PNG は同一ピクセルへエンコードすべき(premultiplied 空間で厳密一致)"
);
}
}
#[test]
fn webp_oversized_area_is_err() {
let mut spec = bar_spec();
spec.width = 8001.0;
spec.height = 8001.0;
let err = render_chart_to_webp(&spec, 1.0, DEFAULT_FONT);
assert!(err.is_err());
assert!(err.unwrap_err().contains("exceeds"));
}
#[test]
fn infinite_scale_is_err() {
let err = render_chart_to_png(&bar_spec(), f32::INFINITY, DEFAULT_FONT);
assert!(err.is_err());
assert!(err.unwrap_err().contains("exceeds"));
}
#[test]
fn webp_infinite_scale_is_err() {
let err = render_chart_to_webp(&bar_spec(), f32::INFINITY, DEFAULT_FONT);
assert!(err.is_err());
assert!(err.unwrap_err().contains("exceeds"));
}
#[test]
fn webp_axis_limit_is_err() {
let mut spec = bar_spec();
spec.width = 20_000.0;
spec.height = 100.0;
let err = render_chart_to_webp(&spec, 1.0, DEFAULT_FONT);
assert!(err.is_err());
assert!(err.unwrap_err().contains("per-axis limit"));
}
#[test]
fn webp_rejects_area_over_webp_budget_within_png_limit() {
let mut spec = bar_spec();
spec.width = 16_001.0; spec.height = 2_049.0; let err = render_chart_to_webp(&spec, 1.0, DEFAULT_FONT);
assert!(err.is_err(), "WebP 面積予算超過は Err でなければならない");
let msg = err.unwrap_err();
assert!(msg.contains("WebP output"), "msg: {msg}");
assert!(msg.contains("area limit"), "msg: {msg}");
}
#[test]
fn arc_to_bezier_produces_closed_circle() {
let mut b = PathBuilder::new();
b.move_to(150.0, 100.0);
arc_to_bezier(
&mut b, 50.0, 50.0, 0.0, false, true, 50.0, 100.0, 150.0, 100.0,
);
arc_to_bezier(
&mut b, 50.0, 50.0, 0.0, false, true, 150.0, 100.0, 50.0, 100.0,
);
b.close();
assert!(b.finish().is_some(), "360° 円は有効パス");
}
fn circle(cx: f64, cy: f64, r: f64, fill: Color) -> Prim {
Prim::Circle {
cx,
cy,
r,
fill,
stroke: Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
},
stroke_width: 1.0,
}
}
const RED: Color = Color {
r: 255,
g: 0,
b: 0,
a: 1.0,
};
const BLUE: Color = Color {
r: 0,
g: 0,
b: 255,
a: 1.0,
};
#[test]
fn uniform_circle_run_len_counts_consecutive_same_appearance() {
let items = [
circle(0.0, 0.0, 3.0, RED),
circle(10.0, 0.0, 3.0, RED),
circle(20.0, 0.0, 3.0, RED),
circle(30.0, 0.0, 5.0, RED), ];
assert_eq!(uniform_circle_run_len(&items, 0), 3);
}
#[test]
fn uniform_circle_run_len_from_break_point_returns_one() {
let items = [
circle(0.0, 0.0, 3.0, RED),
circle(10.0, 0.0, 3.0, RED),
circle(20.0, 0.0, 3.0, RED),
circle(30.0, 0.0, 5.0, RED),
];
assert_eq!(uniform_circle_run_len(&items, 3), 1);
}
#[test]
fn uniform_circle_run_len_breaks_on_differing_fill() {
let items = [
circle(0.0, 0.0, 3.0, RED),
circle(10.0, 0.0, 3.0, RED),
circle(20.0, 0.0, 3.0, BLUE), ];
assert_eq!(uniform_circle_run_len(&items, 0), 2);
}
#[test]
fn uniform_circle_run_len_breaks_on_differing_stroke() {
let red_stroke = Prim::Circle {
cx: 0.0,
cy: 0.0,
r: 3.0,
fill: RED,
stroke: RED,
stroke_width: 1.0,
};
let blue_stroke = Prim::Circle {
cx: 20.0,
cy: 0.0,
r: 3.0,
fill: RED,
stroke: BLUE, stroke_width: 1.0,
};
let items = [red_stroke.clone(), red_stroke, blue_stroke];
assert_eq!(uniform_circle_run_len(&items, 0), 2);
}
#[test]
fn uniform_circle_run_len_breaks_on_differing_stroke_width() {
let stroke = Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
};
let thin = Prim::Circle {
cx: 0.0,
cy: 0.0,
r: 3.0,
fill: RED,
stroke,
stroke_width: 1.0,
};
let thick = Prim::Circle {
cx: 10.0,
cy: 0.0,
r: 3.0,
fill: RED,
stroke,
stroke_width: 2.0, };
let items = [thin.clone(), thin, thick];
assert_eq!(uniform_circle_run_len(&items, 0), 2);
}
#[test]
fn uniform_circle_run_len_zero_for_non_circle_start() {
let items = [
Prim::Rect {
x: 0.0,
y: 0.0,
w: 5.0,
h: 5.0,
fill: RED,
},
circle(10.0, 0.0, 3.0, RED),
];
assert_eq!(uniform_circle_run_len(&items, 0), 0);
}
fn nonzero_alpha_count(pm: &Pixmap) -> usize {
pm.data().chunks_exact(4).filter(|px| px[3] > 0).count()
}
fn stamp_key(r: f64, stroke_width: f64) -> MarkerKey {
MarkerKey {
r,
fill: RED,
stroke: BLUE,
stroke_width,
}
}
#[test]
fn build_stamp_set_count_and_size() {
let key = stamp_key(3.0, 1.0);
let scale = 1.0_f32;
let set = build_stamp_set(&key, scale);
assert_eq!(set.b, STAMP_B);
assert_eq!(set.stamps.len(), (STAMP_B * STAMP_B) as usize);
let r_dev = key.r as f32 * scale;
let stroke_dev = key.stroke_width as f32 * scale;
let expected_pad = (r_dev + stroke_dev / 2.0).ceil() as i32 + 2;
let expected_size = (2 * expected_pad + 2) as u32;
assert_eq!(set.pad, expected_pad);
for pm in &set.stamps {
assert_eq!(pm.width(), expected_size);
assert_eq!(pm.height(), expected_size);
}
}
fn assert_zero_offset_byte_identity(key: &MarkerKey, scale: f32) {
let set = build_stamp_set(key, scale);
let zero = &set.stamps[0];
let r_dev = key.r as f32 * scale;
let stroke_dev = key.stroke_width as f32 * scale;
let pad = (r_dev + stroke_dev / 2.0).ceil() as i32 + 2;
let size = (2 * pad + 2) as u32;
let anchor = pad as f32;
let mut expected = Pixmap::new(size, size).unwrap();
let path = PathBuilder::from_circle(anchor, anchor, r_dev).unwrap();
expected.fill_path(
&path,
&solid_paint(key.fill),
FillRule::Winding,
Transform::identity(),
None,
);
if key.stroke_width > 0.0 {
expected.stroke_path(
&path,
&solid_paint(key.stroke),
&make_stroke(key.stroke_width * scale as f64),
Transform::identity(),
None,
);
}
assert_eq!(zero.data(), expected.data());
}
#[test]
fn build_stamp_set_zero_offset_byte_identical_with_stroke() {
assert_zero_offset_byte_identity(&stamp_key(3.0, 1.0), 1.0);
}
#[test]
fn build_stamp_set_zero_offset_byte_identical_no_stroke() {
assert_zero_offset_byte_identity(&stamp_key(3.0, 0.0), 1.0);
}
#[test]
fn build_stamp_set_bakes_stroke() {
let with_stroke = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
let without_stroke = build_stamp_set(&stamp_key(3.0, 0.0), 1.0);
let with_count: usize = nonzero_alpha_count(&with_stroke.stamps[0]);
let without_count: usize = nonzero_alpha_count(&without_stroke.stamps[0]);
assert!(
with_count > without_count,
"stroke 有り({with_count}) は無し({without_count}) より非透明画素が多いはず"
);
}
#[test]
fn build_stamp_set_subpixel_stamps_differ() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
let idx_00 = 0; let idx_70 = 7; assert_ne!(set.stamps[idx_00].data(), set.stamps[idx_70].data());
}
#[test]
fn build_stamp_set_zero_radius_no_panic() {
let key = stamp_key(0.0, 1.0); let set = build_stamp_set(&key, 1.0);
assert_eq!(set.stamps.len(), (STAMP_B * STAMP_B) as usize);
let pad = set.pad;
let size = set.stamps[0].width();
let anchor = pad as f32;
let mut expected = Pixmap::new(size, size).unwrap();
let face = ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap();
let mut cache = HashMap::new();
render_prim(
&mut expected,
&Prim::Circle {
cx: anchor as f64,
cy: anchor as f64,
r: 0.0,
fill: key.fill,
stroke: key.stroke,
stroke_width: key.stroke_width,
},
Transform::identity(),
&face,
&mut cache,
);
assert_eq!(set.stamps[0].data(), expected.data());
let neg = build_stamp_set(&stamp_key(-1.0, 1.0), 1.0);
assert_eq!(neg.stamps.len(), (STAMP_B * STAMP_B) as usize);
for pm in &neg.stamps {
assert_eq!(nonzero_alpha_count(pm), 0, "r<0 stamp は完全透明のはず");
}
}
#[test]
fn build_stamp_set_scale_two() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 2.0);
assert_eq!(set.stamps.len(), (STAMP_B * STAMP_B) as usize);
assert_eq!(set.pad, 9);
assert_eq!(set.stamps[0].width(), 20);
assert_eq!(set.stamps[0].height(), 20);
assert_ne!(set.stamps[0].data(), set.stamps[7].data());
}
#[test]
fn build_stamp_set_pad_size_literal() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
assert_eq!(set.pad, 6);
assert_eq!(set.stamps[0].width(), 14);
}
fn stamp_key_semi(stroke_width: f64) -> MarkerKey {
MarkerKey {
r: 3.0,
fill: Color {
r: 255,
g: 0,
b: 0,
a: 0.5,
},
stroke: BLUE,
stroke_width,
}
}
fn assert_blit_matches_draw_pixmap(key: &MarkerKey, cx: f32, cy: f32) {
let set = build_stamp_set(key, 1.0);
let (w, h) = (40u32, 40u32);
let bg = tiny_skia::Color::from_rgba8(0, 128, 0, 128);
let mut pm_a = Pixmap::new(w, h).unwrap();
pm_a.fill(bg);
blit_stamp(&mut pm_a, &set, cx, cy);
let mut pm_b = Pixmap::new(w, h).unwrap();
pm_b.fill(bg);
let (ix, iy, idx) = pick_stamp(&set, cx, cy);
pm_b.draw_pixmap(
ix - set.pad,
iy - set.pad,
set.stamps[idx].as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
None,
);
assert_eq!(
pm_a.data(),
pm_b.data(),
"blit_stamp は draw_pixmap とバイト一致でなければならない (key.r={}, cx={cx}, cy={cy})",
key.r
);
}
#[test]
fn blit_stamp_byte_identical_to_draw_pixmap_opaque_integer() {
assert_blit_matches_draw_pixmap(&stamp_key(3.0, 1.0), 20.0, 20.0);
}
#[test]
fn blit_stamp_byte_identical_to_draw_pixmap_semi_fractional() {
assert_blit_matches_draw_pixmap(&stamp_key_semi(1.0), 20.3, 20.7);
}
#[test]
fn blit_stamp_byte_identical_to_draw_pixmap_opaque_fractional() {
assert_blit_matches_draw_pixmap(&stamp_key(3.0, 1.0), 18.6, 21.4);
}
#[test]
fn blit_stamp_byte_identical_to_draw_pixmap_carry_to_next_pixel() {
assert_blit_matches_draw_pixmap(&stamp_key(3.0, 1.0), 20.95, 20.95);
}
#[test]
fn blit_stamp_overlap_composites_not_overwrites() {
let key = stamp_key_semi(0.0); let set = build_stamp_set(&key, 1.0);
let (w, h) = (40u32, 40u32);
let mut single = Pixmap::new(w, h).unwrap();
blit_stamp(&mut single, &set, 20.0, 20.0);
let mut overlap = Pixmap::new(w, h).unwrap();
blit_stamp(&mut overlap, &set, 20.0, 20.0);
blit_stamp(&mut overlap, &set, 22.0, 20.0);
let alpha_at =
|pm: &Pixmap, x: u32, y: u32| -> u8 { pm.data()[((y * w + x) * 4 + 3) as usize] };
let single_a = alpha_at(&single, 20, 20);
let overlap_a = alpha_at(&overlap, 20, 20);
assert!(single_a > 0, "単独 blit で (20,20) は描画されているはず");
assert!(
overlap_a > single_a,
"重なり画素 alpha({overlap_a}) は単独 alpha({single_a}) より大きい(累積)はず"
);
}
#[test]
fn blit_stamp_off_canvas_clips_without_panic() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
let mut pm_tl = Pixmap::new(10, 10).unwrap();
blit_stamp(&mut pm_tl, &set, 0.0, 0.0);
assert!(
nonzero_alpha_count(&pm_tl) > 0,
"一部内側のはみ出しでも in-bounds 画素は描かれるはず"
);
let mut pm_br = Pixmap::new(10, 10).unwrap();
blit_stamp(&mut pm_br, &set, 10.0, 10.0);
assert!(
nonzero_alpha_count(&pm_br) > 0,
"右下はみ出しでも in-bounds 画素は描かれるはず"
);
let mut pm_out = Pixmap::new(10, 10).unwrap();
blit_stamp(&mut pm_out, &set, -100.0, -100.0);
let untouched = Pixmap::new(10, 10).unwrap();
assert_eq!(
pm_out.data(),
untouched.data(),
"完全に外側なら dest は不変のはず"
);
}
fn scene_for(json: &str) -> Scene {
let spec = crate::frontend::chartjs::parse(json, false).unwrap();
let m = crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap();
crate::layout::build_scene(&spec, &m)
}
fn face() -> ttf_parser::Face<'static> {
ttf_parser::Face::parse(DEFAULT_FONT, 0).unwrap()
}
fn diff_fraction(a: &Pixmap, b: &Pixmap) -> f64 {
let diff = a
.data()
.chunks_exact(4)
.zip(b.data().chunks_exact(4))
.filter(|(x, y)| {
x.iter()
.zip(y.iter())
.any(|(xc, yc)| (*xc as i16 - *yc as i16).abs() > 4)
})
.count();
let total = (a.width() as u64 * a.height() as u64) as f64;
diff as f64 / total
}
fn uniform_scatter_json(n: usize) -> String {
let pts = (0..n)
.map(|i| format!(r#"{{"x":{i},"y":{}}}"#, (i * 37 + 13) % 100))
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"type":"scatter","data":{{"datasets":[{{"label":"d","data":[{pts}]}}]}}}}"#)
}
fn percolor_scatter_json(n: usize) -> String {
let pts = (0..n)
.map(|i| format!(r#"{{"x":{i},"y":{}}}"#, (i * 37 + 13) % 100))
.collect::<Vec<_>>()
.join(",");
format!(
r##"{{"type":"scatter","data":{{"datasets":[{{"label":"d","backgroundColor":["#ff0000","#0000ff"],"data":[{pts}]}}]}}}}"##
)
}
fn bubble_json(n: usize) -> String {
let pts = (0..n)
.map(|i| {
format!(
r#"{{"x":{i},"y":{},"r":{}}}"#,
(i * 37 + 13) % 100,
2 + i % 9
)
})
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"type":"bubble","data":{{"datasets":[{{"label":"d","data":[{pts}]}}]}}}}"#)
}
#[test]
fn fallback_per_point_color_is_byte_identical() {
let scene = scene_for(&percolor_scatter_json(200));
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_eq!(
stamp.data(),
reference.data(),
"per-point カラーは stamp path を踏まずバイト一致のはず"
);
}
#[test]
fn fallback_bubble_per_point_radius_is_byte_identical() {
let scene = scene_for(&bubble_json(200));
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_eq!(
stamp.data(),
reference.data(),
"per-point 半径は stamp path を踏まずバイト一致のはず"
);
}
#[test]
fn fallback_short_uniform_run_is_byte_identical() {
let scene = scene_for(&uniform_scatter_json(100));
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_eq!(
stamp.data(),
reference.data(),
"短い run(100 < 128)は stamp path を踏まずバイト一致のはず"
);
}
#[test]
fn stamp_uniform_scatter_within_tolerance() {
let scene = scene_for(&uniform_scatter_json(200));
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_ne!(
stamp.data(),
reference.data(),
"uniform scatter(200 点)は stamp path を踏むはず(バイト一致ではない)"
);
let frac = diff_fraction(&stamp, &reference);
assert!(
frac < 0.02,
"stamp 出力は参照と視覚的に同等(差分 {frac:.6} < 0.02)のはず"
);
}
#[test]
fn stamp_uniform_scatter_within_tolerance_at_scale_2() {
let scene = scene_for(&uniform_scatter_json(200));
let f = face();
let stamp = scene_to_pixmap_with(&scene, 2.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 2.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_ne!(
stamp.data(),
reference.data(),
"scale=2 でも uniform scatter(200 点)は stamp path を踏むはず"
);
let frac = diff_fraction(&stamp, &reference);
assert!(
frac < 0.02,
"scale=2 の stamp 出力は参照と視覚的に同等(差分 {frac:.6} < 0.02)のはず"
);
}
#[test]
fn huge_radius_markers_fall_back_to_fillpath() {
let pts = (0..130)
.map(|i| format!(r#"{{"x":{},"y":{}}}"#, i, (i * 37 + 13) % 100))
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{"type":"scatter","data":{{"datasets":[{{"label":"d","pointRadius":70,"data":[{pts}]}}]}}}}"#
);
let scene = scene_for(&json);
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_eq!(
stamp.data(),
reference.data(),
"巨大半径マーカーは per-prim フォールバックで出力不変のはず"
);
}
#[test]
fn build_stamp_set_negative_stroke_no_panic() {
let set = build_stamp_set(&stamp_key(3.0, -1000.0), 1.0);
assert_eq!(set.stamps.len(), (STAMP_B * STAMP_B) as usize);
assert!(set.pad >= 2, "pad は 2 以上にクランプされるはず");
}
#[test]
fn blit_stamp_non_finite_coords_no_panic() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
let mut pm = Pixmap::new(40, 40).unwrap();
let before = pm.data().to_vec();
blit_stamp(&mut pm, &set, f32::NAN, 20.0);
blit_stamp(&mut pm, &set, 20.0, f32::INFINITY);
blit_stamp(&mut pm, &set, f32::NEG_INFINITY, f32::NAN);
blit_stamp(&mut pm, &set, 1.0e38, 1.0e38);
blit_stamp(&mut pm, &set, -1.0e38, 5.0);
assert_eq!(
pm.data(),
&before[..],
"非有限/巨大座標は何も描画しないはず"
);
}
#[test]
fn pick_stamp_huge_coords_index_in_range() {
let set = build_stamp_set(&stamp_key(3.0, 1.0), 1.0);
let (_, _, idx) = pick_stamp(&set, 1.0e9, 1.0e9);
assert!(
idx < (STAMP_B * STAMP_B) as usize,
"idx は stamps(長さ b*b) の範囲内のはず"
);
}
#[test]
fn stamp_line_markers_within_tolerance() {
let pts = (0..200)
.map(|i| ((i * 37 + 13) % 100).to_string())
.collect::<Vec<_>>()
.join(",");
let labels = (0..200)
.map(|i| format!("\"L{i}\""))
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{"type":"line","data":{{"labels":[{labels}],"datasets":[{{"label":"d","data":[{pts}]}}]}}}}"#
);
let scene = scene_for(&json);
let f = face();
let stamp = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, STAMP_MIN_RUN).unwrap();
let reference = scene_to_pixmap_with(&scene, 1.0, &f, &PNG_LIMITS, usize::MAX).unwrap();
assert_ne!(
stamp.data(),
reference.data(),
"line マーカー(200 点)は stamp path を踏むはず"
);
let frac = diff_fraction(&stamp, &reference);
assert!(
frac < 0.02,
"line マーカーの stamp 出力は参照と視覚的に同等(差分 {frac:.6} < 0.02)のはず"
);
}
}