mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
924 lines
28 KiB
Rust
924 lines
28 KiB
Rust
//! LayoutResult → PDF bytes (krilla ile).
|
||
//! Sadece native (non-WASM) hedeflerde derlenir.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use krilla::color::rgb;
|
||
use krilla::geom::{PathBuilder, Point, Size, Transform};
|
||
use krilla::num::NormalizedF32;
|
||
use krilla::page::PageSettings;
|
||
use krilla::paint::{Fill, Stroke};
|
||
use krilla::text::{Font as KrillaFont, TextDirection};
|
||
use krilla::Document;
|
||
|
||
use crate::text_measure::TextMeasurer;
|
||
use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
|
||
|
||
/// mm → pt dönüşümü (1mm = 2.83465pt)
|
||
const MM_TO_PT: f32 = 72.0 / 25.4;
|
||
|
||
fn mm(v: f64) -> f32 {
|
||
v 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('#');
|
||
let (r, g, b) = match hex.len() {
|
||
6 => (
|
||
u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
|
||
u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
|
||
u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
|
||
),
|
||
3 => {
|
||
let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0);
|
||
(r * 17, g * 17, b * 17)
|
||
}
|
||
_ => (0, 0, 0),
|
||
};
|
||
rgb::Color::new(r, g, b)
|
||
}
|
||
|
||
fn fill_from_color(color: rgb::Color) -> Fill {
|
||
Fill {
|
||
paint: color.into(),
|
||
opacity: NormalizedF32::ONE,
|
||
rule: Default::default(),
|
||
}
|
||
}
|
||
|
||
/// Font koleksiyonu — family + weight + italic → KrillaFont mapping
|
||
struct FontCollection {
|
||
/// (family_lower, is_bold, is_italic) → KrillaFont
|
||
fonts: HashMap<(String, bool, bool), KrillaFont>,
|
||
/// Fallback font (ilk yüklenen regular)
|
||
default: Option<KrillaFont>,
|
||
}
|
||
|
||
impl FontCollection {
|
||
fn new(font_data: &[FontData]) -> Self {
|
||
let mut fonts = HashMap::new();
|
||
let mut default = None;
|
||
|
||
for fd in font_data {
|
||
let Some(font) = KrillaFont::new(
|
||
krilla::Data::from(fd.data.clone()),
|
||
0,
|
||
) else {
|
||
continue;
|
||
};
|
||
|
||
let family_lower = fd.family.to_lowercase();
|
||
let is_bold = is_font_bold(&fd.data);
|
||
let is_italic = is_font_italic(&fd.data);
|
||
|
||
// Default font: ilk regular (non-bold, non-italic)
|
||
if default.is_none() && !is_bold && !is_italic {
|
||
default = Some(font.clone());
|
||
}
|
||
|
||
fonts.insert((family_lower.clone(), is_bold, is_italic), font);
|
||
}
|
||
|
||
// Hiç regular bulamadıysak ilk font'u default yap
|
||
if default.is_none() {
|
||
if let Some(fd) = font_data.first() {
|
||
default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0);
|
||
}
|
||
}
|
||
|
||
Self { fonts, default }
|
||
}
|
||
|
||
fn get(&self, family: Option<&str>, weight: Option<&str>) -> Option<&KrillaFont> {
|
||
let is_bold = matches!(weight, Some("bold"));
|
||
let family_lower = family.unwrap_or("noto sans").to_lowercase();
|
||
|
||
// Her zaman non-italic font ara (italic desteği henüz yok)
|
||
self.fonts
|
||
.get(&(family_lower.clone(), is_bold, false))
|
||
.or_else(|| self.fonts.get(&(family_lower, false, false)))
|
||
.or(self.default.as_ref())
|
||
}
|
||
}
|
||
|
||
/// TTF OS/2 tablosunun offset'ini bul
|
||
fn find_os2_table(data: &[u8]) -> Option<usize> {
|
||
if data.len() < 12 {
|
||
return None;
|
||
}
|
||
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||
let mut offset = 12;
|
||
for _ in 0..num_tables {
|
||
if offset + 16 > data.len() {
|
||
break;
|
||
}
|
||
let tag = &data[offset..offset + 4];
|
||
if tag == b"OS/2" {
|
||
let table_offset =
|
||
u32::from_be_bytes([data[offset + 8], data[offset + 9], data[offset + 10], data[offset + 11]])
|
||
as usize;
|
||
return Some(table_offset);
|
||
}
|
||
offset += 16;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// TTF dosyasının bold olup olmadığını OS/2 tablosundan kontrol et
|
||
fn is_font_bold(data: &[u8]) -> bool {
|
||
let Some(table_offset) = find_os2_table(data) else {
|
||
return false;
|
||
};
|
||
// usWeightClass is at offset 4 in OS/2 table
|
||
if table_offset + 6 <= data.len() {
|
||
let weight_class = u16::from_be_bytes([data[table_offset + 4], data[table_offset + 5]]);
|
||
return weight_class >= 700;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// TTF dosyasının italic olup olmadığını OS/2 tablosundan kontrol et
|
||
fn is_font_italic(data: &[u8]) -> bool {
|
||
let Some(table_offset) = find_os2_table(data) else {
|
||
return false;
|
||
};
|
||
// fsSelection is at offset 62 in OS/2 table, bit 0 = ITALIC
|
||
if table_offset + 64 <= data.len() {
|
||
let fs_selection = u16::from_be_bytes([data[table_offset + 62], data[table_offset + 63]]);
|
||
return fs_selection & 0x0001 != 0;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// LayoutResult → PDF bytes
|
||
pub fn render_pdf(layout: &LayoutResult, font_data: &[FontData]) -> Result<Vec<u8>, String> {
|
||
let fonts = FontCollection::new(font_data);
|
||
let mut measurer = TextMeasurer::new(font_data);
|
||
let mut doc = Document::new();
|
||
|
||
for page in &layout.pages {
|
||
render_page(&mut doc, page, &fonts, font_data, &mut measurer)?;
|
||
}
|
||
|
||
doc.finish().map_err(|e| format!("PDF oluşturma hatası: {e:?}"))
|
||
}
|
||
|
||
fn render_page(
|
||
doc: &mut Document,
|
||
page: &PageLayout,
|
||
fonts: &FontCollection,
|
||
font_data: &[FontData],
|
||
measurer: &mut TextMeasurer,
|
||
) -> Result<(), String> {
|
||
let w = mm(page.width_mm);
|
||
let h = mm(page.height_mm);
|
||
|
||
let page_settings =
|
||
PageSettings::from_wh(w, h).ok_or_else(|| "Geçersiz sayfa boyutu".to_string())?;
|
||
|
||
let mut pdf_page = doc.start_page_with(page_settings);
|
||
let mut surface = pdf_page.surface();
|
||
|
||
for el in &page.elements {
|
||
render_element(&mut surface, el, fonts, font_data, measurer);
|
||
}
|
||
|
||
surface.finish();
|
||
pdf_page.finish();
|
||
Ok(())
|
||
}
|
||
|
||
fn render_element(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
el: &ElementLayout,
|
||
fonts: &FontCollection,
|
||
font_data: &[FontData],
|
||
measurer: &mut TextMeasurer,
|
||
) {
|
||
let x = mm(el.x_mm);
|
||
let y = mm(el.y_mm);
|
||
let w = mm(el.width_mm);
|
||
let h = mm(el.height_mm);
|
||
|
||
// Container background/border
|
||
if el.element_type == "container" {
|
||
render_container_bg(surface, x, y, w, h, &el.style);
|
||
}
|
||
|
||
// Shape background/border (same visual as container bg but as leaf)
|
||
if el.element_type == "shape" {
|
||
render_shape(surface, x, y, w, h, &el.style, &el.content);
|
||
return;
|
||
}
|
||
|
||
let Some(ref content) = el.content else {
|
||
return;
|
||
};
|
||
|
||
match content {
|
||
ResolvedContent::Text { value } => {
|
||
render_text(surface, x, y, w, h, value, &el.style, fonts, measurer);
|
||
}
|
||
ResolvedContent::Line => {
|
||
render_line(surface, x, y, w, &el.style);
|
||
}
|
||
ResolvedContent::Image { src } => {
|
||
render_image(surface, x, y, w, h, src);
|
||
}
|
||
ResolvedContent::PageNumber { current, total } => {
|
||
let text = format!("{current} / {total}");
|
||
render_text(surface, x, y, w, h, &text, &el.style, fonts, measurer);
|
||
}
|
||
ResolvedContent::Table { .. } => {
|
||
// Tablolar expand edilerek container + text olarak render edilir.
|
||
// Bu branch'e normalde düşmemeli.
|
||
}
|
||
ResolvedContent::Shape { .. } => {
|
||
// Shape zaten yukarıda render_shape() ile çizildi, buraya düşmemeli
|
||
}
|
||
ResolvedContent::Checkbox { checked } => {
|
||
render_checkbox(surface, x, y, w, h, *checked, &el.style);
|
||
}
|
||
ResolvedContent::Barcode { format, value } => {
|
||
render_barcode(surface, x, y, w, h, format, value, &el.style, font_data);
|
||
}
|
||
ResolvedContent::RichText { spans } => {
|
||
render_rich_text(surface, x, y, w, h, spans, &el.style, fonts, measurer);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_shape(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
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;
|
||
}
|
||
|
||
if let Some(ref bg) = style.background_color {
|
||
surface.set_fill(Some(fill_from_color(parse_color(bg))));
|
||
} else {
|
||
surface.set_fill(None);
|
||
}
|
||
|
||
if has_border {
|
||
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
|
||
let border_width = mm(style.border_width.unwrap_or(0.5));
|
||
surface.set_stroke(Some(Stroke {
|
||
paint: border_color.into(),
|
||
width: border_width,
|
||
opacity: NormalizedF32::ONE,
|
||
..Default::default()
|
||
}));
|
||
} else {
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
let shape_type = match content {
|
||
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
|
||
_ => "rectangle",
|
||
};
|
||
|
||
let path = match shape_type {
|
||
"ellipse" => {
|
||
let mut pb = PathBuilder::new();
|
||
let cx = x + w / 2.0;
|
||
let cy = y + h / 2.0;
|
||
let rx = w / 2.0;
|
||
let ry = h / 2.0;
|
||
// Approximate ellipse with 4 cubic bezier curves
|
||
let kx = rx * 0.5522848;
|
||
let ky = ry * 0.5522848;
|
||
pb.move_to(cx, cy - ry);
|
||
pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
|
||
pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
|
||
pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
|
||
pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
|
||
pb.close();
|
||
pb.finish()
|
||
}
|
||
_ => {
|
||
// rectangle / rounded_rectangle
|
||
let mut pb = PathBuilder::new();
|
||
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
|
||
pb.push_rect(rect);
|
||
}
|
||
pb.finish()
|
||
}
|
||
};
|
||
|
||
if let Some(p) = path {
|
||
surface.draw_path(&p);
|
||
}
|
||
|
||
surface.set_fill(None);
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
fn render_checkbox(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
checked: bool,
|
||
style: &ResolvedStyle,
|
||
) {
|
||
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#333333"));
|
||
let border_width = mm(style.border_width.unwrap_or(0.3));
|
||
|
||
// Draw box outline
|
||
surface.set_fill(None);
|
||
surface.set_stroke(Some(Stroke {
|
||
paint: border_color.into(),
|
||
width: border_width,
|
||
opacity: NormalizedF32::ONE,
|
||
..Default::default()
|
||
}));
|
||
|
||
let rect_path = {
|
||
let mut pb = PathBuilder::new();
|
||
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);
|
||
}
|
||
|
||
// 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.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.set_fill(None);
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
fn render_container_bg(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
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;
|
||
}
|
||
|
||
// Fill
|
||
if let Some(ref bg) = style.background_color {
|
||
surface.set_fill(Some(fill_from_color(parse_color(bg))));
|
||
} else {
|
||
surface.set_fill(None);
|
||
}
|
||
|
||
// Stroke
|
||
if has_border {
|
||
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
|
||
let border_width = mm(style.border_width.unwrap_or(0.5));
|
||
surface.set_stroke(Some(Stroke {
|
||
paint: border_color.into(),
|
||
width: border_width,
|
||
opacity: NormalizedF32::ONE,
|
||
..Default::default()
|
||
}));
|
||
} else {
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
let rect_path = {
|
||
let mut pb = PathBuilder::new();
|
||
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
|
||
pb.push_rect(rect);
|
||
}
|
||
pb.finish()
|
||
};
|
||
|
||
if let Some(path) = rect_path {
|
||
surface.draw_path(&path);
|
||
}
|
||
|
||
// Reset
|
||
surface.set_fill(None);
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
fn render_text(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
_h: f32,
|
||
text: &str,
|
||
style: &ResolvedStyle,
|
||
fonts: &FontCollection,
|
||
measurer: &mut TextMeasurer,
|
||
) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let font_size = style.font_size.unwrap_or(11.0) as f32;
|
||
let color = style
|
||
.color
|
||
.as_deref()
|
||
.map(parse_color)
|
||
.unwrap_or(rgb::Color::new(0, 0, 0));
|
||
|
||
let Some(font) = fonts.get(
|
||
style.font_family.as_deref(),
|
||
style.font_weight.as_deref(),
|
||
) else {
|
||
return;
|
||
};
|
||
|
||
surface.set_fill(Some(fill_from_color(color)));
|
||
surface.set_stroke(None);
|
||
|
||
// Text baseline: y + ascent (yaklaşık font_size * 0.8)
|
||
let baseline_y = y + font_size * 0.8;
|
||
|
||
// Hizalama — cosmic-text ile text genişliğini ölçerek gerçek pozisyon hesapla
|
||
let text_x = match style.text_align.as_deref() {
|
||
Some("center") | Some("right") => {
|
||
let (text_width_pt, _) = measurer.measure(
|
||
text,
|
||
style.font_family.as_deref(),
|
||
font_size,
|
||
style.font_weight.as_deref(),
|
||
None,
|
||
);
|
||
|
||
if style.text_align.as_deref() == Some("center") {
|
||
x + (w - text_width_pt) / 2.0
|
||
} else {
|
||
x + w - text_width_pt
|
||
}
|
||
}
|
||
_ => x,
|
||
};
|
||
|
||
surface.draw_text(
|
||
Point::from_xy(text_x, baseline_y),
|
||
font.clone(),
|
||
font_size,
|
||
text,
|
||
false,
|
||
TextDirection::Auto,
|
||
);
|
||
}
|
||
|
||
fn render_rich_text(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
_h: f32,
|
||
spans: &[crate::ResolvedRichSpan],
|
||
style: &ResolvedStyle,
|
||
fonts: &FontCollection,
|
||
measurer: &mut TextMeasurer,
|
||
) {
|
||
if spans.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Varsayılan stil
|
||
let default_font_size = style.font_size.unwrap_or(11.0) as f32;
|
||
let default_color = style.color.as_deref().unwrap_or("#000000");
|
||
let default_weight = style.font_weight.as_deref();
|
||
let default_family = style.font_family.as_deref();
|
||
|
||
// Hizalama için toplam genişliği hesapla
|
||
let total_width = {
|
||
let mut tw = 0.0f32;
|
||
for span in spans {
|
||
let fs = span.font_size.map(|f| f as f32).unwrap_or(default_font_size);
|
||
let fw = span.font_weight.as_deref().or(default_weight);
|
||
let ff = span.font_family.as_deref().or(default_family);
|
||
let (sw, _) = measurer.measure(&span.text, ff, fs, fw, None);
|
||
tw += sw;
|
||
}
|
||
tw
|
||
};
|
||
|
||
let line_start_x = match style.text_align.as_deref() {
|
||
Some("center") => x + (w - total_width) / 2.0,
|
||
Some("right") => x + w - total_width,
|
||
_ => x,
|
||
};
|
||
|
||
// Max font size for baseline
|
||
let max_font_size = spans
|
||
.iter()
|
||
.map(|s| s.font_size.map(|f| f as f32).unwrap_or(default_font_size))
|
||
.fold(0.0f32, f32::max);
|
||
let baseline_y = y + max_font_size * 0.8;
|
||
|
||
let mut cursor_x = line_start_x;
|
||
|
||
for span in spans {
|
||
if span.text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let font_size = span.font_size.map(|f| f as f32).unwrap_or(default_font_size);
|
||
let color_str = span.color.as_deref().unwrap_or(default_color);
|
||
let weight = span.font_weight.as_deref().or(default_weight);
|
||
let family = span.font_family.as_deref().or(default_family);
|
||
|
||
let color = parse_color(color_str);
|
||
|
||
let Some(font) = fonts.get(family, weight) else {
|
||
continue;
|
||
};
|
||
|
||
surface.set_fill(Some(fill_from_color(color)));
|
||
surface.set_stroke(None);
|
||
|
||
// Span'ın baseline'ı — farklı font boyutları için ayarla
|
||
let span_baseline = baseline_y + (max_font_size - font_size) * 0.2;
|
||
|
||
surface.draw_text(
|
||
Point::from_xy(cursor_x, span_baseline),
|
||
font.clone(),
|
||
font_size,
|
||
&span.text,
|
||
false,
|
||
TextDirection::Auto,
|
||
);
|
||
|
||
// Sonraki span'ın x pozisyonunu hesapla
|
||
let (span_width, _) = measurer.measure(&span.text, family, font_size, weight, None);
|
||
cursor_x += span_width;
|
||
}
|
||
}
|
||
|
||
fn render_line(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
style: &ResolvedStyle,
|
||
) {
|
||
let stroke_color = style
|
||
.stroke_color
|
||
.as_deref()
|
||
.map(parse_color)
|
||
.unwrap_or(rgb::Color::new(0, 0, 0));
|
||
let stroke_width = mm(style.stroke_width.unwrap_or(0.5));
|
||
|
||
surface.set_fill(None);
|
||
surface.set_stroke(Some(Stroke {
|
||
paint: stroke_color.into(),
|
||
width: stroke_width,
|
||
opacity: NormalizedF32::ONE,
|
||
..Default::default()
|
||
}));
|
||
|
||
let line_y = y + stroke_width / 2.0;
|
||
let path = {
|
||
let mut pb = PathBuilder::new();
|
||
pb.move_to(x, line_y);
|
||
pb.line_to(x + w, line_y);
|
||
pb.finish()
|
||
};
|
||
|
||
if let Some(p) = path {
|
||
surface.draw_path(&p);
|
||
}
|
||
|
||
surface.set_stroke(None);
|
||
}
|
||
|
||
fn render_image(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
src: &str,
|
||
) {
|
||
if src.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// data:image/png;base64,... veya data:image/jpeg;base64,...
|
||
let Some(base64_part) = src.split(',').nth(1) else {
|
||
eprintln!("[dreport] Image src data URI değil, atlanıyor: {}...", &src[..src.len().min(60)]);
|
||
return;
|
||
};
|
||
|
||
use base64::Engine;
|
||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(base64_part) else {
|
||
eprintln!("[dreport] Image base64 decode hatası");
|
||
return;
|
||
};
|
||
|
||
// Tüm formatları image crate ile decode edip PNG'ye çevir (krilla JPEG desteği sınırlı)
|
||
let png_data = match decode_to_png(&decoded) {
|
||
Some(data) => data,
|
||
None => {
|
||
eprintln!("[dreport] Image decode/re-encode hatası, ham veri deneniyor");
|
||
decoded
|
||
}
|
||
};
|
||
|
||
embed_png(surface, x, y, w, h, &png_data);
|
||
}
|
||
|
||
fn render_barcode(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
format: &str,
|
||
value: &str,
|
||
style: &ResolvedStyle,
|
||
font_data: &[FontData],
|
||
) {
|
||
if value.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// Hedef piksel boyutları (yüksek çözünürlük için 4x, minimum 1px)
|
||
let w_px = ((w * 4.0) as u32).max(1);
|
||
let h_px = ((h * 4.0) as u32).max(1);
|
||
let include_text = style.barcode_include_text.unwrap_or(false);
|
||
|
||
let png_result = crate::barcode_gen::generate_barcode_png(format, value, w_px, h_px, include_text, Some(font_data));
|
||
|
||
match png_result {
|
||
Ok(png_bytes) => {
|
||
embed_png(surface, x, y, w, h, &png_bytes);
|
||
}
|
||
Err(e) => {
|
||
eprintln!("[dreport] Barcode üretim hatası ({format}): {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// image crate ile herhangi bir formattan decode edip PNG bytes'a çevir
|
||
fn decode_to_png(raw: &[u8]) -> Option<Vec<u8>> {
|
||
let img = image::load_from_memory(raw).ok()?;
|
||
let rgba = img.to_rgba8();
|
||
let mut buf = Vec::new();
|
||
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
|
||
image::ImageEncoder::write_image(
|
||
encoder,
|
||
rgba.as_raw(),
|
||
rgba.width(),
|
||
rgba.height(),
|
||
image::ExtendedColorType::Rgba8,
|
||
)
|
||
.ok()?;
|
||
Some(buf)
|
||
}
|
||
|
||
/// PNG bytes'ı PDF'e göm
|
||
fn embed_png(
|
||
surface: &mut krilla::surface::Surface<'_>,
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
data: &[u8],
|
||
) {
|
||
let data_vec: Vec<u8> = data.to_vec();
|
||
let Ok(img) = krilla::image::Image::from_png(data_vec.into(), true) else {
|
||
eprintln!("[dreport] PNG krilla embed hatası");
|
||
return;
|
||
};
|
||
|
||
let Some(size) = Size::from_wh(w, h) else {
|
||
return;
|
||
};
|
||
|
||
surface.push_transform(&Transform::from_translate(x, y));
|
||
surface.draw_image(img, size);
|
||
surface.pop();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{ElementLayout, PageLayout, ResolvedContent, ResolvedStyle};
|
||
|
||
fn test_fonts() -> Vec<FontData> {
|
||
crate::text_measure::load_test_fonts()
|
||
}
|
||
|
||
#[test]
|
||
fn test_simple_pdf() {
|
||
let layout = LayoutResult {
|
||
pages: vec![PageLayout {
|
||
page_index: 0,
|
||
width_mm: 210.0,
|
||
height_mm: 297.0,
|
||
elements: vec![
|
||
ElementLayout {
|
||
id: "title".to_string(),
|
||
x_mm: 15.0,
|
||
y_mm: 15.0,
|
||
width_mm: 180.0,
|
||
height_mm: 20.0,
|
||
element_type: "static_text".to_string(),
|
||
content: Some(ResolvedContent::Text {
|
||
value: "FATURA".to_string(),
|
||
}),
|
||
style: ResolvedStyle {
|
||
font_size: Some(18.0),
|
||
font_weight: Some("bold".to_string()),
|
||
..Default::default()
|
||
},
|
||
children: vec![],
|
||
},
|
||
ElementLayout {
|
||
id: "line1".to_string(),
|
||
x_mm: 15.0,
|
||
y_mm: 38.0,
|
||
width_mm: 180.0,
|
||
height_mm: 0.5,
|
||
element_type: "line".to_string(),
|
||
content: Some(ResolvedContent::Line),
|
||
style: ResolvedStyle {
|
||
stroke_color: Some("#000000".to_string()),
|
||
stroke_width: Some(0.5),
|
||
..Default::default()
|
||
},
|
||
children: vec![],
|
||
},
|
||
ElementLayout {
|
||
id: "body".to_string(),
|
||
x_mm: 15.0,
|
||
y_mm: 42.0,
|
||
width_mm: 180.0,
|
||
height_mm: 14.0,
|
||
element_type: "static_text".to_string(),
|
||
content: Some(ResolvedContent::Text {
|
||
value: "Bu bir test belgesidir.".to_string(),
|
||
}),
|
||
style: ResolvedStyle {
|
||
font_size: Some(11.0),
|
||
..Default::default()
|
||
},
|
||
children: vec![],
|
||
},
|
||
],
|
||
}],
|
||
};
|
||
|
||
let fonts = test_fonts();
|
||
let pdf = render_pdf(&layout, &fonts).expect("PDF oluşturulabilmeli");
|
||
|
||
// PDF magic bytes kontrolü
|
||
assert!(pdf.starts_with(b"%PDF"), "Geçerli PDF çıktısı olmalı");
|
||
assert!(pdf.len() > 100, "PDF boyutu çok küçük: {} bytes", pdf.len());
|
||
|
||
// Debug: dosyaya yaz
|
||
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.parent()
|
||
.unwrap()
|
||
.join("test_output.pdf");
|
||
std::fs::write(&out_path, &pdf).unwrap();
|
||
println!("Test PDF yazıldı: {}", out_path.display());
|
||
}
|
||
|
||
#[test]
|
||
fn test_full_pipeline() {
|
||
use dreport_core::models::*;
|
||
use serde_json::json;
|
||
|
||
let template = Template {
|
||
id: "test".to_string(),
|
||
name: "Test".to_string(),
|
||
page: PageSettings { width: 210.0, height: 297.0 },
|
||
fonts: vec!["Noto Sans".to_string()],
|
||
header: None,
|
||
footer: None,
|
||
root: ContainerElement {
|
||
id: "root".to_string(),
|
||
position: PositionMode::Flow,
|
||
size: SizeConstraint {
|
||
width: SizeValue::Auto,
|
||
height: SizeValue::Auto,
|
||
min_width: None, min_height: None, max_width: None, max_height: None,
|
||
},
|
||
direction: "column".to_string(),
|
||
gap: 5.0,
|
||
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 },
|
||
align: "stretch".to_string(),
|
||
justify: "start".to_string(),
|
||
style: ContainerStyle::default(),
|
||
break_inside: "auto".to_string(),
|
||
children: vec![
|
||
TemplateElement::StaticText(StaticTextElement {
|
||
id: "title".to_string(),
|
||
position: PositionMode::Flow,
|
||
size: SizeConstraint {
|
||
width: SizeValue::Fr { value: 1.0 },
|
||
height: SizeValue::Auto,
|
||
min_width: None, min_height: None, max_width: None, max_height: None,
|
||
},
|
||
style: TextStyle {
|
||
font_size: Some(18.0),
|
||
font_weight: Some("bold".to_string()),
|
||
..Default::default()
|
||
},
|
||
content: "FATURA".to_string(),
|
||
}),
|
||
TemplateElement::Line(LineElement {
|
||
id: "line1".to_string(),
|
||
position: PositionMode::Flow,
|
||
size: SizeConstraint {
|
||
width: SizeValue::Fr { value: 1.0 },
|
||
height: SizeValue::Auto,
|
||
min_width: None, min_height: None, max_width: None, max_height: None,
|
||
},
|
||
style: LineStyle {
|
||
stroke_color: Some("#000000".to_string()),
|
||
stroke_width: Some(0.5),
|
||
},
|
||
}),
|
||
TemplateElement::Text(TextElement {
|
||
id: "firma".to_string(),
|
||
position: PositionMode::Flow,
|
||
size: SizeConstraint {
|
||
width: SizeValue::Fr { value: 1.0 },
|
||
height: SizeValue::Auto,
|
||
min_width: None, min_height: None, max_width: None, max_height: None,
|
||
},
|
||
style: TextStyle {
|
||
font_size: Some(11.0),
|
||
..Default::default()
|
||
},
|
||
content: None,
|
||
binding: dreport_core::models::ScalarBinding {
|
||
path: "firma.unvan".to_string(),
|
||
},
|
||
}),
|
||
],
|
||
},
|
||
};
|
||
|
||
let data = json!({
|
||
"firma": { "unvan": "Acme Teknoloji A.Ş." }
|
||
});
|
||
|
||
let fonts = test_fonts();
|
||
let layout = crate::compute_layout(&template, &data, &fonts);
|
||
let pdf = render_pdf(&layout, &fonts).expect("Full pipeline PDF");
|
||
|
||
assert!(pdf.starts_with(b"%PDF"));
|
||
assert!(pdf.len() > 200);
|
||
|
||
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.parent()
|
||
.unwrap()
|
||
.join("test_output_full.pdf");
|
||
std::fs::write(&out_path, &pdf).unwrap();
|
||
println!("Full pipeline PDF: {}", out_path.display());
|
||
}
|
||
}
|