visual testing

This commit is contained in:
2026-04-06 03:17:30 +03:00
parent 53ba44e2f9
commit f04c39cb69
29 changed files with 2575 additions and 76 deletions

View File

@@ -2,13 +2,16 @@
name = "dreport-layout"
version = "0.1.0"
edition = "2024"
description = "Layout engine for dreport (taffy + cosmic-text)"
license = "MIT"
publish = ["gitea"]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
dreport-core = { path = "../core" }
dreport-core = { version = "0.1.0", path = "../core", registry = "gitea" }
dexpr = { version = "0.1.0", registry = "gitea" }
taffy = "0.9"
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }

View File

@@ -442,12 +442,12 @@ fn render_pie(
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let radius = pw.min(ph) / 2.0 * 0.9;
let radius = pw.min(ph) / 2.0 * 0.65;
let inner_frac = data.style.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
let inner_r = radius * inner_frac;
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.5);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(3.0);
let label_color = data
.labels
.as_ref()
@@ -499,7 +499,7 @@ fn render_pie(
.unwrap();
}
// Label
// Percentage label inside slice
if show_labels {
let mid_angle = start_angle + sweep / 2.0;
let label_r = if inner_r > 0.0 {
@@ -518,6 +518,37 @@ fn render_pie(
.unwrap();
}
// Category name label outside slice with leader line
if i < data.categories.len() {
let mid_angle = start_angle + sweep / 2.0;
let line_start_r = radius; // starts at pie edge
let line_end_r = radius + 3.0;
let text_r = radius + 4.0;
// Leader line from pie edge to label
let lx1 = cx + line_start_r * mid_angle.cos();
let ly1 = cy + line_start_r * mid_angle.sin();
let lx2 = cx + line_end_r * mid_angle.cos();
let ly2 = cy + line_end_r * mid_angle.sin();
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
lx1, ly1, lx2, ly2
)
.unwrap();
// Category text
let tx = cx + text_r * mid_angle.cos();
let ty = cy + text_r * mid_angle.sin();
let anchor = if mid_angle.cos() >= 0.0 { "start" } else { "end" };
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
tx, ty, anchor, escape_xml(&data.categories[i])
)
.unwrap();
}
start_angle = end_angle;
}
}

View File

