This commit is contained in:
2026-04-09 00:36:23 +03:00
parent 4fda0e7d98
commit aa27228d08
5 changed files with 459 additions and 570 deletions

View File

@@ -29,14 +29,14 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Title
if let Some(ref title) = cl.title {
let anchor = match title.align.as_str() {
"left" => "start",
"right" => "end",
_ => "middle",
"left" => SvgAnchor::Start,
"right" => SvgAnchor::End,
_ => SvgAnchor::Middle,
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
title.x, title.y, title.font_size, title.color, anchor, escape_xml(&title.text)
title.x, title.y, title.font_size, title.color, anchor.as_str(), escape_xml(&title.text)
)
.unwrap();
}
@@ -56,14 +56,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis && let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0;
let y = height_mm - 2.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(x_label)
)
.unwrap();
svg_text(&mut svg, cl.plot_x + cl.plot_w / 2.0, height_mm - 2.0, 2.8, "#666", SvgAnchor::Middle, x_label);
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
@@ -101,24 +94,8 @@ fn render_bar(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
)
.unwrap();
if bl.show_labels {
if bl.stacked {
if bar.value > 0.0 {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
if bl.show_labels && (!bl.stacked || bar.value > 0.0) {
svg_text(svg, bar.label_x, bar.label_y, bl.label_font, &bl.label_color, SvgAnchor::Middle, &format_value(bar.value));
}
}
@@ -162,12 +139,7 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
if ll.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
pt.x, pt.y - 1.5, ll.label_font, ll.label_color, format_value(pt.value)
)
.unwrap();
svg_text(svg, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
}
}
@@ -239,18 +211,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
.unwrap();
}
// Percentage label inside slice
if pl.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle" dominant-baseline="central">{}%</text>"##,
slice.label_x, slice.label_y, pl.label_font, pl.label_color,
(slice.fraction * 100.0).round()
)
.unwrap();
let pct = format!("{}%", (slice.fraction * 100.0).round());
svg_text_central(svg, slice.label_x, slice.label_y, pl.label_font, &pl.label_color, SvgAnchor::Middle, &pct);
}
// Category name label outside slice with leader line
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
write!(
svg,
@@ -259,18 +224,8 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
slice.leader_end_x, slice.leader_end_y
)
.unwrap();
let anchor = if slice.cat_label_anchor_end {
"end"
} else {
"start"
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
slice.cat_label_x, slice.cat_label_y, anchor, escape_xml(&slice.cat_label_text)
)
.unwrap();
let anchor = if slice.cat_label_anchor_end { SvgAnchor::End } else { SvgAnchor::Start };
svg_text_central(svg, slice.cat_label_x, slice.cat_label_y, 2.5, "#555", anchor, &slice.cat_label_text);
}
}
}
@@ -292,15 +247,7 @@ fn render_legend(
item.swatch_x, item.swatch_y, color
)
.unwrap();
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
item.text_x,
item.text_y,
legend.font_size,
escape_xml(&item.name)
)
.unwrap();
svg_text(svg, item.text_x, item.text_y, legend.font_size, "#666", SvgAnchor::Start, &item.name);
}
}
@@ -310,12 +257,7 @@ fn render_legend(
fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
for tick in &y_axis.ticks {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.3" fill="#666" text-anchor="end">{}</text>"##,
y_axis.axis_x - 1.5, tick.y + 0.8, tick.label
)
.unwrap();
svg_text(svg, y_axis.axis_x - 1.5, tick.y + 0.8, 2.3, "#666", SvgAnchor::End, &tick.label);
if y_axis.show_grid {
write!(
@@ -340,6 +282,7 @@ fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout)
let angle = x_labels.rotate_angle;
for label in &x_labels.labels {
if angle > 0.0 {
// Döndürülmüş etiket — transform gerektiğinden helper kullanamıyoruz
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-{:.1},{:.2},{:.2})">{}</text>"##,
@@ -347,12 +290,7 @@ fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout)
)
.unwrap();
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
label.x, label.y, escape_xml(&label.text)
)
.unwrap();
svg_text(svg, label.x, label.y, 2.5, "#666", SvgAnchor::Middle, &label.text);
}
}
}
@@ -364,6 +302,61 @@ fn escape_xml(s: &str) -> String {
.replace('"', "&quot;")
}
/// SVG text hizalama modu
enum SvgAnchor {
Start,
Middle,
End,
}
impl SvgAnchor {
fn as_str(&self) -> &str {
match self {
SvgAnchor::Start => "start",
SvgAnchor::Middle => "middle",
SvgAnchor::End => "end",
}
}
}
/// Tekrarlayan SVG text element yazımını soyutlar.
fn svg_text(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
/// SVG text with dominant-baseline="central" (pie labels vb.)
fn svg_text_central(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}" dominant-baseline="central">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -292,6 +292,58 @@ impl From<&dreport_core::models::CheckboxStyle> for ResolvedStyle {
}
}
impl From<&data_resolve::ResolvedChartData> for ChartRenderData {
fn from(cd: &data_resolve::ResolvedChartData) -> Self {
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| {
chart_layout::DEFAULT_COLORS[i % chart_layout::DEFAULT_COLORS.len()]
.to_string()
})
})
.collect();
Self {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd
.series
.iter()
.map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)),
inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_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()),
}
}
}
/// Ana layout hesaplama fonksiyonu.
/// Template + data + font verileri alır, her element için pozisyon döner.
pub fn compute_layout(

View File

@@ -21,11 +21,6 @@ fn mm(v: f64) -> f32 {
v as f32 * MM_TO_PT
}
/// f64 mm degerini f32 pt'ye cevir (chart render icin)
fn pt(mm_val: f64) -> f32 {
mm_val as f32 * MM_TO_PT
}
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
fn parse_color(hex: &str) -> rgb::Color {
let hex = hex.trim_start_matches('#');
@@ -46,6 +41,18 @@ fn parse_color(hex: &str) -> rgb::Color {
rgb::Color::new(r, g, b)
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
}
}
// ---------------------------------------------------------------------------
// Path builders
// ---------------------------------------------------------------------------
/// Rounded rectangle path oluştur. radius 0 ise düz dikdörtgen.
fn build_rect_path(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
@@ -92,11 +99,132 @@ fn build_ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<krilla::geom::Pa
pb.finish()
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
/// Merkez + radius'tan daire path'i oluştur (build_ellipse_path'in kısa hali)
fn build_circle_path(cx: f32, cy: f32, r: f32) -> Option<krilla::geom::Path> {
build_ellipse_path(cx - r, cy - r, r * 2.0, r * 2.0)
}
// ---------------------------------------------------------------------------
// SurfaceExt — krilla surface üzerinde tekrar eden draw kalıplarını soyutlar
// ---------------------------------------------------------------------------
trait SurfaceExt {
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color);
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32);
fn draw_filled_stroked(
&mut self,
path: &krilla::geom::Path,
fill: Option<rgb::Color>,
stroke_color: rgb::Color,
stroke_width: f32,
);
}
impl SurfaceExt for krilla::surface::Surface<'_> {
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color) {
self.set_fill(Some(fill_from_color(color)));
self.set_stroke(None);
self.draw_path(path);
self.set_fill(None);
}
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32) {
self.set_fill(None);
self.set_stroke(Some(Stroke {
paint: color.into(),
width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
self.draw_path(path);
self.set_stroke(None);
}
fn draw_filled_stroked(
&mut self,
path: &krilla::geom::Path,
fill: Option<rgb::Color>,
stroke_color: rgb::Color,
stroke_width: f32,
) {
self.set_fill(fill.map(fill_from_color));
self.set_stroke(Some(Stroke {
paint: stroke_color.into(),
width: stroke_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
self.draw_path(path);
self.set_fill(None);
self.set_stroke(None);
}
}
// ---------------------------------------------------------------------------
// draw_box — container ve shape'in ortak fill+border çizim mantığı
// ---------------------------------------------------------------------------
/// Kutu şekli: dikdörtgen, yuvarlatılmış dikdörtgen veya elips.
enum BoxShape {
Rect { radius: f32 },
Ellipse,
}
/// Arka plan + border'ı tek seferde çizer (CSS border-box modeli).
/// Container ve shape render'larının ortak kodu.
fn draw_box(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
bg_color: Option<&str>,
border_color: Option<&str>,
border_width: Option<f64>,
shape: BoxShape,
) {
let has_bg = bg_color.is_some();
let has_border = border_color.is_some() && border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let build_path = |bx: f32, by: f32, bw: f32, bh: f32, shape: &BoxShape| -> Option<krilla::geom::Path> {
match shape {
BoxShape::Ellipse => build_ellipse_path(bx, by, bw, bh),
BoxShape::Rect { radius } => build_rect_path(bx, by, bw, bh, *radius),
}
};
if has_border {
let bw = mm(border_width.unwrap_or(0.5));
let bc = parse_color(border_color.unwrap_or("#000000"));
let inset = bw / 2.0;
// Border durumunda radius'u inset kadar küçült
let inset_shape = match shape {
BoxShape::Ellipse => BoxShape::Ellipse,
BoxShape::Rect { radius } => BoxShape::Rect {
radius: (radius - inset).max(0.0),
},
};
let path = build_path(x + inset, y + inset, w - bw, h - bw, &inset_shape);
if let Some(p) = path {
surface.draw_filled_stroked(
&p,
bg_color.map(parse_color),
bc,
bw,
);
}
} else {
let fill = parse_color(bg_color.unwrap_or("#ffffff"));
let path = build_path(x, y, w, h, &shape);
if let Some(p) = path {
surface.draw_filled(&p, fill);
}
}
}
@@ -329,80 +457,29 @@ fn render_shape(
style: &ResolvedStyle,
content: &Option<ResolvedContent>,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let shape_type = match content {
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
_ => "rectangle",
};
let rect_radius = |s: &ResolvedStyle| -> f32 {
if shape_type == "rounded_rectangle" {
s.border_radius.map(mm).unwrap_or(mm(3.0))
} else {
s.border_radius.map(mm).unwrap_or(0.0)
}
let shape = match shape_type {
"ellipse" => BoxShape::Ellipse,
"rounded_rectangle" => BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(mm(3.0)),
},
_ => BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(0.0),
},
};
if has_border {
let border_width = mm(style.border_width.unwrap_or(0.5));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// Fill + stroke tek path ile — anti-aliasing uyumu
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
let path = match shape_type {
"ellipse" => {
build_ellipse_path(x + inset, y + inset, w - border_width, h - border_width)
}
_ => {
let radius = rect_radius(style);
build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
)
}
};
if let Some(p) = path {
surface.draw_path(&p);
}
} else {
// Sadece fill, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
let path = match shape_type {
"ellipse" => build_ellipse_path(x, y, w, h),
_ => build_rect_path(x, y, w, h, rect_radius(style)),
};
if let Some(p) = path {
surface.draw_path(&p);
}
}
surface.set_fill(None);
surface.set_stroke(None);
draw_box(
surface,
x, y, w, h,
style.background_color.as_deref(),
style.border_color.as_deref(),
style.border_width,
shape,
);
}
fn render_checkbox(
@@ -418,54 +495,24 @@ fn render_checkbox(
let border_width = mm(style.border_width.unwrap_or(0.3));
let inset = border_width / 2.0;
// Draw box outline (inset for CSS border-box match)
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
if let Some(p) = build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
0.0,
) {
surface.draw_path(&p);
if let Some(p) = build_rect_path(x + inset, y + inset, w - border_width, h - border_width, 0.0) {
surface.draw_stroked(&p, border_color, border_width);
}
// Draw checkmark if checked
if checked {
let check_color = parse_color(style.color.as_deref().unwrap_or("#000000"));
let stroke_w = w.min(h) * 0.12;
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: check_color.into(),
width: stroke_w,
opacity: NormalizedF32::ONE,
..Default::default()
}));
// Checkmark: two lines forming a "✓"
let check_path = {
let mut pb = PathBuilder::new();
let mx = w * 0.2;
let my = h * 0.5;
pb.move_to(x + mx, y + my);
pb.move_to(x + w * 0.2, y + h * 0.5);
pb.line_to(x + w * 0.4, y + h * 0.75);
pb.line_to(x + w * 0.8, y + h * 0.25);
pb.finish()
};
if let Some(p) = check_path {
surface.draw_path(&p);
surface.draw_stroked(&p, check_color, stroke_w);
}
}
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_container_bg(
@@ -476,55 +523,16 @@ fn render_container_bg(
h: f32,
style: &ResolvedStyle,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let radius = style.border_radius.map(mm).unwrap_or(0.0);
if has_border {
let border_width = mm(style.border_width.unwrap_or(0.5));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// CSS border-box: stroke path'i border_width/2 içeri çek.
// Tek draw_path ile hem fill hem stroke çizerek anti-aliasing uyumunu sağla.
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
if let Some(path) = build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
) {
surface.draw_path(&path);
}
} else {
// Sadece background, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
if let Some(path) = build_rect_path(x, y, w, h, radius) {
surface.draw_path(&path);
}
}
surface.set_fill(None);
surface.set_stroke(None);
draw_box(
surface,
x, y, w, h,
style.background_color.as_deref(),
style.border_color.as_deref(),
style.border_width,
BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(0.0),
},
);
}
#[allow(clippy::too_many_arguments)]
@@ -707,32 +715,16 @@ fn render_line(
h: f32,
style: &ResolvedStyle,
) {
let stroke_color = style
let color = style
.stroke_color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
// Çizgiyi filled rectangle olarak çiz — CSS borderTop ile aynı davranış.
// Stroke kullanmak sub-pixel anti-aliasing farkları yaratır.
surface.set_fill(Some(fill_from_color(stroke_color)));
surface.set_stroke(None);
let rect_path = {
let mut pb = PathBuilder::new();
// Eleman yüksekliği layout engine tarafından stroke_width olarak hesaplandı.
// Tüm eleman alanını dolduran ince dikdörtgen çiz.
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(p) = rect_path {
surface.draw_path(&p);
if let Some(path) = build_rect_path(x, y, w, h, 0.0) {
surface.draw_filled(&path, color);
}
surface.set_fill(None);
}
#[derive(Debug, PartialEq)]
@@ -935,14 +927,14 @@ fn render_chart(
if let Some(f) = font {
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let fs_pt = pt(title.font_size);
let fs_pt = mm(title.font_size);
let (tw, _) = measurer.measure(&title.text, None, fs_pt, Some("bold"), None);
let tx = match title.align.as_str() {
"left" => pt(title.x),
"right" => pt(title.x) - tw,
_ => pt(title.x) - tw / 2.0,
"left" => mm(title.x),
"right" => mm(title.x) - tw,
_ => mm(title.x) - tw / 2.0,
};
let ty = pt(title.y);
let ty = mm(title.y);
surface.draw_text(
Point::from_xy(tx, ty),
f.clone(),
@@ -966,26 +958,28 @@ fn render_chart(
if bl.stacked {
if bar.value > 0.0 {
let label = format_value(bar.value);
chart_text_centered(
chart_text(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
} else {
let label = format_value(bar.value);
chart_text_centered(
chart_text(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1015,7 +1009,7 @@ fn render_chart(
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: color.into(),
width: pt(ll.line_width),
width: mm(ll.line_width),
opacity: NormalizedF32::ONE,
..Default::default()
}));
@@ -1023,9 +1017,9 @@ fn render_chart(
let mut pb = PathBuilder::new();
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(pt(*lx), pt(*ly));
pb.move_to(mm(*lx), mm(*ly));
} else {
pb.line_to(pt(*lx), pt(*ly));
pb.line_to(mm(*lx), mm(*ly));
}
}
pb.finish()
@@ -1037,24 +1031,8 @@ fn render_chart(
// Points
if ll.show_points {
for (lx, ly) in &points {
let r = pt(0.8);
let cx = pt(*lx);
let cy = pt(*ly);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let circle = {
let mut pb = PathBuilder::new();
let k = r * 0.5522848;
pb.move_to(cx, cy - r);
pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
pb.close();
pb.finish()
};
if let Some(p) = circle {
surface.draw_path(&p);
if let Some(circle) = build_circle_path(mm(*lx), mm(*ly), mm(0.8)) {
surface.draw_filled(&circle, color);
}
}
}
@@ -1063,13 +1041,14 @@ fn render_chart(
if ll.show_labels {
for lp in &series_layout.points {
let label = format_value(lp.value);
chart_text_centered(
chart_text(
surface,
lp.x,
lp.y - 1.5,
&label,
ll.label_font,
&ll.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1114,13 +1093,14 @@ fn render_chart(
if pl.show_labels {
let pct = (slice.fraction * 100.0).round();
let label = format!("{}%", pct);
chart_text_centered(
chart_text(
surface,
slice.label_x,
slice.label_y,
&label,
pl.label_font,
&pl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1136,29 +1116,22 @@ fn render_chart(
parse_color("#999999"),
0.5,
);
if slice.cat_label_anchor_end {
chart_text_end(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
let align = if slice.cat_label_anchor_end {
ChartTextAlign::End
} else {
chart_text_start(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
}
ChartTextAlign::Start
};
chart_text(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
align,
fonts,
measurer,
);
}
}
}
@@ -1177,13 +1150,14 @@ fn render_chart(
legend.swatch_size,
color,
);
chart_text_start(
chart_text(
surface,
item.text_x,
item.text_y,
&item.name,
legend.font_size,
"#666666",
ChartTextAlign::Start,
fonts,
measurer,
);
@@ -1196,14 +1170,14 @@ fn render_chart(
if let Some(ref x_label) = data.x_label {
let lx = cl.plot_x + cl.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);
chart_text(surface, lx, ly, x_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
}
if let Some(ref y_label) = data.y_label {
let lx = base_x_mm + 3.0;
let ly = cl.plot_y + cl.plot_h / 2.0;
surface.push_transform(&Transform::from_translate(pt(lx), pt(ly)));
surface.push_transform(&Transform::from_translate(mm(lx), mm(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);
chart_text(surface, 0.0, 0.0, y_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
surface.pop();
surface.pop();
}
@@ -1219,7 +1193,7 @@ fn chart_rect(
rh: f64,
color: rgb::Color,
) {
let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh));
let (rx, ry, rw, rh) = (mm(rx), mm(ry), mm(rw), mm(rh));
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let path = {
@@ -1243,7 +1217,7 @@ fn chart_line_seg(
color: rgb::Color,
width: f32,
) {
let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2));
let (x1, y1, x2, y2) = (mm(x1), mm(y1), mm(x2), mm(y2));
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: color.into(),
@@ -1262,93 +1236,52 @@ fn chart_line_seg(
}
}
/// Chart icin metin ciz — tek satirlik, centered
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
#[allow(clippy::too_many_arguments)]
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, 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 metin hizalama modu
enum ChartTextAlign {
Start,
Center,
End,
}
/// Chart icin metin ciz — end-aligned (sag hizali)
/// Chart için tek satır metin çiz (mm cinsinden koordinatlar, pt'ye çevrilir)
#[allow(clippy::too_many_arguments)]
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, 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)
#[allow(clippy::too_many_arguments)]
fn chart_text_start(
fn chart_text(
surface: &mut krilla::surface::Surface<'_>,
x_mm: f64,
cy_mm: f64,
y_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
align: ChartTextAlign,
fonts: &FontCollection,
_measurer: &mut TextMeasurer,
measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None, None);
let Some(f) = font else {
let Some(font) = fonts.get(None, None, None) else {
return;
};
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let fs = mm(font_size_mm);
let px = mm(x_mm);
let py = mm(y_mm);
let draw_x = match align {
ChartTextAlign::Start => px,
ChartTextAlign::Center => {
let (tw, _) = measurer.measure(text, None, fs, None, None);
px - tw / 2.0
}
ChartTextAlign::End => {
let (tw, _) = measurer.measure(text, None, fs, None, None);
px - tw
}
};
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,
Point::from_xy(draw_x, py),
font.clone(),
fs,
text,
false,
TextDirection::Auto,
@@ -1363,13 +1296,14 @@ fn render_chart_y_axis(
measurer: &mut TextMeasurer,
) {
for tick in &y_axis.ticks {
chart_text_end(
chart_text(
surface,
y_axis.axis_x - 1.5,
tick.y + 0.8,
&tick.label,
2.3,
"#666666",
ChartTextAlign::End,
fonts,
measurer,
);
@@ -1409,31 +1343,33 @@ fn render_chart_x_labels(
let angle = x_labels.rotate_angle;
for label in &x_labels.labels {
if angle > 0.0 {
surface.push_transform(&Transform::from_translate(pt(label.x), pt(label.y)));
surface.push_transform(&Transform::from_translate(mm(label.x), mm(label.y)));
let angle_rad = (angle as f32).to_radians();
let c = angle_rad.cos();
let s = angle_rad.sin();
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
chart_text_end(
chart_text(
surface,
0.0,
0.0,
&label.text,
2.2,
"#666666",
ChartTextAlign::End,
fonts,
measurer,
);
surface.pop();
surface.pop();
} else {
chart_text_centered(
chart_text(
surface,
label.x,
label.y,
&label.text,
2.5,
"#666666",
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1452,19 +1388,19 @@ fn build_arc_path(
) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
let sx = pt(cx + radius * start.cos());
let sy = pt(cy + radius * start.sin());
let sx = mm(cx + radius * start.cos());
let sy = mm(cy + radius * start.sin());
if inner_r > 0.0 {
pb.move_to(sx, sy);
approximate_arc(&mut pb, cx, cy, radius, start, end);
let ix = pt(cx + inner_r * end.cos());
let iy = pt(cy + inner_r * end.sin());
let ix = mm(cx + inner_r * end.cos());
let iy = mm(cy + inner_r * end.sin());
pb.line_to(ix, iy);
approximate_arc(&mut pb, cx, cy, inner_r, end, start);
pb.close();
} else {
pb.move_to(pt(cx), pt(cy));
pb.move_to(mm(cx), mm(cy));
pb.line_to(sx, sy);
approximate_arc(&mut pb, cx, cy, radius, start, end);
pb.close();
@@ -1496,7 +1432,7 @@ fn approximate_arc(pb: &mut PathBuilder, cx: f64, cy: f64, r: f64, start: f64, e
let c2x = p2x + k * r * a2.sin();
let c2y = p2y - k * r * a2.cos();
pb.cubic_to(pt(c1x), pt(c1y), pt(c2x), pt(c2y), pt(p2x), pt(p2y));
pb.cubic_to(mm(c1x), mm(c1y), mm(c2x), mm(c2y), mm(p2x), mm(p2y));
}
}
@@ -1792,7 +1728,7 @@ mod tests {
#[test]
fn test_pt_conversion() {
let result = pt(25.4);
let result = mm(25.4);
assert!((result - 72.0).abs() < 0.01);
}

View File

@@ -279,6 +279,30 @@ fn build_container(
Ok(node)
}
/// Leaf node oluştur ve node_map'e kaydet (tekrarlayan boilerplate'i ortadan kaldırır).
fn register_leaf(
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
style: Style,
id: &str,
element_type: &str,
content: Option<ResolvedContent>,
resolved_style: ResolvedStyle,
) -> Result<NodeId, LayoutError> {
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: id.to_string(),
element_type: element_type.to_string(),
content,
style: resolved_style,
children_ids: vec![],
},
);
Ok(node)
}
/// Herhangi bir element tipini taffy node'a çevir
#[allow(clippy::too_many_arguments)]
fn build_element(
@@ -354,78 +378,45 @@ fn build_element(
),
TemplateElement::Line(e) => {
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
// Line: genişlik parent'tan, yükseklik stroke width
let mut leaf_style = style;
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
if matches!(e.base.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
style.size.height = Dimension::length(mm_to_pt(stroke_w));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::Line),
style: {
let mut s: ResolvedStyle = (&e.style).into();
s.stroke_width = Some(stroke_w);
s
},
children_ids: vec![],
},
);
Ok(node)
let mut rs: ResolvedStyle = (&e.style).into();
rs.stroke_width = Some(stroke_w);
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Line),
rs,
)
}
TemplateElement::Image(e) => {
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let src = resolved.images.get(&e.base.id).cloned().unwrap_or_default();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::Image { src }),
style: (&e.style).into(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Image { src }),
(&e.style).into(),
)
}
TemplateElement::Barcode(e) => {
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let value = resolved.barcodes.get(&e.base.id).cloned().unwrap_or_default();
// Barcode leaf'e minimum boyut ver (MeasureFunc yok, Auto=0 olur)
let is_qr = e.format == "qr";
let default_h = if is_qr { 20.0 } else { 15.0 }; // mm
let default_w = if is_qr { 20.0 } else { 40.0 }; // mm
if matches!(e.base.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(default_h));
style.min_size.height = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 15.0 }));
}
if matches!(e.base.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(default_w));
style.min_size.width = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 40.0 }));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::Barcode {
format: e.format.clone(),
value,
}),
style: (&e.style).into(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Barcode { format: e.format.clone(), value }),
(&e.style).into(),
)
}
TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (cache ile)
@@ -459,52 +450,33 @@ fn build_element(
}
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::Shape {
shape_type: e.shape_type.clone(),
}),
style: (&e.style).into(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Shape { shape_type: e.shape_type.clone() }),
(&e.style).into(),
)
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved
let checked = resolved
.texts
.get(&e.base.id)
.map(|s| s.as_str())
.unwrap_or("false");
let checked = checked_str == "true";
.map(|s| s == "true")
.unwrap_or(false);
let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
// Auto size → square based on style.size
let mut leaf_style = style;
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
if matches!(e.base.size.width, SizeValue::Auto) {
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
style.size.width = Dimension::length(mm_to_pt(box_size_mm));
}
if matches!(e.base.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::Checkbox { checked }),
style: (&e.style).into(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Checkbox { checked }),
(&e.style).into(),
)
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.base.id).cloned().unwrap_or_default();
@@ -563,28 +535,20 @@ fn build_element(
}
TemplateElement::Chart(e) => {
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
// Default minimum boyut — Auto ise chart cok kucuk olmasin
if matches!(e.base.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(80.0));
}
if matches!(e.base.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(60.0));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: None, // SVG collect_layout'ta uretilecek
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
None, // SVG collect_layout'ta üretilecek
ResolvedStyle::default(),
)
}
TemplateElement::PageBreak(e) => {
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
let style = Style {
size: Size {
width: Dimension::auto(),
@@ -592,18 +556,12 @@ fn build_element(
},
..Default::default()
};
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: None,
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
None,
ResolvedStyle::default(),
)
}
}
}
@@ -763,62 +721,12 @@ fn collect_layout(
let w_mm = pt_to_mm(layout.size.width);
let h_mm = pt_to_mm(layout.size.height);
// Chart elementleri icin SVG uret (boyutlar artik belli)
// Chart elementleri için SVG üret (boyutlar artık belli)
let content = if info.element_type == "chart" {
resolved.charts.get(&info.element_id).map(|cd| {
use crate::chart_layout::DEFAULT_COLORS;
use crate::{ChartRenderData, ChartSeriesData};
// Renk paleti olustur
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
})
.collect();
ResolvedContent::Chart {
svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
chart_data: Box::new(ChartRenderData {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd
.series
.iter()
.map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(
cd.group_mode,
Some(dreport_core::models::GroupMode::Stacked)
),
inner_radius: cd.style.inner_radius,
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()),
}),
chart_data: Box::new(crate::ChartRenderData::from(cd)),
}
})
} else {

File diff suppressed because one or more lines are too long