add elements

This commit is contained in:
2026-04-03 01:26:54 +03:00
parent f0a1835fa2
commit 7684a2a871
29 changed files with 3600 additions and 177 deletions

View File

@@ -2,6 +2,72 @@ use dreport_core::models::*;
use serde_json::Value;
use std::collections::HashMap;
/// Şu anki tarihi verilen format string'ine göre formatla.
/// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss
/// WASM'da js_sys::Date, native'de SystemTime kullanır.
fn format_current_date(fmt: &str) -> String {
let (year, month, day, hour, minute, second) = current_datetime_parts();
fmt.replace("YYYY", &format!("{:04}", year))
.replace("MM", &format!("{:02}", month))
.replace("DD", &format!("{:02}", day))
.replace("HH", &format!("{:02}", hour))
.replace("mm", &format!("{:02}", minute))
.replace("ss", &format!("{:02}", second))
}
#[cfg(target_arch = "wasm32")]
fn current_datetime_parts() -> (i32, u32, u32, u32, u32, u32) {
let d = js_sys::Date::new_0();
(
d.get_full_year() as i32,
d.get_month() as u32 + 1, // JS months are 0-based
d.get_date() as u32,
d.get_hours() as u32,
d.get_minutes() as u32,
d.get_seconds() as u32,
)
}
#[cfg(not(target_arch = "wasm32"))]
fn current_datetime_parts() -> (i32, u32, u32, u32, u32, u32) {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Simple UTC date calculation (no timezone dependency)
let days = (secs / 86400) as i64;
let time_of_day = secs % 86400;
let hour = (time_of_day / 3600) as u32;
let minute = ((time_of_day % 3600) / 60) as u32;
let second = (time_of_day % 60) as u32;
// Days since 1970-01-01 → year/month/day (civil calendar)
// Algorithm from Howard Hinnant's chrono-compatible date library
let z = days + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d, hour, minute, second)
}
/// Çözümlenmiş rich text span'ı
#[derive(Debug, Clone)]
pub struct ResolvedRichSpan {
pub text: String,
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
}
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
/// Table ve barcode gibi özel tipler de burada çözülür.
#[derive(Debug, Clone)]
@@ -14,6 +80,10 @@ pub struct ResolvedData {
pub barcodes: HashMap<String, String>,
/// element_id → çözümlenmiş image src
pub images: HashMap<String, String>,
/// page_number element_id → format string (sayfa bölme sonrası çözülecek)
pub page_number_formats: HashMap<String, String>,
/// element_id → çözümlenmiş rich text span listesi
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
}
#[derive(Debug, Clone)]
@@ -51,8 +121,16 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
tables: HashMap::new(),
barcodes: HashMap::new(),
images: HashMap::new(),
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
};
if let Some(ref header) = template.header {
resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved);
}
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved);
if let Some(ref footer) = template.footer {
resolve_element(&TemplateElement::Container(footer.clone()), data, &mut resolved);
}
resolved
}
@@ -70,8 +148,10 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
resolved.texts.insert(e.id.clone(), text);
}
TemplateElement::PageNumber(e) => {
// Sayfa numarası layout sonrasında çözülecek, placeholder koy
let fmt = e.format.as_deref().unwrap_or("{current} / {total}");
// Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
let fmt = e.format.as_deref().unwrap_or("{current} / {total}").to_string();
resolved.page_number_formats.insert(e.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback)
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1"));
}
TemplateElement::Barcode(e) => {
@@ -116,7 +196,59 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
resolve_element(child, data, resolved);
}
}
TemplateElement::CurrentDate(e) => {
let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY");
let text = format_current_date(fmt);
resolved.texts.insert(e.id.clone(), text);
}
TemplateElement::Checkbox(e) => {
let checked = if let Some(binding) = &e.binding {
let val = resolve_path(data, &binding.path);
match val {
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
Value::String(s) => s == "true" || s == "1",
_ => false,
}
} else {
e.checked.unwrap_or(false)
};
// Store as "true"/"false" string in texts map
resolved.texts.insert(e.id.clone(), checked.to_string());
}
TemplateElement::CalculatedText(e) => {
let result = crate::expr_eval::evaluate_expression(&e.expression, data);
let formatted = crate::expr_eval::apply_format(&result, e.format.as_deref());
resolved.texts.insert(e.id.clone(), formatted);
}
TemplateElement::RichText(e) => {
let spans: Vec<ResolvedRichSpan> = e
.content
.iter()
.map(|span| {
let text = if let Some(ref binding) = span.binding {
let bound = value_to_string(resolve_path(data, &binding.path));
match &span.text {
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound),
_ => bound,
}
} else {
span.text.clone().unwrap_or_default()
};
ResolvedRichSpan {
text,
font_size: span.style.font_size.or(e.style.font_size),
font_weight: span.style.font_weight.clone().or(e.style.font_weight.clone()),
font_family: span.style.font_family.clone().or(e.style.font_family.clone()),
color: span.style.color.clone().or(e.style.color.clone()),
}
})
.collect();
resolved.rich_texts.insert(e.id.clone(), spans);
}
TemplateElement::Line(_) => {}
TemplateElement::Shape(_) => {}
TemplateElement::PageBreak(_) => {}
}
}
@@ -192,6 +324,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -202,6 +336,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::Text(TextElement {
id: "el_name".to_string(),
@@ -233,6 +368,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -243,6 +380,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::Text(TextElement {
id: "el_no".to_string(),
@@ -274,6 +412,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -284,6 +424,7 @@ mod tests {
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(),
@@ -307,6 +448,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -317,6 +460,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
@@ -342,6 +486,7 @@ mod tests {
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}),
],
},
@@ -368,6 +513,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -378,6 +525,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
@@ -395,6 +543,7 @@ mod tests {
},
],
style: TableStyle::default(),
repeat_header: Some(true),
}),
],
},
@@ -413,6 +562,8 @@ mod tests {
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -423,6 +574,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::Text(TextElement {
id: "el_missing".to_string(),

View File

@@ -0,0 +1,510 @@
use serde_json::Value;
/// Expression evaluator for calculated_text elements.
/// This is a safe recursive descent parser — NOT an arbitrary code executor.
/// It only supports arithmetic, string operations, comparisons, and data path lookups.
///
/// Supported syntax:
/// - Path lookup: `firma.unvan`, `toplamlar.kdv`
/// - Arithmetic: `+`, `-`, `*`, `/`
/// - String concatenation: `+` when operand is string
/// - String literals: `"..."` or `'...'`
/// - Number literals: `42`, `3.14`
/// - Comparison: `>`, `<`, `>=`, `<=`, `==`, `!=`
/// - Ternary: `expr ? "a" : "b"`
/// - Parentheses: `(a + b) * c`
pub fn evaluate_expression(expr: &str, data: &Value) -> String {
let tokens = tokenize(expr);
if tokens.is_empty() {
return String::new();
}
let mut parser = Parser {
tokens: &tokens,
pos: 0,
data,
};
match parser.parse_ternary() {
ExprValue::Num(n) => format_number(n),
ExprValue::Str(s) => s,
ExprValue::Bool(b) => b.to_string(),
ExprValue::Null => String::new(),
}
}
fn format_number(n: f64) -> String {
if n == n.floor() && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
/// Format result with given format type
pub fn apply_format(value: &str, format: Option<&str>) -> String {
match format {
Some("currency") => format_currency(value),
Some("percentage") => format_percentage(value),
Some("number") => format_number_str(value),
_ => value.to_string(),
}
}
fn format_currency(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
let abs = n.abs();
let integer = abs.floor() as i64;
let frac = ((abs - abs.floor()) * 100.0).round() as i64;
let int_str = format_with_thousands(integer);
let sign = if n < 0.0 { "-" } else { "" };
format!("{}{},{:02}", sign, int_str, frac)
} else {
value.to_string()
}
}
fn format_percentage(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
format!("%{:.2}", n)
} else {
value.to_string()
}
}
fn format_number_str(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
if n == n.floor() && n.abs() < 1e15 {
format_with_thousands(n.abs() as i64)
} else {
format!("{:.2}", n)
}
} else {
value.to_string()
}
}
fn format_with_thousands(n: i64) -> String {
let s = n.to_string();
let len = s.len();
if len <= 3 {
return s;
}
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push('.');
}
result.push(ch);
}
result
}
// --- Tokenizer ---
#[derive(Debug, Clone, PartialEq)]
enum Token {
Num(f64),
Str(String),
Ident(String),
Plus,
Minus,
Star,
Slash,
LParen,
RParen,
Gt,
Lt,
Gte,
Lte,
Eq,
Neq,
Question,
Colon,
}
fn tokenize(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
match chars[i] {
' ' | '\t' | '\n' | '\r' => i += 1,
'+' => { tokens.push(Token::Plus); i += 1; }
'-' => {
// Negative number: after operator or at start
let is_unary = tokens.is_empty()
|| matches!(tokens.last(), Some(
Token::Plus | Token::Minus | Token::Star | Token::Slash
| Token::LParen | Token::Question | Token::Colon
| Token::Gt | Token::Lt | Token::Gte | Token::Lte
| Token::Eq | Token::Neq
));
if is_unary && i + 1 < len && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.') {
let start = i;
i += 1;
while i < len && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
let num_str: String = chars[start..i].iter().collect();
if let Ok(n) = num_str.parse::<f64>() {
tokens.push(Token::Num(n));
}
} else {
tokens.push(Token::Minus);
i += 1;
}
}
'*' => { tokens.push(Token::Star); i += 1; }
'/' => { tokens.push(Token::Slash); i += 1; }
'(' => { tokens.push(Token::LParen); i += 1; }
')' => { tokens.push(Token::RParen); i += 1; }
'?' => { tokens.push(Token::Question); i += 1; }
':' => { tokens.push(Token::Colon); i += 1; }
'>' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Gte); i += 2;
} else {
tokens.push(Token::Gt); i += 1;
}
}
'<' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Lte); i += 2;
} else {
tokens.push(Token::Lt); i += 1;
}
}
'=' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Eq); i += 2;
} else {
i += 1;
}
}
'!' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Neq); i += 2;
} else {
i += 1;
}
}
'"' | '\'' => {
let quote = chars[i];
i += 1;
let start = i;
while i < len && chars[i] != quote {
i += 1;
}
let s: String = chars[start..i].iter().collect();
tokens.push(Token::Str(s));
if i < len { i += 1; }
}
c if c.is_ascii_digit() || (c == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) => {
let start = i;
while i < len && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
let num_str: String = chars[start..i].iter().collect();
if let Ok(n) = num_str.parse::<f64>() {
tokens.push(Token::Num(n));
}
}
c if c.is_alphanumeric() || c == '_' => {
let start = i;
while i < len && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.') {
i += 1;
}
// Trim trailing dots
while i > start && chars[i - 1] == '.' {
i -= 1;
}
let ident: String = chars[start..i].iter().collect();
match ident.as_str() {
"true" => tokens.push(Token::Num(1.0)),
"false" => tokens.push(Token::Num(0.0)),
_ => tokens.push(Token::Ident(ident)),
}
}
_ => i += 1,
}
}
tokens
}
// --- Parser (recursive descent) ---
#[derive(Debug, Clone)]
enum ExprValue {
Num(f64),
Str(String),
Bool(bool),
Null,
}
impl ExprValue {
fn to_num(&self) -> f64 {
match self {
ExprValue::Num(n) => *n,
ExprValue::Str(s) => s.parse().unwrap_or(0.0),
ExprValue::Bool(b) => if *b { 1.0 } else { 0.0 },
ExprValue::Null => 0.0,
}
}
fn to_str(&self) -> String {
match self {
ExprValue::Num(n) => format_number(*n),
ExprValue::Str(s) => s.clone(),
ExprValue::Bool(b) => b.to_string(),
ExprValue::Null => String::new(),
}
}
fn is_truthy(&self) -> bool {
match self {
ExprValue::Num(n) => *n != 0.0,
ExprValue::Str(s) => !s.is_empty(),
ExprValue::Bool(b) => *b,
ExprValue::Null => false,
}
}
fn is_string(&self) -> bool {
matches!(self, ExprValue::Str(_))
}
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
data: &'a Value,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<&Token> {
let tok = self.tokens.get(self.pos);
self.pos += 1;
tok
}
fn parse_ternary(&mut self) -> ExprValue {
let cond = self.parse_comparison();
if self.peek() == Some(&Token::Question) {
self.advance();
let then_val = self.parse_ternary();
if self.peek() == Some(&Token::Colon) {
self.advance();
}
let else_val = self.parse_ternary();
if cond.is_truthy() { then_val } else { else_val }
} else {
cond
}
}
fn parse_comparison(&mut self) -> ExprValue {
let left = self.parse_additive();
match self.peek() {
Some(Token::Gt) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() > r.to_num()) }
Some(Token::Lt) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() < r.to_num()) }
Some(Token::Gte) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() >= r.to_num()) }
Some(Token::Lte) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() <= r.to_num()) }
Some(Token::Eq) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_str() == r.to_str()) }
Some(Token::Neq) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_str() != r.to_str()) }
_ => left,
}
}
fn parse_additive(&mut self) -> ExprValue {
let mut left = self.parse_multiplicative();
loop {
match self.peek() {
Some(Token::Plus) => {
self.advance();
let right = self.parse_multiplicative();
if left.is_string() || right.is_string() {
left = ExprValue::Str(format!("{}{}", left.to_str(), right.to_str()));
} else {
left = ExprValue::Num(left.to_num() + right.to_num());
}
}
Some(Token::Minus) => {
self.advance();
let right = self.parse_multiplicative();
left = ExprValue::Num(left.to_num() - right.to_num());
}
_ => break,
}
}
left
}
fn parse_multiplicative(&mut self) -> ExprValue {
let mut left = self.parse_primary();
loop {
match self.peek() {
Some(Token::Star) => {
self.advance();
let right = self.parse_primary();
left = ExprValue::Num(left.to_num() * right.to_num());
}
Some(Token::Slash) => {
self.advance();
let right = self.parse_primary();
let r = right.to_num();
left = ExprValue::Num(if r != 0.0 { left.to_num() / r } else { 0.0 });
}
_ => break,
}
}
left
}
fn parse_primary(&mut self) -> ExprValue {
match self.advance().cloned() {
Some(Token::Num(n)) => ExprValue::Num(n),
Some(Token::Str(s)) => ExprValue::Str(s),
Some(Token::Ident(path)) => {
let val = resolve_path(self.data, &path);
json_to_expr(val)
}
Some(Token::LParen) => {
let val = self.parse_ternary();
if self.peek() == Some(&Token::RParen) {
self.advance();
}
val
}
Some(Token::Minus) => {
let val = self.parse_primary();
ExprValue::Num(-val.to_num())
}
_ => ExprValue::Null,
}
}
}
fn resolve_path<'a>(data: &'a Value, path: &str) -> &'a Value {
let mut current = data;
for key in path.split('.') {
current = match current {
Value::Object(map) => map.get(key).unwrap_or(&Value::Null),
_ => &Value::Null,
};
}
current
}
fn json_to_expr(v: &Value) -> ExprValue {
match v {
Value::Number(n) => ExprValue::Num(n.as_f64().unwrap_or(0.0)),
Value::String(s) => ExprValue::Str(s.clone()),
Value::Bool(b) => ExprValue::Bool(*b),
Value::Null => ExprValue::Null,
_ => ExprValue::Str(v.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_simple_path() {
let data = json!({"firma": {"unvan": "Acme A.Ş."}});
assert_eq!(evaluate_expression("firma.unvan", &data), "Acme A.Ş.");
}
#[test]
fn test_arithmetic() {
let data = json!({"toplamlar": {"araToplam": 16000, "kdv": 2880}});
assert_eq!(evaluate_expression("toplamlar.araToplam + toplamlar.kdv", &data), "18880");
}
#[test]
fn test_multiplication() {
let data = json!({"toplamlar": {"araToplam": 16000}});
assert_eq!(evaluate_expression("toplamlar.araToplam * 0.20", &data), "3200");
}
#[test]
fn test_string_concat() {
let data = json!({"fatura": {"no": "FTR-001"}});
assert_eq!(evaluate_expression("\"Fatura No: \" + fatura.no", &data), "Fatura No: FTR-001");
}
#[test]
fn test_ternary() {
let data = json!({"fatura": {"tutar": 5000}});
assert_eq!(evaluate_expression("fatura.tutar > 0 ? \"Borclu\" : \"Alacakli\"", &data), "Borclu");
}
#[test]
fn test_ternary_false() {
let data = json!({"fatura": {"tutar": 0}});
assert_eq!(evaluate_expression("fatura.tutar > 0 ? \"Borclu\" : \"Alacakli\"", &data), "Alacakli");
}
#[test]
fn test_parentheses() {
let data = json!({"a": 2, "b": 3, "c": 4});
assert_eq!(evaluate_expression("(a + b) * c", &data), "20");
}
#[test]
fn test_number_literal() {
let data = json!({});
assert_eq!(evaluate_expression("42", &data), "42");
assert_eq!(evaluate_expression("3.14", &data), "3.14");
}
#[test]
fn test_division_by_zero() {
let data = json!({});
assert_eq!(evaluate_expression("10 / 0", &data), "0");
}
#[test]
fn test_missing_path() {
let data = json!({});
assert_eq!(evaluate_expression("missing.path", &data), "");
}
#[test]
fn test_comparison_eq() {
let data = json!({"status": "paid"});
assert_eq!(evaluate_expression("status == \"paid\" ? \"Odendi\" : \"Odenmedi\"", &data), "Odendi");
}
#[test]
fn test_format_currency() {
assert_eq!(apply_format("18880", Some("currency")), "18.880,00 ₺");
assert_eq!(apply_format("1000.5", Some("currency")), "1.000,50 ₺");
}
#[test]
fn test_format_percentage() {
assert_eq!(apply_format("20", Some("percentage")), "%20.00");
}
#[test]
fn test_negative_result() {
let data = json!({"a": 10, "b": 20});
assert_eq!(evaluate_expression("a - b", &data), "-10");
}
#[test]
fn test_empty_expression() {
let data = json!({});
assert_eq!(evaluate_expression("", &data), "");
}
}

View File

@@ -3,6 +3,8 @@ pub mod text_measure;
pub mod data_resolve;
pub mod table_layout;
pub mod tree;
pub mod page_break;
pub mod expr_eval;
#[cfg(target_arch = "wasm32")]
pub mod wasm_api;
@@ -56,6 +58,12 @@ pub enum ResolvedContent {
Barcode { format: String, value: String },
#[serde(rename = "page_number")]
PageNumber { current: usize, total: usize },
#[serde(rename = "shape")]
Shape { shape_type: String },
#[serde(rename = "checkbox")]
Checkbox { checked: bool },
#[serde(rename = "rich_text")]
RichText { spans: Vec<ResolvedRichSpan> },
#[serde(rename = "table")]
Table {
headers: Vec<TableHeaderCell>,
@@ -64,6 +72,16 @@ pub enum ResolvedContent {
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedRichSpan {
pub text: String,
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableHeaderCell {
pub text: String,

View File

@@ -0,0 +1,995 @@
use std::collections::{HashMap, HashSet};
use crate::{ElementLayout, PageLayout, ResolvedContent};
/// Sayfa bölme girdi yapısı
pub struct PageSplitInput {
/// Body elemanları (sınırsız yükseklikte hesaplanmış, mutlak mm koordinatları)
pub body_elements: Vec<ElementLayout>,
/// Sayfa yüksekliği (mm)
pub page_height_mm: f64,
/// Header yüksekliği (mm) — body'nin başlangıç offset'i
pub header_height_mm: f64,
/// Footer yüksekliği (mm)
pub footer_height_mm: f64,
/// Header elemanları (klonlanacak, her sayfada tekrar)
pub header_elements: Vec<ElementLayout>,
/// Footer elemanları (klonlanacak, her sayfada tekrar)
pub footer_elements: Vec<ElementLayout>,
/// Sayfa genişliği (mm)
pub page_width_mm: f64,
/// Container break modları: element_id → "auto" | "avoid"
pub break_modes: HashMap<String, String>,
/// page_number format string'leri: element_id → format
pub page_number_formats: HashMap<String, String>,
/// Root container'ın üst padding'i (mm) — sayfa 2+ için body offset
pub root_padding_top_mm: f64,
}
/// Body elemanlarını sayfalara böl, header/footer ekle, page number'ları çöz.
pub fn split_into_pages(input: PageSplitInput) -> Vec<PageLayout> {
let content_height = input.page_height_mm - input.header_height_mm - input.footer_height_mm;
if content_height <= 0.0 {
// Header + footer sayfaya sığmıyor — tek sayfa döndür
return vec![assemble_page(
0,
&input.body_elements,
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
0.0,
input.root_padding_top_mm,
)];
}
// Parent lookup: element_id → parent_id (children alanından)
let parent_map = build_parent_map(&input.body_elements);
// "avoid" grupları: container_id → (top_mm, bottom_mm, tüm descendant id'leri)
let avoid_groups = build_avoid_groups(&input.body_elements, &input.break_modes, &parent_map);
// Tablo yapısı tespiti: table_id → header element id'leri
let table_info = detect_table_structure(&input.body_elements);
// Elemanları sayfalara böl
let page_slices = split_elements(
&input.body_elements,
content_height,
&avoid_groups,
&parent_map,
&table_info,
);
let total_pages = page_slices.len().max(1);
let mut pages: Vec<PageLayout> = Vec::with_capacity(total_pages);
for (page_idx, slice) in page_slices.iter().enumerate() {
let page = assemble_page(
page_idx,
&slice.elements,
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
slice.y_offset,
input.root_padding_top_mm,
);
pages.push(page);
}
// Boş sayfa koruması
if pages.is_empty() {
pages.push(assemble_page(
0,
&[],
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
0.0,
input.root_padding_top_mm,
));
}
// Page number çözümleme
let total = pages.len();
for (page_idx, page) in pages.iter_mut().enumerate() {
resolve_page_numbers(&mut page.elements, page_idx + 1, total, &input.page_number_formats);
}
pages
}
/// Bir avoid grubunun bilgisi
struct AvoidGroup {
top_mm: f64,
bottom_mm: f64,
element_ids: HashSet<String>,
}
/// Tablo yapısı bilgisi
struct TableInfo {
/// table_id → header satırının eleman id'leri
_header_element_ids: Vec<String>,
/// table_id → header satırındaki elemanların klonları
header_elements: Vec<ElementLayout>,
/// Header yüksekliği (mm)
header_height_mm: f64,
}
/// Sayfa dilimi
struct PageSlice {
elements: Vec<ElementLayout>,
y_offset: f64, // Bu sayfanın strip'teki başlangıç y koordinatı
}
fn build_parent_map(elements: &[ElementLayout]) -> HashMap<String, String> {
let mut map = HashMap::new();
for el in elements {
for child_id in &el.children {
map.insert(child_id.clone(), el.id.clone());
}
}
map
}
fn build_avoid_groups(
elements: &[ElementLayout],
break_modes: &HashMap<String, String>,
_parent_map: &HashMap<String, String>,
) -> Vec<AvoidGroup> {
// Hangi container'lar avoid?
let avoid_ids: HashSet<&String> = break_modes
.iter()
.filter(|(_, mode)| mode.as_str() == "avoid")
.map(|(id, _)| id)
.collect();
if avoid_ids.is_empty() {
return vec![];
}
let element_map: HashMap<&str, &ElementLayout> =
elements.iter().map(|e| (e.id.as_str(), e)).collect();
let mut groups = Vec::new();
for avoid_id in &avoid_ids {
if let Some(container) = element_map.get(avoid_id.as_str()) {
// Bu container'ın tüm descendant'larını bul
let mut descendant_ids = HashSet::new();
descendant_ids.insert(container.id.clone());
collect_descendants(container.id.as_str(), elements, &mut descendant_ids);
// Grubun top/bottom'ını hesapla
let mut top = container.y_mm;
let mut bottom = container.y_mm + container.height_mm;
for el in elements {
if descendant_ids.contains(&el.id) {
top = top.min(el.y_mm);
bottom = bottom.max(el.y_mm + el.height_mm);
}
}
groups.push(AvoidGroup {
top_mm: top,
bottom_mm: bottom,
element_ids: descendant_ids,
});
}
}
groups
}
fn collect_descendants(
parent_id: &str,
elements: &[ElementLayout],
result: &mut HashSet<String>,
) {
// children alanından recursive olarak topla
for el in elements {
if el.id == parent_id {
for child_id in &el.children {
result.insert(child_id.clone());
collect_descendants(child_id, elements, result);
}
break;
}
}
}
/// Bir elemanın en yakın avoid ancestor'ı var mı?
fn find_avoid_group<'a>(
element_id: &str,
avoid_groups: &'a [AvoidGroup],
) -> Option<&'a AvoidGroup> {
avoid_groups
.iter()
.find(|g| g.element_ids.contains(element_id))
}
fn detect_table_structure(elements: &[ElementLayout]) -> HashMap<String, TableInfo> {
// Tablo yapısını ID pattern'inden tespit et:
// {table_id}_header → header satırı container
// {table_id}_hdr_{N} → header hücreleri
// {table_id}_row_{N} → veri satırı container
// {table_id}_r{N}c{M} → veri hücreleri
let mut tables: HashMap<String, TableInfo> = HashMap::new();
// Önce header container'ları bul
for el in elements {
if el.id.ends_with("_header") && el.element_type == "container" {
let table_id = el.id.trim_end_matches("_header").to_string();
// Bu table_id ile başlayan row'lar var mı kontrol et
let has_rows = elements
.iter()
.any(|e| e.id.starts_with(&format!("{}_row_", table_id)));
if has_rows {
// Header elemanlarını topla (header container + children)
let mut header_ids = vec![el.id.clone()];
for child_id in &el.children {
header_ids.push(child_id.clone());
}
let header_elements: Vec<ElementLayout> = elements
.iter()
.filter(|e| header_ids.contains(&e.id))
.cloned()
.collect();
let header_height = el.height_mm;
tables.insert(
table_id,
TableInfo {
_header_element_ids: header_ids,
header_elements,
header_height_mm: header_height,
},
);
}
}
}
tables
}
/// Hangi tablo'ya ait bir satır elemanı mı?
fn detect_table_row(element_id: &str) -> Option<(String, usize)> {
// Pattern: {table_id}_row_{N}
if let Some(pos) = element_id.rfind("_row_") {
let table_id = element_id[..pos].to_string();
let row_str = &element_id[pos + 5..];
if let Ok(row_idx) = row_str.parse::<usize>() {
return Some((table_id, row_idx));
}
}
None
}
fn split_elements(
elements: &[ElementLayout],
content_height: f64,
avoid_groups: &[AvoidGroup],
_parent_map: &HashMap<String, String>,
table_info: &HashMap<String, TableInfo>,
) -> Vec<PageSlice> {
if elements.is_empty() {
return vec![PageSlice {
elements: vec![],
y_offset: 0.0,
}];
}
let mut pages: Vec<PageSlice> = vec![PageSlice {
elements: Vec::new(),
y_offset: 0.0,
}];
// Yapısal container'ları tespit et: çocukları arasında container olan container'lar.
// Bu container'lar sayfa sınırında bölünebilir (çocukları bireysel sayfa bölmesi yapar).
// Aksine, çocukları sadece leaf olan container'lar (ör. tablo satırı) atomik kalır.
let element_type_map: HashMap<&str, &str> = elements
.iter()
.map(|e| (e.id.as_str(), e.element_type.as_str()))
.collect();
let splittable_containers: HashSet<&str> = elements
.iter()
.filter(|e| e.element_type == "container")
.filter(|e| {
e.children
.iter()
.any(|child_id| element_type_map.get(child_id.as_str()) == Some(&"container"))
})
.map(|e| e.id.as_str())
.collect();
let mut page_top = 0.0; // Mevcut sayfanın strip'teki başlangıç y'si
let mut processed: HashSet<String> = HashSet::new();
// Hangi tablo'ların header'ı bu sayfada zaten var?
let mut table_header_on_page: HashSet<String> = HashSet::new();
for el in elements {
if processed.contains(&el.id) {
continue;
}
// page_break elemanı → mevcut sayfaya ekle, sonra yeni sayfa zorla
if el.element_type == "page_break" {
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
page_top = el.y_mm + el.height_mm;
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
continue;
}
let el_top = el.y_mm;
let el_bottom = el.y_mm + el.height_mm;
let relative_bottom = el_bottom - page_top;
// Avoid group kontrolü
if let Some(group) = find_avoid_group(&el.id, avoid_groups) {
let group_relative_bottom = group.bottom_mm - page_top;
let group_height = group.bottom_mm - group.top_mm;
if group_relative_bottom > content_height && group_height <= content_height {
// Grup mevcut sayfaya sığmıyor ama tek sayfaya sığar → yeni sayfa
page_top = group.top_mm;
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
}
// Grup sayfadan büyükse → normal akışa devam (bölünemez ama mecbur)
}
// Eleman mevcut sayfaya sığıyor mu?
if relative_bottom > content_height && el_top > page_top {
// Yapısal container (çocukları container olan) → bölünebilir.
// Komple yeni sayfaya atmak yerine mevcut sayfada bırak,
// çocuk elemanlar bireysel olarak sayfa bölmesini halledecek.
if splittable_containers.contains(el.id.as_str()) {
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
continue;
}
// Sığmıyor → yeni sayfa
// Tablo satırı mı? Header tekrarı gerekebilir
let mut table_header_to_add: Option<(String, Vec<ElementLayout>, f64)> = None;
if let Some((table_id, _row_idx)) = detect_table_row(&el.id) {
if let Some(info) = table_info.get(&table_id) {
// Yeni sayfada bu tablonun header'ını tekrarla
table_header_to_add =
Some((table_id.clone(), info.header_elements.clone(), info.header_height_mm));
}
}
page_top = el_top;
// Tablo header tekrarı varsa, header yüksekliği kadar offset
if let Some((ref table_id, ref header_els, header_h)) = table_header_to_add {
// Header'ı yeni sayfanın başına koy (offset'li)
page_top = el_top - header_h;
let new_page_idx = pages.len();
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
// Header elemanlarını klonla ve y pozisyonlarını yeni sayfaya taşı.
// Orijinal header elemanları tablonun ilk konumundaki y değerlerine sahip.
// Yeni sayfada page_top'tan başlamaları gerekir.
let orig_header_y = header_els
.iter()
.map(|e| e.y_mm)
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(page_top);
let y_shift = page_top - orig_header_y;
for hdr_el in header_els {
let mut cloned = hdr_el.clone();
cloned.y_mm += y_shift;
cloned.id = format!("{}_p{}", hdr_el.id, new_page_idx);
pages.last_mut().unwrap().elements.push(cloned);
}
table_header_on_page.insert(table_id.clone());
} else {
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
}
}
// Elemanı mevcut sayfaya ekle
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
}
pages
}
fn assemble_page(
page_index: usize,
body_elements: &[ElementLayout],
header_elements: &[ElementLayout],
footer_elements: &[ElementLayout],
page_width_mm: f64,
page_height_mm: f64,
header_height_mm: f64,
footer_height_mm: f64,
body_y_offset: f64,
root_padding_top_mm: f64,
) -> PageLayout {
let mut elements = Vec::new();
// Header elemanları (y = orijinal y, sayfa başında)
for el in header_elements {
let mut cloned = el.clone();
if page_index > 0 {
// Sonraki sayfalarda ID'yi unique yap
cloned.id = format!("{}_p{}", el.id, page_index);
}
elements.push(cloned);
}
// Body elemanları (y offset'li — strip y'den sayfa-relative y'ye)
// Sayfa 2+ için root padding tekrar eklenir (root container sadece sayfa 1'de var)
let extra_top = if page_index > 0 { root_padding_top_mm } else { 0.0 };
for el in body_elements {
let mut adjusted = el.clone();
adjusted.y_mm = el.y_mm - body_y_offset + header_height_mm + extra_top;
elements.push(adjusted);
}
// Footer elemanları (sayfanın altında)
let footer_y_offset = page_height_mm - footer_height_mm;
for el in footer_elements {
let mut cloned = el.clone();
// Footer elemanlarının y'si footer container'ın başlangıcına relative
// Footer'ın orijinal y'si 0'dan başlıyor (ayrı hesaplanıyor)
// Sayfa içi pozisyon: footer_y_offset + orijinal y
cloned.y_mm = el.y_mm + footer_y_offset;
if page_index > 0 {
cloned.id = format!("{}_p{}", el.id, page_index);
}
elements.push(cloned);
}
PageLayout {
page_index,
width_mm: page_width_mm,
height_mm: page_height_mm,
elements,
}
}
fn resolve_page_numbers(
elements: &mut [ElementLayout],
current_page: usize,
total_pages: usize,
formats: &HashMap<String, String>,
) {
for el in elements.iter_mut() {
if el.element_type != "page_number" {
continue;
}
// ID'den orijinal format ID'sini çıkar (sayfa klonları _p{N} ile biter)
let original_id = if let Some(pos) = el.id.rfind("_p") {
let suffix = &el.id[pos + 2..];
if suffix.parse::<usize>().is_ok() {
&el.id[..pos]
} else {
&el.id
}
} else {
&el.id
};
let fmt = formats
.get(original_id)
.map(|s| s.as_str())
.unwrap_or("{current} / {total}");
let text = fmt
.replace("{current}", &current_page.to_string())
.replace("{total}", &total_pages.to_string());
el.content = Some(ResolvedContent::PageNumber {
current: current_page,
total: total_pages,
});
// Ayrıca text content'i de güncelle (LayoutRenderer text olarak render ediyor)
// PageNumber render'da content.type === "text" kontrolü var, text olarak da ekle
el.content = Some(ResolvedContent::Text { value: text });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ResolvedStyle;
fn make_element(id: &str, y: f64, height: f64, element_type: &str) -> ElementLayout {
ElementLayout {
id: id.to_string(),
x_mm: 0.0,
y_mm: y,
width_mm: 180.0,
height_mm: height,
element_type: element_type.to_string(),
content: None,
style: ResolvedStyle::default(),
children: vec![],
}
}
#[test]
fn test_single_page_no_split() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
make_element("el2", 50.0, 50.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].elements.len(), 2);
}
#[test]
fn test_auto_page_break() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].elements.len(), 1);
assert_eq!(pages[0].elements[0].id, "el1");
assert_eq!(pages[1].elements.len(), 1);
assert_eq!(pages[1].elements[0].id, "el2");
}
#[test]
fn test_manual_page_break() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
make_element("pb1", 50.0, 0.0, "page_break"),
make_element("el2", 50.0, 50.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].elements.len(), 2); // el1 + pb1
assert_eq!(pages[1].elements.len(), 1); // el2
}
#[test]
fn test_header_footer_on_all_pages() {
let header = vec![make_element("hdr", 0.0, 15.0, "text")];
let footer = vec![make_element("ftr", 0.0, 10.0, "text")];
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: header,
footer_elements: footer,
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
// Her sayfada header + body + footer var
// Sayfa 1: hdr + el1 + ftr = 3
assert!(pages[0].elements.iter().any(|e| e.id == "hdr"));
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[0].elements.iter().any(|e| e.id == "ftr"));
// Sayfa 2: hdr_p1 + el2 + ftr_p1 = 3
assert!(pages[1].elements.iter().any(|e| e.id == "hdr_p1"));
assert!(pages[1].elements.iter().any(|e| e.id == "el2"));
assert!(pages[1].elements.iter().any(|e| e.id == "ftr_p1"));
}
#[test]
fn test_page_numbers_resolved() {
let mut formats = HashMap::new();
formats.insert("pn".to_string(), "{current} / {total}".to_string());
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: vec![{
let mut el = make_element("pn", 0.0, 10.0, "page_number");
el.content = Some(ResolvedContent::Text {
value: "1 / 1".to_string(),
});
el
}],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: formats,
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
// Sayfa 1: pn → "1 / 2"
let pn1 = pages[0].elements.iter().find(|e| e.id == "pn").unwrap();
if let Some(ResolvedContent::Text { value }) = &pn1.content {
assert_eq!(value, "1 / 2");
} else {
panic!("page_number content should be text");
}
// Sayfa 2: pn_p1 → "2 / 2"
let pn2 = pages[1]
.elements
.iter()
.find(|e| e.id == "pn_p1")
.unwrap();
if let Some(ResolvedContent::Text { value }) = &pn2.content {
assert_eq!(value, "2 / 2");
} else {
panic!("page_number content should be text");
}
}
#[test]
fn test_table_splits_across_pages_not_jumps() {
// Tablo wrapper container sayfa yüksekliğinden büyük olduğunda,
// komple yeni sayfaya atlamak yerine satırları sayfalara bölmeli.
//
// Senaryo: sayfa 200mm, content 200mm (header/footer yok).
// Tablonun öncesinde 50mm'lik bir eleman var.
// Tablo wrapper: y=50, h=300 (sayfaya sığmaz).
// Tablo satırları: her biri 30mm.
// Beklenen: ilk sayfa = el1 + tbl wrapper + header + ilk ~5 satır,
// ikinci sayfa = kalan satırlar.
let mut tbl_wrapper = make_element("tbl", 50.0, 300.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
"tbl_row_6".to_string(),
"tbl_row_7".to_string(),
"tbl_row_8".to_string(),
"tbl_row_9".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 50.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 50.0, 20.0, "static_text");
// 10 satır, her biri 28mm (gap dahil), y=70'ten başlıyor
let rows: Vec<ElementLayout> = (0..10)
.flat_map(|i| {
let y = 70.0 + (i as f64) * 28.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 28.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 28.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![
make_element("el1", 0.0, 50.0, "text"),
tbl_wrapper,
tbl_header,
tbl_hdr_0,
];
body_elements.extend(rows);
let input = PageSplitInput {
body_elements,
page_height_mm: 200.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
// Tablo komple 2. sayfaya atlamamalı!
// Sayfa 1'de el1 + tablo başlangıcı olmalı
assert!(
pages[0].elements.iter().any(|e| e.id == "el1"),
"el1 should be on page 1"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl"),
"table wrapper should start on page 1 (not jump to page 2)"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl_header"),
"table header should be on page 1"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl_row_0"),
"first table row should be on page 1"
);
// En az 2 sayfa olmalı (tablo bölünmeli)
assert!(
pages.len() >= 2,
"table should split across at least 2 pages, got {}",
pages.len()
);
// Son satırlar sonraki sayfa(lar)da olmalı
let last_row_id = "tbl_row_9";
let last_row_page = pages
.iter()
.position(|p| p.elements.iter().any(|e| e.id == last_row_id))
.expect("last row should exist somewhere");
assert!(
last_row_page > 0,
"last table row should be on a later page"
);
}
#[test]
fn test_table_header_repeats_on_new_page() {
// Tablo satırı yeni sayfaya geçtiğinde, header tekrar edilmeli.
//
// Senaryo: sayfa 150mm, tablo y=0'dan başlıyor.
// Header: 20mm, her satır 30mm → 4 satır = 120mm + header 20mm = 140mm (1. sayfa)
// 5. satır sığmaz → 2. sayfaya geçer, header tekrar olmalı.
let mut tbl_wrapper = make_element("tbl", 0.0, 200.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 0.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 0.0, 20.0, "static_text");
let rows: Vec<ElementLayout> = (0..6)
.flat_map(|i| {
let y = 20.0 + (i as f64) * 30.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 30.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 30.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![tbl_wrapper, tbl_header, tbl_hdr_0];
body_elements.extend(rows);
let input = PageSplitInput {
body_elements,
page_height_mm: 150.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "should split into at least 2 pages");
// Sayfa 2'de tablo header'ının tekrar edilmiş kopyası olmalı
let page2_has_header = pages[1]
.elements
.iter()
.any(|e| e.id.starts_with("tbl_header"));
assert!(
page2_has_header,
"table header should be repeated on page 2. Page 2 elements: {:?}",
pages[1].elements.iter().map(|e| &e.id).collect::<Vec<_>>()
);
}
#[test]
fn test_repeated_header_no_gap_with_rows() {
// Tekrarlanan header ile ilk satır arasında boşluk olmamalı.
// Header'ın y pozisyonu yeni sayfanın başlangıcına relocate edilmeli.
//
// Senaryo: tablo y=100'de başlıyor, header 10mm, satırlar 8mm.
// Sayfa content_height=80mm.
// Satırlar: y=110, 118, 126, ... → relative_bottom kontrolü.
let mut tbl_wrapper = make_element("tbl", 100.0, 120.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
"tbl_row_6".to_string(),
"tbl_row_7".to_string(),
"tbl_row_8".to_string(),
"tbl_row_9".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 100.0, 10.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 100.0, 10.0, "static_text");
let rows: Vec<ElementLayout> = (0..10)
.flat_map(|i| {
let y = 110.0 + (i as f64) * 12.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 12.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 12.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![
make_element("el1", 0.0, 50.0, "text"), // 50mm metin
make_element("el2", 50.0, 50.0, "text"), // 50mm metin (toplam 100mm)
tbl_wrapper,
tbl_header,
tbl_hdr_0,
];
body_elements.extend(rows);
// content_height = 200 - 15 - 10 = 175
let doc_header = vec![make_element("doc_hdr", 0.0, 15.0, "text")];
let doc_footer = vec![make_element("doc_ftr", 0.0, 10.0, "text")];
let input = PageSplitInput {
body_elements,
page_height_mm: 200.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: doc_header,
footer_elements: doc_footer,
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 5.0,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "should have at least 2 pages");
// Sayfa 2'deki elemanlar
let page2 = &pages[1];
// Tekrarlanan header'ı bul
let repeated_header = page2
.elements
.iter()
.find(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header")
.expect("repeated table header should exist on page 2");
// Header'dan sonraki ilk satırı bul
let first_row_on_page2 = page2
.elements
.iter()
.find(|e| e.id.starts_with("tbl_row_"))
.expect("at least one table row should be on page 2");
// Header'ın alt kenarı ile satırın üst kenarı arasında boşluk olmamalı (veya çok az)
let header_bottom = repeated_header.y_mm + repeated_header.height_mm;
let row_top = first_row_on_page2.y_mm;
let gap = (row_top - header_bottom).abs();
assert!(
gap < 1.0,
"gap between repeated header (bottom={:.1}) and first row (top={:.1}) should be < 1mm, got {:.1}mm",
header_bottom,
row_top,
gap
);
// Header y değeri negatif olmamalı
assert!(
repeated_header.y_mm >= 0.0,
"repeated header y should be non-negative, got {:.1}",
repeated_header.y_mm
);
// Header, document header'dan sonra gelmeli
assert!(
repeated_header.y_mm >= 15.0,
"repeated header should be after doc header (15mm), got {:.1}",
repeated_header.y_mm
);
}
}

View File

@@ -208,6 +208,12 @@ fn render_element(
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;
};
@@ -230,12 +236,160 @@ fn render_element(
// 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,
@@ -354,6 +508,92 @@ fn render_text(
);
}
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,
@@ -595,6 +835,8 @@ mod tests {
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,
@@ -609,6 +851,7 @@ mod tests {
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(),

View File

@@ -326,6 +326,7 @@ mod tests {
justify: "space-between".to_string(),
style: ContainerStyle::default(),
children: vec![],
break_inside: "auto".to_string(),
};
let style = container_to_style(&el, None);
assert_eq!(style.flex_direction, FlexDirection::Row);
@@ -346,6 +347,7 @@ mod tests {
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![],
break_inside: "auto".to_string(),
};
let style = container_to_style(&el, None);
assert_eq!(style.position, Position::Absolute);

View File

@@ -73,6 +73,7 @@ pub fn expand_table(
..Default::default()
},
children: header_cells,
break_inside: "auto".to_string(),
}));
// Header altına ayırıcı çizgi
@@ -163,6 +164,7 @@ pub fn expand_table(
..Default::default()
},
children: cells,
break_inside: "auto".to_string(),
}));
}
@@ -187,6 +189,7 @@ pub fn expand_table(
..Default::default()
},
children,
break_inside: "auto".to_string(),
}
}
@@ -219,6 +222,7 @@ mod tests {
data_source: ArrayBinding { path: "items".to_string() },
columns,
style: TableStyle::default(),
repeat_header: Some(true),
}
}
@@ -230,6 +234,8 @@ mod tests {
tables,
barcodes: HashMap::new(),
images: HashMap::new(),
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
}
}