@@ -118,6 +118,24 @@ pub struct ChartRenderData {
pub line_width: Option<f64>,
#[serde(default)]
pub background_color: Option<String>,
// Label color
#[serde(default)]
pub label_color: Option<String>,
// Legend
#[serde(default)]
pub legend_show: bool,
#[serde(default)]
pub legend_position: Option<String>,
#[serde(default)]
pub legend_font_size: Option<f64>,
// Axis labels
#[serde(default)]
pub x_label: Option<String>,
#[serde(default)]
pub y_label: Option<String>,
// Title align
#[serde(default)]
pub title_align: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -759,7 +759,6 @@ fn render_chart(
measurer: &mut TextMeasurer,
) {
// Tum hesaplar mm cinsinden yapilir, cizim pt'ye cevrilir
// base_x_mm, base_y_mm: element'in sayfa uzerindeki mm pozisyonu
let base_x_mm: f64 = (x / MM_TO_PT) as f64;
let base_y_mm: f64 = (y / MM_TO_PT) as f64;
let w_mm: f64 = (w / MM_TO_PT) as f64;
@@ -769,26 +768,31 @@ fn render_chart(
chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm,
parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF")));
// Margin'ler (SVG renderer ile ayni mantik)
let mut mt = 2.0_f64;
let mut mb = 4.0_f64;
let ml = 14.0_f64;
let mr = 4.0_f64;
// Margin hesaplari — SVG renderer ile AYNI mantik
let mut margin_top = 2.0_f64;
let mut margin_bottom = 4.0_f64;
let mut margin_left = 8.0_f64;
let margin_right = 4.0_f64;
// Title
if let Some(ref title) = data.title_text {
if !title.is_empty() {
let fs = data.title_font_size.unwrap_or(4.0);
mt += fs * 0.4 + 2.0;
margin_top += fs * 0.4 + 2.0;
let color = parse_color(data.title_color.as_deref().unwrap_or("#333333"));
let font = fonts.get(None, Some("bold"));
if let Some(f) = font {
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let fs_pt = fs as f32;
let fs_pt = pt(fs);
let (tw, _) = measurer.measure(title, None, fs_pt, Some("bold"), None);
let tx = pt(base_x_mm + w_mm / 2.0) - tw / 2.0;
let ty = pt(base_y_mm + mt - 1.0);
let align = data.title_align.as_deref().unwrap_or("center");
let tx = match align {
"left" => pt(base_x_mm + margin_left),
"right" => pt(base_x_mm + w_mm - margin_right) - tw,
_ => pt(base_x_mm + w_mm / 2.0) - tw / 2.0,
};
let ty = pt(base_y_mm + margin_top - 1.0);
surface.draw_text(
Point::from_xy(tx, ty),
f.clone(), fs_pt, title, false, TextDirection::Auto,
@@ -797,24 +801,85 @@ fn render_chart(
}
}
let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie);
// Legend space
let legend_show = data.legend_show;
let legend_pos = data.legend_position.as_deref().unwrap_or("bottom");
let legend_font = data.legend_font_size.unwrap_or(2.8);
if !is_pie {
let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0);
if max_label_len > 6 { mb += 10.0; } else { mb += 4.0; }
mb += 4.0;
if legend_show && data.series.len() > 1 {
match legend_pos {
"top" => margin_top += legend_font + 3.0,
"bottom" => margin_bottom += legend_font + 3.0,
_ => {} // right — icerde handle edilecek
}
}
let plot_x = base_x_mm + ml;
let plot_y = base_y_mm + mt;
let plot_w = (w_mm - ml - mr).max(1.0);
let plot_h = (h_mm - mt - mb).max(1.0);
let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie);
// Axis labels icin yer ac (bar ve line) — SVG ile ayni
if !is_pie {
if data.x_label.is_some() {
margin_bottom += 4.0;
}
if data.y_label.is_some() {
margin_left += 4.0;
}
// Category labels icin alt bosluk
let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0);
let n_cats = data.categories.len();
let available_w = w_mm - margin_left - margin_right;
let cat_width = if n_cats > 0 { available_w / n_cats as f64 } else { available_w };
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
let will_rotate = max_label_len > max_chars_fit;
if will_rotate {
let char_w_mm = 1.1;
let max_text_w = max_label_len as f64 * char_w_mm;
let label_v = max_text_w * 0.707;
margin_bottom += label_v.min(25.0).max(6.0);
let label_h = max_text_w * 0.707;
let extra_left = (label_h - cat_width / 2.0).max(0.0);
margin_left += extra_left.min(10.0);
} else {
margin_bottom += 4.0;
}
// Y-axis value labels icin sol bosluk
margin_left += 6.0;
}
let plot_x = base_x_mm + margin_left;
let plot_y = base_y_mm + margin_top;
let plot_w = (w_mm - margin_left - margin_right).max(1.0);
let plot_h = (h_mm - margin_top - margin_bottom).max(1.0);
use dreport_core::models::ChartType;
match data.chart_type {
ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h),
ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h),
ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h),
ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer),
ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer),
ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h, fonts, measurer),
}
// Legend render
if legend_show && data.series.len() > 1 {
render_chart_legend(surface, data, legend_pos, legend_font, base_x_mm, base_y_mm, w_mm, h_mm, margin_left, margin_top, plot_w, plot_h, fonts, measurer);
}
// Axis labels
if !is_pie {
if let Some(ref x_label) = data.x_label {
let lx = plot_x + plot_w / 2.0;
let ly = base_y_mm + h_mm - 2.0;
chart_text_centered(surface, lx, ly, x_label, 2.8, "#666666", fonts, measurer);
}
if let Some(ref y_label) = data.y_label {
let lx = base_x_mm + 3.0;
let ly = plot_y + plot_h / 2.0;
// Rotated text — krilla'da transform ile
surface.push_transform(&Transform::from_translate(pt(lx), pt(ly)));
surface.push_transform(&Transform::from_row(0.0, -1.0, 1.0, 0.0, 0.0, 0.0));
chart_text_centered(surface, 0.0, 0.0, y_label, 2.8, "#666666", fonts, measurer);
surface.pop();
surface.pop();
}
}
}
@@ -855,37 +920,258 @@ fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64,
}
}
/// Chart icin metin ciz — tek satirlik, centered
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
fn chart_text_centered(
surface: &mut krilla::surface::Surface<'_>,
cx_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None);
let Some(f) = font else { return; };
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto,
);
}
/// Chart icin metin ciz — end-aligned (sag hizali)
fn chart_text_end(
surface: &mut krilla::surface::Surface<'_>,
right_x_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None);
let Some(f) = font else { return; };
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto,
);
}
/// Chart icin metin ciz — start-aligned (sol hizali)
fn chart_text_start(
surface: &mut krilla::surface::Surface<'_>,
x_mm: f64, cy_mm: f64,
text: &str, font_size_mm: f64, color_hex: &str,
fonts: &FontCollection, _measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None);
let Some(f) = font else { return; };
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(x_mm), pt(cy_mm)),
f.clone(), fs_pt, text, false, TextDirection::Auto,
);
}
fn chart_format_value(v: f64) -> String {
if v.abs() >= 1_000_000.0 {
format!("{:.1}M", v / 1_000_000.0)
} else if v.abs() >= 1_000.0 {
format!("{:.1}K", v / 1_000.0)
} else if v.fract().abs() < 1e-10 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}
/// Y-axis grid + value labels (SVG render_y_axis ile ayni)
fn render_chart_y_axis(
surface: &mut krilla::surface::Surface<'_>,
min_val: f64, max_val: f64,
px: f64, py: f64, pw: f64, ph: f64,
show_grid: bool, grid_color: &str,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val };
let tick_count = 5;
for i in 0..=tick_count {
let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range;
let y = py + ph - frac * ph;
// Value label
let label = chart_format_value(val);
chart_text_end(surface, px - 1.5, y + 0.8, &label, 2.3, "#666666", fonts, measurer);
// Grid line
if show_grid {
let gc = parse_color(grid_color);
chart_line_seg(surface, px, y, px + pw, y, gc, 0.4);
}
}
// Y axis line
let ac = parse_color("#9CA3AF");
chart_line_seg(surface, px, py, px, py + ph, ac, 0.8);
}
/// X-axis category labels — bar chart (slot-based spacing)
fn render_chart_x_labels(
surface: &mut krilla::surface::Surface<'_>,
categories: &[String],
px: f64, baseline_y: f64, pw: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let n_cats = categories.len();
if n_cats == 0 { return; }
let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = px + ci as f64 * cat_width + cat_width / 2.0;
let y = baseline_y + 2.5;
render_chart_single_x_label(surface, cat, x, y, needs_rotate, fonts, measurer);
}
}
/// X-axis category labels — line chart (point-based spacing)
fn render_chart_x_labels_line(
surface: &mut krilla::surface::Surface<'_>,
categories: &[String],
px: f64, baseline_y: f64, pw: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let n_cats = categories.len();
if n_cats == 0 { return; }
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 };
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 };
let y = baseline_y + 2.5;
render_chart_single_x_label(surface, cat, x, y, needs_rotate, fonts, measurer);
}
}
/// Tek bir X-axis label — rotate gerekiyorsa -45° ile
fn render_chart_single_x_label(
surface: &mut krilla::surface::Surface<'_>,
text: &str, x_mm: f64, y_mm: f64, rotate: bool,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
if rotate {
// -45° rotate, text-anchor="end"
surface.push_transform(&Transform::from_translate(pt(x_mm), pt(y_mm)));
// rotate(-45°) = cos(-45), sin(-45), -sin(-45), cos(-45)
let c = std::f32::consts::FRAC_PI_4.cos();
let s = std::f32::consts::FRAC_PI_4.sin();
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
// end-aligned: text saga hizali (negatif x'e dogru)
chart_text_end(surface, 0.0, 0.0, text, 2.2, "#666666", fonts, measurer);
surface.pop();
surface.pop();
} else {
chart_text_centered(surface, x_mm, y_mm, text, 2.5, "#666666", fonts, measurer);
}
}
/// Legend render
fn render_chart_legend(
surface: &mut krilla::surface::Surface<'_>,
data: &crate::ChartRenderData,
position: &str, font_size: f64,
base_x: f64, base_y: f64,
total_w: f64, total_h: f64,
margin_left: f64, margin_top: f64,
plot_w: f64, _plot_h: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
use dreport_core::models::ChartType;
let names: Vec<&str> = if matches!(data.chart_type, ChartType::Pie) {
data.categories.iter().map(|s| s.as_str()).collect()
} else {
data.series.iter().map(|s| s.name.as_str()).collect()
};
let swatch_size = 2.5;
let item_gap = 3.0 + font_size * 0.4;
let spacing = 4.0;
match position {
"top" => {
let y = base_y + margin_top - font_size - 1.5;
let mut x = base_x + margin_left;
for (i, name) in names.iter().enumerate() {
let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5"));
chart_rect(surface, x, y - font_size * 0.3, swatch_size, swatch_size, color);
chart_text_start(surface, x + item_gap, y + font_size * 0.3, name, font_size, "#666666", fonts, measurer);
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
}
}
"right" => {
let x = base_x + margin_left + plot_w + 4.0;
let mut y = base_y + margin_top + 2.0;
for (i, name) in names.iter().enumerate() {
let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5"));
chart_rect(surface, x, y, swatch_size, swatch_size, color);
chart_text_start(surface, x + item_gap, y + font_size * 0.7, name, font_size, "#666666", fonts, measurer);
y += font_size + 2.0;
}
}
_ => {
// bottom (default)
let y = base_y + total_h - 3.0;
let total_legend_w: f64 = names.iter()
.map(|n| item_gap + n.len() as f64 * font_size * 0.5 + spacing)
.sum::<f64>() - spacing;
let mut x = base_x + (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() {
let color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5"));
chart_rect(surface, x, y - font_size * 0.3, swatch_size, swatch_size, color);
chart_text_start(surface, x + item_gap, y + font_size * 0.3, name, font_size, "#666666", fonts, measurer);
x += item_gap + name.len() as f64 * font_size * 0.5 + spacing;
}
}
}
}
/// Bar chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu)
fn render_chart_bar(
surface: &mut krilla::surface::Surface<'_>,
data: &crate::ChartRenderData,
px: f64, py: f64, pw: f64, ph: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
if data.categories.is_empty() || data.series.is_empty() { return; }
let (min_val, max_val) = chart_value_range(data);
let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val };
let show_grid = data.show_grid;
let grid_color = data.grid_color.as_deref().unwrap_or("#E5E7EB");
// Grid + Y axis labels
render_chart_y_axis(surface, min_val, max_val, px, py, pw, ph, show_grid, grid_color, fonts, measurer);
let n_cats = data.categories.len();
let n_series = data.series.len();
let cat_width = pw / n_cats as f64;
let bar_gap = data.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8);
let group_width = cat_width * (1.0 - bar_gap);
// Grid
if data.show_grid {
let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB"));
for i in 0..=5 {
let frac = i as f64 / 5.0;
let gy = py + ph - frac * ph;
chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4);
}
}
// Axis lines
let ac = parse_color("#9CA3AF");
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
chart_line_seg(surface, px, py, px, py + ph, ac, 0.8);
let show_labels = data.show_labels;
let label_font = data.label_font_size.unwrap_or(2.2);
let label_color = data.label_color.as_deref().unwrap_or("#333333");
// Bars
if data.stacked {
@@ -898,6 +1184,10 @@ fn render_chart_bar(
let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0;
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
chart_rect(surface, bx, by, group_width, bh.max(0.0), color);
if show_labels && val > 0.0 {
let label = chart_format_value(val);
chart_text_centered(surface, bx + group_width / 2.0, by + bh / 2.0 + label_font * 0.15, &label, label_font, label_color, fonts, measurer);
}
y_off += bh;
}
}
@@ -911,9 +1201,20 @@ fn render_chart_bar(
let by = py + ph - bh;
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
chart_rect(surface, bx, by, bar_w.max(0.1), bh.max(0.0), color);
if show_labels {
let label = chart_format_value(val);
chart_text_centered(surface, bx + bar_w / 2.0, by - 0.8, &label, label_font, label_color, fonts, measurer);
}
}
}
}
// X axis category labels
render_chart_x_labels(surface, &data.categories, px, py + ph, pw, fonts, measurer);
// X axis line
let ac = parse_color("#9CA3AF");
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
}
/// Line chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu)
@@ -921,6 +1222,7 @@ fn render_chart_line(
surface: &mut krilla::surface::Surface<'_>,
data: &crate::ChartRenderData,
px: f64, py: f64, pw: f64, ph: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
if data.categories.is_empty() || data.series.is_empty() { return; }
@@ -930,19 +1232,15 @@ fn render_chart_line(
let line_w = data.line_width.unwrap_or(0.5);
let show_points = data.show_points.unwrap_or(true);
// Grid
if data.show_grid {
let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB"));
for i in 0..=5 {
let frac = i as f64 / 5.0;
let gy = py + ph - frac * ph;
chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4);
}
}
let show_grid = data.show_grid;
let grid_color = data.grid_color.as_deref().unwrap_or("#E5E7EB");
// Axis
let ac = parse_color("#9CA3AF");
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
// Grid + Y axis labels
render_chart_y_axis(surface, min_val, max_val, px, py, pw, ph, show_grid, grid_color, fonts, measurer);
let show_labels = data.show_labels;
let label_font = data.label_font_size.unwrap_or(2.2);
let label_color = data.label_color.as_deref().unwrap_or("#333333");
for (si, series) in data.series.iter().enumerate() {
let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5"));
@@ -993,7 +1291,23 @@ fn render_chart_line(
if let Some(p) = circle { surface.draw_path(&p); }
}
}
// Value labels on points
if show_labels {
for (ci, val) in series.values.iter().enumerate() {
let (lx, ly) = points[ci];
let label = chart_format_value(*val);
chart_text_centered(surface, lx, ly - 1.5, &label, label_font, label_color, fonts, measurer);
}
}
}
// X axis category labels
render_chart_x_labels_line(surface, &data.categories, px, py + ph, pw, fonts, measurer);
// Axis line
let ac = parse_color("#9CA3AF");
chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8);
}
/// Pie/donut chart — tum koordinatlar mm cinsinden
@@ -1001,6 +1315,7 @@ fn render_chart_pie(
surface: &mut krilla::surface::Surface<'_>,
data: &crate::ChartRenderData,
px: f64, py: f64, pw: f64, ph: f64,
fonts: &FontCollection, measurer: &mut TextMeasurer,
) {
let values: Vec<f64> = if data.series.len() == 1 {
data.series[0].values.clone()
@@ -1015,10 +1330,14 @@ fn render_chart_pie(
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let radius = pw.min(ph) / 2.0 * 0.9;
let radius = pw.min(ph) / 2.0 * 0.65;
let inner_frac = data.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
let inner_r = radius * inner_frac;
let show_labels = data.show_labels;
let label_font = data.label_font_size.unwrap_or(3.0);
let label_color = data.label_color.as_deref().unwrap_or("#333333");
let mut start_angle = -std::f64::consts::FRAC_PI_2;
for (i, val) in values.iter().enumerate() {
@@ -1038,6 +1357,41 @@ fn render_chart_pie(
let path = build_arc_path(cx, cy, radius, inner_r, start_angle, end_angle);
if let Some(p) = path { surface.draw_path(&p); }
// Percentage label inside slice
if show_labels {
let mid_angle = start_angle + sweep / 2.0;
let label_r = if inner_r > 0.0 { (radius + inner_r) / 2.0 } else { radius * 0.65 };
let lx = cx + label_r * mid_angle.cos();
let ly = cy + label_r * mid_angle.sin();
let pct = (val / total * 100.0).round();
let label = format!("{}%", pct);
chart_text_centered(surface, lx, ly, &label, label_font, label_color, fonts, measurer);
}
// Category name label outside slice with leader line
if i < data.categories.len() {
let mid_angle = start_angle + sweep / 2.0;
let line_start_r = radius;
let line_end_r = radius + 3.0;
let text_r = radius + 4.0;
// Leader line
let lx1 = cx + line_start_r * mid_angle.cos();
let ly1 = cy + line_start_r * mid_angle.sin();
let lx2 = cx + line_end_r * mid_angle.cos();
let ly2 = cy + line_end_r * mid_angle.sin();
chart_line_seg(surface, lx1, ly1, lx2, ly2, parse_color("#999999"), 0.5);
// Category text
let tx = cx + text_r * mid_angle.cos();
let ty = cy + text_r * mid_angle.sin();
if mid_angle.cos() >= 0.0 {
chart_text_start(surface, tx, ty, &data.categories[i], 2.5, "#555555", fonts, measurer);
} else {
chart_text_end(surface, tx, ty, &data.categories[i], 2.5, "#555555", fonts, measurer);
}
}
start_angle = end_angle;
}
}

View File

@@ -771,6 +771,13 @@ fn collect_layout(
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_color.clone(),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
},
}
})

