mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
add elements
This commit is contained in:
@@ -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(),
|
||||
|
||||
510
layout-engine/src/expr_eval.rs
Normal file
510
layout-engine/src/expr_eval.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
995
layout-engine/src/page_break.rs
Normal file
995
layout-engine/src/page_break.rs
Normal 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}", ¤t_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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user