View File

@@ -4,6 +4,15 @@ use std::hash::Hash;
use crate::FontData;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
/// Rich text span — ölçüm için gerekli bilgiler
#[derive(Clone)]
pub struct RichSpanMeasure {
pub text: String,
pub font_family: Option<String>,
pub font_size_pt: f32,
pub font_weight: Option<String>,
}
/// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
#[derive(Default)]
@@ -172,6 +181,83 @@ impl TextMeasurer {
(width_pt, height_pt)
}
/// Rich text ölç — birden fazla span, her biri farklı font/boyut/kalınlık.
/// cosmic-text set_rich_text() ile attributed text ölçümü yapar.
pub fn measure_rich_text(
&mut self,
spans: &[RichSpanMeasure],
available_width_pt: Option<f32>,
) -> (f32, f32) {
if spans.is_empty() {
return (0.0, 0.0);
}
// En büyük font boyutunu bul — line height buna göre belirlenir
let max_font_size_pt = spans
.iter()
.map(|s| s.font_size_pt)
.fold(0.0f32, f32::max);
if max_font_size_pt <= 0.0 {
return (0.0, 0.0);
}
let max_font_size_px = max_font_size_pt * PT_TO_PX;
let line_height_px = max_font_size_px * 1.2;
let metrics = Metrics::new(max_font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
let width_px = available_width_pt.map(|w| w * PT_TO_PX);
buffer.set_size(&mut self.font_system, width_px, None);
// Her span için (text, Attrs) pair oluştur
let rich_spans: Vec<(&str, Attrs)> = spans
.iter()
.map(|span| {
let weight = match span.font_weight.as_deref() {
Some("bold") => Weight::BOLD,
_ => Weight::NORMAL,
};
let family_name = span.font_family.as_deref().unwrap_or("Noto Sans");
let font_size_px = span.font_size_pt * PT_TO_PX;
let attrs = Attrs::new()
.family(Family::Name(family_name))
.weight(weight)
.metrics(Metrics::new(font_size_px, font_size_px * 1.2));
(span.text.as_str(), attrs)
})
.collect();
buffer.set_rich_text(
&mut self.font_system,
rich_spans,
&Attrs::new(),
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
let mut max_width: f32 = 0.0;
let mut total_height: f32 = 0.0;
for run in buffer.layout_runs() {
if run.line_w > max_width {
max_width = run.line_w;
}
total_height = run.line_top + line_height_px;
}
if total_height == 0.0 {
total_height = line_height_px;
}
let width_pt = max_width / PT_TO_PX + 0.5;
let height_pt = total_height / PT_TO_PX;
(width_pt, height_pt)
}
}
#[cfg(test)]

View File

@@ -7,7 +7,7 @@ use crate::data_resolve::ResolvedData;
use crate::sizing::{self, mm_to_pt, pt_to_mm};
use crate::table_layout;
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
use crate::{ElementLayout, LayoutResult, ResolvedContent, ResolvedStyle};
/// Taffy node ile dreport element arasındaki mapping
struct NodeInfo {
@@ -24,6 +24,8 @@ struct MeasureContext {
font_family: Option<String>,
font_size_pt: f32,
font_weight: Option<String>,
/// Rich text span'ları (varsa text/font_family/font_size_pt/font_weight yok sayılır)
rich_spans: Option<Vec<crate::text_measure::RichSpanMeasure>>,
}
/// Ana layout hesaplama fonksiyonu.
@@ -32,42 +34,53 @@ pub fn compute(
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> LayoutResult {
let page_w_pt = mm_to_pt(template.page.width);
// --- 1. Header layout (varsa) ---
let (header_elements, header_height_mm) = if let Some(ref header) = template.header {
compute_section(header, page_w_pt, resolved, measurer)
} else {
(vec![], 0.0)
};
// --- 2. Footer layout (varsa) ---
let (footer_elements, footer_height_mm) = if let Some(ref footer) = template.footer {
compute_section(footer, page_w_pt, resolved, measurer)
} else {
(vec![], 0.0)
};
// --- 3. Body layout — SINIRSIZ YÜKSEKLİK ---
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
// Kök sayfa node'u: sabit boyutlu, column flex container
let page_w_pt = mm_to_pt(template.page.width);
let page_h_pt = mm_to_pt(template.page.height);
// Root container'ı build et
let root_node = build_container(
&template.root,
&mut taffy,
&mut node_map,
resolved,
None, // root'un parent direction'ı yok
None,
);
// Sayfa wrapper: sabit boyutlu flex container, root'u stretch eder
// Sayfa wrapper: sayfa genişliğinde ama yükseklik sınırsız (auto)
let page_style = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::length(page_w_pt),
height: Dimension::length(page_h_pt),
height: Dimension::auto(),
},
..Default::default()
};
let page_node = taffy.new_with_children(page_style, &[root_node]).unwrap();
// Layout hesapla
taffy
.compute_layout_with_measure(
page_node,
Size {
width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::Definite(page_h_pt),
height: AvailableSpace::MaxContent,
},
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
@@ -75,16 +88,90 @@ pub fn compute(
)
.unwrap();
// Layout sonuçlarını topla
let elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
let body_elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: template.page.width,
height_mm: template.page.height,
elements,
}],
// --- 4. Container break modlarını topla ---
let break_modes = collect_break_modes(&template.root);
// --- 5. Sayfalara böl ---
let input = crate::page_break::PageSplitInput {
body_elements,
page_height_mm: template.page.height,
header_height_mm,
footer_height_mm,
header_elements,
footer_elements,
page_width_mm: template.page.width,
break_modes,
page_number_formats: resolved.page_number_formats.clone(),
root_padding_top_mm: template.root.padding.top,
};
let pages = crate::page_break::split_into_pages(input);
LayoutResult { pages }
}
/// Header veya footer gibi bağımsız bir container section'ı hesapla.
/// Sayfa genişliğinde, auto yükseklikte layout yapar.
fn compute_section(
container: &ContainerElement,
page_w_pt: f32,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> (Vec<ElementLayout>, f64) {
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let section_node = build_container(container, &mut taffy, &mut node_map, resolved, None);
let wrapper_style = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::length(page_w_pt),
height: Dimension::auto(),
},
..Default::default()
};
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node]).unwrap();
taffy
.compute_layout_with_measure(
wrapper_node,
Size {
width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::MaxContent,
},
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)
.unwrap();
let elements = collect_layout(&taffy, section_node, &node_map, 0.0, 0.0);
// Section yüksekliği
let section_layout = taffy.layout(section_node).unwrap();
let height_mm = pt_to_mm(section_layout.size.height);
(elements, height_mm)
}
/// Template ağacındaki tüm container'ların break_inside modlarını topla.
fn collect_break_modes(root: &ContainerElement) -> HashMap<String, String> {
let mut modes = HashMap::new();
collect_break_modes_recursive(&TemplateElement::Container(root.clone()), &mut modes);
modes
}
fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<String, String>) {
if let TemplateElement::Container(c) = el {
modes.insert(c.id.clone(), c.break_inside.clone());
for child in &c.children {
collect_break_modes_recursive(child, modes);
}
}
}
@@ -190,6 +277,42 @@ fn build_element(
parent_direction,
)
}
TemplateElement::CurrentDate(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"current_date",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CalculatedText(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"calculated_text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::Line(e) => {
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
@@ -293,6 +416,144 @@ fn build_element(
parent_direction,
)
}
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let node = taffy.new_leaf(style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "shape".to_string(),
content: Some(ResolvedContent::Shape {
shape_type: e.shape_type.clone(),
}),
style: ResolvedStyle {
background_color: e.style.background_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
border_radius: e.style.border_radius,
..Default::default()
},
children_ids: vec![],
},
);
node
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("false");
let checked = checked_str == "true";
let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Auto size → square based on style.size
let mut leaf_style = style;
if matches!(e.size.width, SizeValue::Auto) {
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
}
if matches!(e.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked }),
style: ResolvedStyle {
color: e.style.check_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
..Default::default()
},
children_ids: vec![],
},
);
node
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
let rich_span_measures: Vec<crate::text_measure::RichSpanMeasure> = spans
.iter()
.map(|s| crate::text_measure::RichSpanMeasure {
text: s.text.clone(),
font_family: s.font_family.clone(),
font_size_pt: s.font_size.unwrap_or(11.0) as f32,
font_weight: s.font_weight.clone(),
})
.collect();
let max_font_size_pt = rich_span_measures
.iter()
.map(|s| s.font_size_pt)
.fold(11.0f32, f32::max);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let context = MeasureContext {
text: String::new(),
font_family: None,
font_size_pt: max_font_size_pt,
font_weight: None,
rich_spans: Some(rich_span_measures),
};
let node = taffy.new_leaf_with_context(style, context).unwrap();
// ResolvedContent::RichText span'ları oluştur
let resolved_spans: Vec<crate::ResolvedRichSpan> = spans
.iter()
.map(|s| crate::ResolvedRichSpan {
text: s.text.clone(),
font_size: s.font_size,
font_weight: s.font_weight.clone(),
font_family: s.font_family.clone(),
color: s.color.clone(),
})
.collect();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "rich_text".to_string(),
content: Some(ResolvedContent::RichText { spans: resolved_spans }),
style: ResolvedStyle {
font_size: e.style.font_size,
font_weight: e.style.font_weight.clone(),
font_family: e.style.font_family.clone(),
color: e.style.color.clone(),
text_align: e.style.align.clone(),
..Default::default()
},
children_ids: vec![],
},
);
node
}
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(),
height: Dimension::length(mm_to_pt(0.5)),
},
..Default::default()
};
let node = taffy.new_leaf(style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "page_break".to_string(),
content: None,
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
node
}
}
}
@@ -331,6 +592,7 @@ fn build_text_leaf(
font_family: text_style.font_family.clone(),
font_size_pt,
font_weight: text_style.font_weight.clone(),
rich_spans: None,
};
let node = taffy.new_leaf_with_context(style, context).unwrap();
@@ -387,13 +649,17 @@ fn measure_leaf(
AvailableSpace::MinContent => Some(0.0),
};
let (measured_w, measured_h) = measurer.measure(
&ctx.text,
ctx.font_family.as_deref(),
ctx.font_size_pt,
ctx.font_weight.as_deref(),
available_width,
);
let (measured_w, measured_h) = if let Some(ref rich_spans) = ctx.rich_spans {
measurer.measure_rich_text(rich_spans, available_width)
} else {
measurer.measure(
&ctx.text,
ctx.font_family.as_deref(),
ctx.font_size_pt,
ctx.font_weight.as_deref(),
available_width,
)
};
Size {
width: known_dimensions.width.unwrap_or(measured_w),
@@ -458,6 +724,8 @@ mod tests {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -480,6 +748,7 @@ mod tests {
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(),
@@ -598,6 +867,8 @@ mod tests {
height: 297.0,
},
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -620,6 +891,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Container(ContainerElement {
id: "row".to_string(),
position: PositionMode::Flow,
@@ -642,6 +914,7 @@ mod tests {
align: "start".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "left".to_string(),
@@ -721,6 +994,8 @@ mod tests {
height: 297.0,
},
fonts: vec![],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -743,6 +1018,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "abs_text".to_string(),
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
@@ -806,6 +1082,8 @@ mod tests {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -821,6 +1099,7 @@ mod tests {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
// Header row
TemplateElement::Container(ContainerElement {
@@ -833,6 +1112,7 @@ mod tests {
align: "start".to_string(),
justify: "space-between".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
// Sol: firma bilgileri
TemplateElement::Container(ContainerElement {
@@ -845,6 +1125,7 @@ mod tests {
align: "start".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_unvan".to_string(),
@@ -921,6 +1202,7 @@ mod tests {
align: "end".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_baslik".to_string(),

View File

@@ -49,6 +49,8 @@ fn simple_template() -> Template {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -64,6 +66,7 @@ fn simple_template() -> Template {
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,
@@ -188,6 +191,8 @@ fn test_compute_layout_with_data_binding() {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -203,6 +208,7 @@ fn test_compute_layout_with_data_binding() {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "bound_text".to_string(),
position: PositionMode::Flow,
@@ -254,6 +260,8 @@ fn test_compute_layout_multiple_children_ordering() {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -269,6 +277,7 @@ fn test_compute_layout_multiple_children_ordering() {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "first".to_string(),

View File

@@ -51,6 +51,8 @@ fn simple_template() -> Template {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -66,6 +68,7 @@ fn simple_template() -> Template {
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,
@@ -118,6 +121,8 @@ fn test_render_pdf_with_multiple_elements() {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -133,6 +138,7 @@ fn test_render_pdf_with_multiple_elements() {
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "header".to_string(),
@@ -207,6 +213,8 @@ fn test_render_pdf_with_container_styles() {
height: 297.0,
},
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
@@ -227,6 +235,7 @@ fn test_render_pdf_with_container_styles() {
border_width: Some(1.0),
..Default::default()
},
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "text".to_string(),
position: PositionMode::Flow,
@@ -254,3 +263,79 @@ fn test_render_pdf_with_container_styles() {
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF"));
}
#[test]
fn test_page_break_produces_multiple_pages() {
let template = Template {
id: "pb_test".to_string(),
name: "Page Break 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::default(),
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: "t1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
content: "Page 1 content".to_string(),
}),
TemplateElement::PageBreak(PageBreakElement { id: "pb1".to_string() }),
TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
content: "Page 2 content".to_string(),
}),
],
},
};
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
println!("Layout pages: {}", layout.pages.len());
for (i, page) in layout.pages.iter().enumerate() {
println!("Page {}: {} elements", i, page.elements.len());
for el in &page.elements {
println!(" - {} (type={}, y={:.1}mm, h={:.1}mm)", el.id, el.element_type, el.y_mm, el.height_mm);
}
}
assert_eq!(layout.pages.len(), 2, "Page break should produce 2 pages");
// Verify page 1 has t1 and page 2 has t2
let p1_ids: Vec<&str> = layout.pages[0].elements.iter().map(|e| e.id.as_str()).collect();
let p2_ids: Vec<&str> = layout.pages[1].elements.iter().map(|e| e.id.as_str()).collect();
println!("Page 1 IDs: {:?}", p1_ids);
println!("Page 2 IDs: {:?}", p2_ids);
assert!(p1_ids.contains(&"t1"), "Page 1 should contain t1");
assert!(p2_ids.contains(&"t2"), "Page 2 should contain t2");
// Render PDF and verify it's valid
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf_bytes.starts_with(b"%PDF"));
// Write PDF for manual inspection
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.join("test_page_break.pdf");
std::fs::write(&out_path, &pdf_bytes).unwrap();
println!("Wrote: {}", out_path.display());
}