View File

@@ -0,0 +1,33 @@
{
"satis": [
{ "ay": "Ocak", "gelir": 15000, "kanal": "Online" },
{ "ay": "Ocak", "gelir": 8000, "kanal": "Magaza" },
{ "ay": "Ocak", "gelir": 3000, "kanal": "Toptan" },
{ "ay": "Subat", "gelir": 18000, "kanal": "Online" },
{ "ay": "Subat", "gelir": 9500, "kanal": "Magaza" },
{ "ay": "Subat", "gelir": 4200, "kanal": "Toptan" },
{ "ay": "Mart", "gelir": 22000, "kanal": "Online" },
{ "ay": "Mart", "gelir": 11000, "kanal": "Magaza" },
{ "ay": "Mart", "gelir": 5100, "kanal": "Toptan" },
{ "ay": "Nisan", "gelir": 19500, "kanal": "Online" },
{ "ay": "Nisan", "gelir": 10200, "kanal": "Magaza" },
{ "ay": "Nisan", "gelir": 4800, "kanal": "Toptan" }
],
"trend": [
{ "hafta": "H1", "ziyaretci": 1200, "kaynak": "Organik" },
{ "hafta": "H1", "ziyaretci": 800, "kaynak": "Reklam" },
{ "hafta": "H2", "ziyaretci": 1500, "kaynak": "Organik" },
{ "hafta": "H2", "ziyaretci": 950, "kaynak": "Reklam" },
{ "hafta": "H3", "ziyaretci": 1350, "kaynak": "Organik" },
{ "hafta": "H3", "ziyaretci": 1100, "kaynak": "Reklam" },
{ "hafta": "H4", "ziyaretci": 1800, "kaynak": "Organik" },
{ "hafta": "H4", "ziyaretci": 1250, "kaynak": "Reklam" }
],
"dagilim": [
{ "kategori": "Elektronik", "oran": 35 },
{ "kategori": "Giyim", "oran": 25 },
{ "kategori": "Gida", "oran": 20 },
{ "kategori": "Kozmetik", "oran": 12 },
{ "kategori": "Diger", "oran": 8 }
]
}

