use std::collections::HashMap;
use std::f64::consts::PI;
use resvg::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::raster::MAX_PNG_AREA_PIXELS;
use crate::scene::{Anchor, Prim, Scene};
pub fn render_chart_to_png(
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!("フォント解析失敗: {e}"))?;
let measurer =
crate::text::TextMeasurer::new(font_bytes).map_err(|e| format!("計測初期化失敗: {e}"))?;
let scene = crate::layout::build_scene(spec, &measurer);
scene_to_png_with_face(&scene, scale, &face)
}
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 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!("フォント解析失敗: {e}"))?;
scene_to_png_with_face(scene, scale, &face)
}
fn scene_to_png_with_face(
scene: &Scene,
scale: f32,
face: &ttf_parser::Face<'_>,
) -> Result<Vec<u8>, 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;
if area > MAX_PNG_AREA_PIXELS {
return Err(format!(
"PNG 解像度 {w}×{h} px ({area} ピクセル) が上限 {MAX_PNG_AREA_PIXELS} px² を超えています"
));
}
let mut pixmap =
Pixmap::new(w, h).ok_or_else(|| format!("Pixmap 確保失敗: 寸法 {w}x{h} が無効です"))?;
let transform = Transform::from_scale(scale, scale);
let mut glyph_cache: HashMap<ttf_parser::GlyphId, Option<tiny_skia::Path>> = HashMap::new();
for prim in &scene.items {
render_prim(&mut pixmap, prim, transform, face, &mut glyph_cache);
}
pixmap
.encode_png()
.map_err(|e| format!("PNG エンコード失敗: {e}"))
}
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::Circle { cx, cy, r, fill } => {
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,
);
}
Prim::Text {
x,
y,
size,
anchor,
fill,
content,
} => {
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 solid_paint(color: Color) -> Paint<'static> {
let mut paint = Paint::default();
let alpha = (color.a * 255.0).round() as u8;
paint.set_color(tiny_skia::Color::from_rgba8(
color.r, color.g, color.b, alpha,
));
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()
}
#[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 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("上限"));
}
#[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 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° 円は有効パス");
}
}