Files
dreport/layout-engine/src/pdf_render.rs
2026-04-03 01:26:54 +03:00

924 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}