View File

@@ -0,0 +1,131 @@
{
"id": "chart_test",
"name": "Chart Visual Test",
"page": { "width": 210, "height": 297 },
"fonts": ["Noto Sans"],
"root": {
"id": "root",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"direction": "column",
"gap": 8,
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "bar_chart",
"type": "chart",
"position": { "type": "flow" },
"size": {
"width": { "type": "fr", "value": 1 },
"height": { "type": "fixed", "value": 80 }
},
"chartType": "bar",
"dataSource": { "path": "satis" },
"categoryField": "ay",
"valueField": "gelir",
"groupField": "kanal",
"groupMode": "grouped",
"title": {
"text": "Aylik Satis Geliri",
"fontSize": 4.0,
"color": "#1a1a1a"
},
"legend": {
"show": true,
"position": "bottom",
"fontSize": 2.8
},
"labels": {
"show": true,
"fontSize": 2.2,
"color": "#333333"
},
"axis": {
"xLabel": "Aylar",
"yLabel": "Gelir (TL)",
"showGrid": true,
"gridColor": "#E5E7EB"
},
"style": {
"colors": ["#4F46E5", "#10B981", "#F59E0B"],
"backgroundColor": "#FFFFFF",
"barGap": 0.2
}
},
{
"id": "line_chart",
"type": "chart",
"position": { "type": "flow" },
"size": {
"width": { "type": "fr", "value": 1 },
"height": { "type": "fixed", "value": 80 }
},
"chartType": "line",
"dataSource": { "path": "trend" },
"categoryField": "hafta",
"valueField": "ziyaretci",
"groupField": "kaynak",
"title": {
"text": "Haftalik Ziyaretci Trendi",
"fontSize": 4.0,
"color": "#1a1a1a"
},
"legend": {
"show": true,
"position": "bottom",
"fontSize": 2.8
},
"labels": {
"show": false
},
"axis": {
"showGrid": true,
"gridColor": "#E5E7EB"
},
"style": {
"colors": ["#EF4444", "#8B5CF6"],
"backgroundColor": "#FFFFFF",
"lineWidth": 0.5,
"showPoints": true
}
},
{
"id": "pie_chart",
"type": "chart",
"position": { "type": "flow" },
"size": {
"width": { "type": "fr", "value": 1 },
"height": { "type": "fixed", "value": 80 }
},
"chartType": "pie",
"dataSource": { "path": "dagilim" },
"categoryField": "kategori",
"valueField": "oran",
"title": {
"text": "Kategori Dagilimi",
"fontSize": 4.0,
"color": "#1a1a1a"
},
"legend": {
"show": true,
"position": "right",
"fontSize": 2.8
},
"labels": {
"show": true,
"fontSize": 2.5,
"color": "#FFFFFF"
},
"style": {
"colors": ["#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"],
"backgroundColor": "#FFFFFF",
"innerRadius": 0.0
}
}
]
}
}

View File

@@ -0,0 +1,42 @@
{
"company": {
"name": "Teknova Yazilim A.S.",
"city": "Istanbul",
"revenue": 148200
},
"order": {
"code": "ORD-2026-0042"
},
"meta": {
"version": "1.0.0"
},
"products": [
{ "no": 1, "name": "Web Application Development", "qty": 1, "price": 45000, "total": 45000 },
{ "no": 2, "name": "Mobile App Development", "qty": 1, "price": 35000, "total": 35000 },
{ "no": 3, "name": "UI/UX Design Service", "qty": 40, "price": 750, "total": 30000 },
{ "no": 4, "name": "Server Maintenance (Annual)", "qty": 1, "price": 12000, "total": 12000 },
{ "no": 5, "name": "SSL Certificate", "qty": 3, "price": 500, "total": 1500 },
{ "no": 6, "name": "Cloud Hosting Setup", "qty": 1, "price": 8500, "total": 8500 },
{ "no": 7, "name": "Database Optimization", "qty": 2, "price": 6000, "total": 12000 }
],
"distribution": [
{ "category": "Development", "value": 80000 },
{ "category": "Design", "value": 30000 },
{ "category": "Infrastructure", "value": 22000 },
{ "category": "Support", "value": 12000 }
],
"trend": [
{ "month": "Jan", "series": "Revenue", "value": 18000 },
{ "month": "Feb", "series": "Revenue", "value": 22000 },
{ "month": "Mar", "series": "Revenue", "value": 19500 },
{ "month": "Apr", "series": "Revenue", "value": 28000 },
{ "month": "May", "series": "Revenue", "value": 32000 },
{ "month": "Jun", "series": "Revenue", "value": 35000 },
{ "month": "Jan", "series": "Costs", "value": 12000 },
{ "month": "Feb", "series": "Costs", "value": 14000 },
{ "month": "Mar", "series": "Costs", "value": 11000 },
{ "month": "Apr", "series": "Costs", "value": 16000 },
{ "month": "May", "series": "Costs", "value": 18000 },
{ "month": "Jun", "series": "Costs", "value": 20000 }
]
}

View File

@@ -0,0 +1,466 @@
{
"id": "comprehensive_test",
"name": "Comprehensive Element Test",
"page": { "width": 210, "height": 297 },
"fonts": ["Noto Sans", "Noto Sans Mono"],
"root": {
"id": "root",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"direction": "column",
"gap": 4,
"padding": { "top": 12, "right": 12, "bottom": 12, "left": 12 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "title",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 16, "fontWeight": "bold", "color": "#1a1a1a", "align": "center" },
"content": "COMPREHENSIVE ELEMENT TEST"
},
{
"id": "subtitle",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 9, "color": "#888888", "align": "center" },
"content": "All element types in a single document"
},
{
"id": "line_top",
"type": "line",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "strokeColor": "#1e293b", "strokeWidth": 1 }
},
{
"id": "section_text",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "column",
"gap": 2,
"padding": { "top": 2, "right": 4, "bottom": 2, "left": 4 },
"align": "stretch",
"justify": "start",
"style": { "backgroundColor": "#f0f4ff", "borderColor": "#c7d2fe", "borderWidth": 0.5, "borderRadius": 2 },
"children": [
{
"id": "sec1_label",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "fontWeight": "bold", "color": "#4338ca" },
"content": "TEXT ELEMENTS"
},
{
"id": "row_texts",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 4,
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"align": "start",
"justify": "start",
"style": {},
"children": [
{
"id": "bound_text",
"type": "text",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "fontSize": 10, "color": "#333333" },
"content": "Company: ",
"binding": { "path": "company.name" }
},
{
"id": "bound_text2",
"type": "text",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "fontSize": 10, "color": "#333333" },
"content": "City: ",
"binding": { "path": "company.city" }
}
]
},
{
"id": "rich_text_el",
"type": "rich_text",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "fontSize": 9, "color": "#333333" },
"content": [
{ "text": "Rich text: ", "style": { "fontSize": 9, "fontWeight": "bold", "color": "#1e293b" } },
{ "text": "normal ", "style": { "fontSize": 9, "color": "#555555" } },
{ "text": "bold ", "style": { "fontSize": 9, "fontWeight": "bold", "color": "#dc2626" } },
{ "text": "large ", "style": { "fontSize": 12, "color": "#059669" } },
{ "text": "mono", "style": { "fontSize": 9, "fontFamily": "Noto Sans Mono", "color": "#7c3aed" } }
]
},
{
"id": "calc_text",
"type": "calculated_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 9, "color": "#333333" },
"expression": "company.revenue * 0.20",
"format": "currency"
},
{
"id": "row_date_page",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 8,
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"align": "center",
"justify": "start",
"style": {},
"children": [
{
"id": "date_el",
"type": "current_date",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 9, "color": "#666666" },
"format": "DD.MM.YYYY"
},
{
"id": "page_num",
"type": "page_number",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 9, "color": "#666666" },
"format": "Page {current}/{total}"
}
]
}
]
},
{
"id": "line_thin",
"type": "line",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "strokeColor": "#e2e8f0", "strokeWidth": 0.3 }
},
{
"id": "section_shapes",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "column",
"gap": 2,
"padding": { "top": 2, "right": 4, "bottom": 2, "left": 4 },
"align": "stretch",
"justify": "start",
"style": { "backgroundColor": "#fef3c7", "borderColor": "#fbbf24", "borderWidth": 0.5, "borderRadius": 2 },
"children": [
{
"id": "sec2_label",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "fontWeight": "bold", "color": "#92400e" },
"content": "SHAPES, CHECKBOXES & BARCODES"
},
{
"id": "row_shapes",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 4,
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"align": "center",
"justify": "start",
"style": {},
"children": [
{
"id": "shape_rect",
"type": "shape",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } },
"shapeType": "rectangle",
"style": { "backgroundColor": "#3b82f6", "borderColor": "#1d4ed8", "borderWidth": 0.5 }
},
{
"id": "shape_ellipse",
"type": "shape",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } },
"shapeType": "ellipse",
"style": { "backgroundColor": "#ef4444", "borderColor": "#b91c1c", "borderWidth": 0.5 }
},
{
"id": "shape_rounded",
"type": "shape",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 15 }, "height": { "type": "fixed", "value": 10 } },
"shapeType": "rounded_rectangle",
"style": { "backgroundColor": "#10b981", "borderColor": "#047857", "borderWidth": 0.5, "borderRadius": 3 }
},
{
"id": "cb_checked",
"type": "checkbox",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 5 }, "height": { "type": "fixed", "value": 5 } },
"checked": true,
"style": { "size": 5, "checkColor": "#059669", "borderColor": "#333333", "borderWidth": 0.3 }
},
{
"id": "cb_label1",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "color": "#333333" },
"content": "Checked"
},
{
"id": "cb_unchecked",
"type": "checkbox",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 5 }, "height": { "type": "fixed", "value": 5 } },
"checked": false,
"style": { "size": 5, "checkColor": "#000000", "borderColor": "#333333", "borderWidth": 0.3 }
},
{
"id": "cb_label2",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "color": "#333333" },
"content": "Unchecked"
}
]
},
{
"id": "row_barcodes",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 6,
"padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 },
"align": "start",
"justify": "start",
"style": {},
"children": [
{
"id": "barcode_qr",
"type": "barcode",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 20 }, "height": { "type": "fixed", "value": 20 } },
"format": "qr",
"value": "https://dreport.dev",
"style": {}
},
{
"id": "barcode_128",
"type": "barcode",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 40 }, "height": { "type": "fixed", "value": 15 } },
"format": "code128",
"binding": { "path": "order.code" },
"style": { "includeText": true }
},
{
"id": "barcode_ean",
"type": "barcode",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 35 }, "height": { "type": "fixed", "value": 15 } },
"format": "ean13",
"value": "5901234123457",
"style": { "includeText": true }
}
]
}
]
},
{
"id": "section_table",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "column",
"gap": 2,
"padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "sec3_label",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "fontWeight": "bold", "color": "#0f766e" },
"content": "REPEATING TABLE"
},
{
"id": "products_table",
"type": "repeating_table",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"dataSource": { "path": "products" },
"columns": [
{ "id": "col_no", "field": "no", "title": "#", "width": { "type": "fixed", "value": 8 }, "align": "center" },
{ "id": "col_name", "field": "name", "title": "Product", "width": { "type": "fr", "value": 1 }, "align": "left" },
{ "id": "col_qty", "field": "qty", "title": "Qty", "width": { "type": "fixed", "value": 15 }, "align": "right" },
{ "id": "col_price", "field": "price", "title": "Price", "width": { "type": "fixed", "value": 25 }, "align": "right", "format": "currency" },
{ "id": "col_total", "field": "total", "title": "Total", "width": { "type": "fixed", "value": 25 }, "align": "right", "format": "currency" }
],
"style": {
"fontSize": 8,
"headerFontSize": 8,
"headerBg": "#0f766e",
"headerColor": "#ffffff",
"zebraOdd": "#ffffff",
"zebraEven": "#f0fdfa",
"borderColor": "#99f6e4",
"borderWidth": 0.3
}
}
]
},
{
"id": "section_charts",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "column",
"gap": 2,
"padding": { "top": 2, "right": 0, "bottom": 0, "left": 0 },
"align": "stretch",
"justify": "start",
"style": {},
"children": [
{
"id": "sec4_label",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 8, "fontWeight": "bold", "color": "#9333ea" },
"content": "CHARTS"
},
{
"id": "charts_row",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 4,
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"align": "start",
"justify": "start",
"style": {},
"children": [
{
"id": "chart_bar",
"type": "chart",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "fixed", "value": 45 } },
"chartType": "bar",
"dataSource": { "path": "products" },
"categoryField": "name",
"valueField": "total",
"title": { "text": "Revenue by Product", "fontSize": 3, "color": "#1e293b" },
"legend": { "show": false },
"labels": { "show": true, "fontSize": 2, "color": "#333" },
"axis": { "showGrid": true },
"style": { "colors": ["#6366f1", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"] }
},
{
"id": "chart_pie",
"type": "chart",
"position": { "type": "flow" },
"size": { "width": { "type": "fixed", "value": 55 }, "height": { "type": "fixed", "value": 45 } },
"chartType": "pie",
"dataSource": { "path": "distribution" },
"categoryField": "category",
"valueField": "value",
"title": { "text": "Distribution", "fontSize": 3, "color": "#1e293b" },
"legend": { "show": true, "position": "bottom", "fontSize": 2 },
"labels": { "show": true, "fontSize": 2, "color": "#333" },
"style": { "colors": ["#3b82f6", "#ef4444", "#10b981", "#f59e0b"], "innerRadius": 0.4 }
}
]
},
{
"id": "chart_line",
"type": "chart",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "fixed", "value": 40 } },
"chartType": "line",
"dataSource": { "path": "trend" },
"categoryField": "month",
"valueField": "value",
"groupField": "series",
"title": { "text": "Monthly Trend", "fontSize": 3, "color": "#1e293b" },
"legend": { "show": true, "position": "top", "fontSize": 2 },
"labels": { "show": false },
"axis": { "showGrid": true },
"style": { "colors": ["#6366f1", "#ef4444"], "lineWidth": 1.5, "showPoints": true }
}
]
},
{
"id": "line_bottom",
"type": "line",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"style": { "strokeColor": "#1e293b", "strokeWidth": 0.5 }
},
{
"id": "footer_row",
"type": "container",
"position": { "type": "flow" },
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
"direction": "row",
"gap": 0,
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
"align": "center",
"justify": "space-between",
"style": {},
"children": [
{
"id": "footer_left",
"type": "static_text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 7, "color": "#94a3b8" },
"content": "Generated by dreport visual test suite"
},
{
"id": "footer_right",
"type": "text",
"position": { "type": "flow" },
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
"style": { "fontSize": 7, "color": "#94a3b8", "align": "right" },
"content": "Version: ",
"binding": { "path": "meta.version" }
}
]
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ mod visual {
use std::process::Command;
use dreport_core::models::Template;
use dreport_layout::{compute_layout, FontData};
use dreport_layout::{compute_layout, FontData, ResolvedContent};
use dreport_layout::pdf_render::render_pdf;
fn fixtures_dir() -> std::path::PathBuf {
@@ -156,17 +156,15 @@ mod visual {
}
}
#[test]
fn test_visual_snapshot_basic() {
let pdf_bytes =
generate_test_pdf("visual_test_template.json", "visual_test_data.json");
fn run_visual_test(template_file: &str, data_file: &str, test_name: &str) {
let pdf_bytes = generate_test_pdf(template_file, data_file);
assert!(!pdf_bytes.is_empty(), "PDF should not be empty");
let snap_dir = snapshots_dir();
fs::create_dir_all(&snap_dir).unwrap();
let actual_png = snap_dir.join("visual_test_actual.png");
let reference_png = snap_dir.join("visual_test_reference.png");
let actual_png = snap_dir.join(format!("{}_actual.png", test_name));
let reference_png = snap_dir.join(format!("{}_reference.png", test_name));
if !pdf_to_png(&pdf_bytes, &actual_png) {
eprintln!("Skipping visual comparison - pdftoppm not available");
@@ -188,7 +186,8 @@ mod visual {
match compare_images(&actual_png, &reference_png, 0.01) {
Ok(diff) => {
println!(
"Visual test passed: {:.4}% pixels differ",
"Visual test [{}] passed: {:.4}% pixels differ",
test_name,
diff * 100.0
);
let _ = fs::remove_file(&actual_png);
@@ -196,10 +195,99 @@ mod visual {
Err(err) => {
// Keep actual for debugging
panic!(
"Visual regression detected: {}. Actual saved at {:?}",
err, actual_png
"Visual regression [{}]: {}. Actual saved at {:?}",
test_name, err, actual_png
);
}
}
}
/// SVG'yi standalone HTML'e sar — chart'ın HTML render'ını görmek icin
fn generate_chart_svg_html(template_file: &str, data_file: &str, output_path: &Path) {
let template_json = fs::read_to_string(fixtures_dir().join(template_file)).unwrap();
let data_json = fs::read_to_string(fixtures_dir().join(data_file)).unwrap();
let template: Template = serde_json::from_str(&template_json).unwrap();
let data: serde_json::Value = serde_json::from_str(&data_json).unwrap();
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
let mut html = String::from("<!DOCTYPE html><html><head><style>body{margin:20px;font-family:sans-serif;background:#f5f5f5}.chart-box{margin:10px 0;background:white;box-shadow:0 1px 3px rgba(0,0,0,.1)}</style></head><body><h2>Chart SVG Preview (HTML render)</h2>");
for page in &layout.pages {
for el in &page.elements {
if let Some(ResolvedContent::Chart { svg, .. }) = &el.content {
html.push_str(&format!(
"<div class='chart-box' style='width:{}mm;height:{}mm'>{}</div>",
el.width_mm, el.height_mm, svg
));
}
}
}
html.push_str("</body></html>");
fs::write(output_path, html).unwrap();
}
/// Cross-renderer reference PNG output directory
fn cross_renderer_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("frontend/tests/visual/cross-renderer-refs")
}
/// Generates PDF→PNG references for cross-renderer comparison with HTML render.
/// Run explicitly: cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored
#[test]
#[ignore]
fn generate_cross_renderer_refs() {
let fixtures = [
("visual_test_template.json", "visual_test_data.json", "visual_test"),
("chart_test_template.json", "chart_test_data.json", "chart_test"),
("comprehensive_test_template.json", "comprehensive_test_data.json", "comprehensive_test"),
];
let out_dir = cross_renderer_dir();
fs::create_dir_all(&out_dir).unwrap();
for (template_file, data_file, name) in &fixtures {
let pdf_bytes = generate_test_pdf(template_file, data_file);
assert!(!pdf_bytes.is_empty(), "PDF should not be empty for {}", name);
let png_path = out_dir.join(format!("{}.png", name));
if !pdf_to_png(&pdf_bytes, &png_path) {
panic!("pdftoppm failed for {} — install poppler-utils", name);
}
println!("Cross-renderer reference: {:?}", png_path);
}
}
#[test]
fn test_visual_snapshot_basic() {
run_visual_test("visual_test_template.json", "visual_test_data.json", "visual_test");
}
#[test]
fn test_visual_snapshot_charts() {
let pdf_bytes = generate_test_pdf("chart_test_template.json", "chart_test_data.json");
assert!(!pdf_bytes.is_empty(), "Chart PDF should not be empty");
let snap_dir = snapshots_dir();
fs::create_dir_all(&snap_dir).unwrap();
// PDF ciktisini kaydet (inceleme icin)
let pdf_path = snap_dir.join("chart_test.pdf");
fs::write(&pdf_path, &pdf_bytes).unwrap();
println!("Chart PDF saved to {:?}", pdf_path);
// SVG HTML ciktisini kaydet (karsilastirma icin)
let html_path = snap_dir.join("chart_test_svg.html");
generate_chart_svg_html("chart_test_template.json", "chart_test_data.json", &html_path);
println!("Chart SVG HTML saved to {:?}", html_path);
// Visual regression test
run_visual_test("chart_test_template.json", "chart_test_data.json", "chart_test");
}
}