Skip to main content

webfluent/codegen/
pdf.rs

1use crate::config::project::PdfConfig;
2use crate::parser::{Program, Declaration, Statement, UIElement, ComponentRef, Expr, StringPart};
3
4// ─── Base14 Font Metrics (Helvetica) ────────────────────────────────
5const HELVETICA_WIDTHS: [u16; 95] = [
6    278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
7    556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
8    1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
9    667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
10    333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
11    556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
12];
13
14const HELVETICA_BOLD_WIDTHS: [u16; 95] = [
15    278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
16    556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
17    975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
18    667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
19    333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
20    611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
21];
22
23const TIMES_WIDTHS: [u16; 95] = [
24    250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
25    500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
26    921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
27    556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
28    333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
29    500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541,
30];
31
32const COURIER_WIDTH: u16 = 600;
33
34fn char_width(ch: char, font: &str) -> u16 {
35    let code = ch as u32;
36    if code < 32 || code > 126 {
37        return 500;
38    }
39    let idx = (code - 32) as usize;
40    if font.contains("Courier") {
41        COURIER_WIDTH
42    } else if font.contains("Times") {
43        TIMES_WIDTHS[idx]
44    } else if font.contains("Bold") {
45        HELVETICA_BOLD_WIDTHS[idx]
46    } else {
47        HELVETICA_WIDTHS[idx]
48    }
49}
50
51fn text_width(text: &str, font: &str, font_size: f64) -> f64 {
52    let units: u32 = text.chars().map(|c| char_width(c, font) as u32).sum();
53    units as f64 * font_size / 1000.0
54}
55
56fn truncate_text(text: &str, font: &str, font_size: f64, max_width: f64) -> String {
57    let full_w = text_width(text, font, font_size);
58    if full_w <= max_width {
59        return text.to_string();
60    }
61    let ellipsis_w = text_width("...", font, font_size);
62    let target = max_width - ellipsis_w;
63    if target <= 0.0 {
64        return "...".to_string();
65    }
66    let mut w = 0.0;
67    let mut end = 0;
68    for (i, ch) in text.char_indices() {
69        let cw = char_width(ch, font) as f64 * font_size / 1000.0;
70        if w + cw > target { break; }
71        w += cw;
72        end = i + ch.len_utf8();
73    }
74    format!("{}...", &text[..end])
75}
76
77fn page_dimensions(size: &str) -> (f64, f64) {
78    match size.to_uppercase().as_str() {
79        "A4" => (595.28, 841.89),
80        "A3" => (841.89, 1190.55),
81        "A5" => (419.53, 595.28),
82        "LETTER" => (612.0, 792.0),
83        "LEGAL" => (612.0, 1008.0),
84        _ => (595.28, 841.89),
85    }
86}
87
88// ─── PDF Object ─────────────────────────────────────────────────────
89
90struct PdfObj {
91    id: usize,
92    data: Vec<u8>,
93}
94
95// ─── Content Stream Builder ─────────────────────────────────────────
96
97struct ContentStream {
98    ops: Vec<u8>,
99}
100
101impl ContentStream {
102    fn new() -> Self { Self { ops: Vec::new() } }
103
104    fn op(&mut self, s: &str) {
105        self.ops.extend_from_slice(s.as_bytes());
106        self.ops.push(b'\n');
107    }
108
109    fn set_font(&mut self, tag: &str, size: f64) {
110        self.op(&format!("/{} {} Tf", tag, fmt_f64(size)));
111    }
112    fn set_color(&mut self, r: f64, g: f64, b: f64) {
113        self.op(&format!("{} {} {} rg", fmt_f64(r), fmt_f64(g), fmt_f64(b)));
114    }
115    fn set_stroke_color(&mut self, r: f64, g: f64, b: f64) {
116        self.op(&format!("{} {} {} RG", fmt_f64(r), fmt_f64(g), fmt_f64(b)));
117    }
118    fn begin_text(&mut self) { self.op("BT"); }
119    fn end_text(&mut self)   { self.op("ET"); }
120    fn text_position(&mut self, x: f64, y: f64) {
121        self.op(&format!("{} {} Td", fmt_f64(x), fmt_f64(y)));
122    }
123    fn show_text(&mut self, text: &str) {
124        self.op(&format!("<{}> Tj", text_to_pdf_hex(text)));
125    }
126    fn text_at(&mut self, x: f64, y: f64, font_tag: &str, size: f64, text: &str) {
127        self.begin_text();
128        self.set_font(font_tag, size);
129        self.text_position(x, y);
130        self.show_text(text);
131        self.end_text();
132    }
133    fn rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
134        self.op(&format!("{} {} {} {} re", fmt_f64(x), fmt_f64(y), fmt_f64(w), fmt_f64(h)));
135    }
136    fn fill(&mut self)   { self.op("f"); }
137    fn stroke(&mut self) { self.op("S"); }
138    fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64) {
139        self.op(&format!("{} {} m {} {} l S", fmt_f64(x1), fmt_f64(y1), fmt_f64(x2), fmt_f64(y2)));
140    }
141    fn set_line_width(&mut self, w: f64) {
142        self.op(&format!("{} w", fmt_f64(w)));
143    }
144    fn rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64) {
145        let r = r.min(w / 2.0).min(h / 2.0);
146        if r < 0.5 { self.rect(x, y, w, h); return; }
147        let k = 0.5523;
148        let kr = k * r;
149        self.op(&format!("{} {} m", fmt_f64(x + r), fmt_f64(y)));
150        self.op(&format!("{} {} l", fmt_f64(x + w - r), fmt_f64(y)));
151        self.op(&format!("{} {} {} {} {} {} c", fmt_f64(x+w-r+kr), fmt_f64(y), fmt_f64(x+w), fmt_f64(y+r-kr), fmt_f64(x+w), fmt_f64(y+r)));
152        self.op(&format!("{} {} l", fmt_f64(x + w), fmt_f64(y + h - r)));
153        self.op(&format!("{} {} {} {} {} {} c", fmt_f64(x+w), fmt_f64(y+h-r+kr), fmt_f64(x+w-r+kr), fmt_f64(y+h), fmt_f64(x+w-r), fmt_f64(y+h)));
154        self.op(&format!("{} {} l", fmt_f64(x + r), fmt_f64(y + h)));
155        self.op(&format!("{} {} {} {} {} {} c", fmt_f64(x+r-kr), fmt_f64(y+h), fmt_f64(x), fmt_f64(y+h-r+kr), fmt_f64(x), fmt_f64(y+h-r)));
156        self.op(&format!("{} {} l", fmt_f64(x), fmt_f64(y + r)));
157        self.op(&format!("{} {} {} {} {} {} c", fmt_f64(x), fmt_f64(y+r-kr), fmt_f64(x+r-kr), fmt_f64(y), fmt_f64(x+r), fmt_f64(y)));
158        self.op("h");
159    }
160    fn bytes(&self) -> &[u8] { &self.ops }
161}
162
163// ─── PDF Writer ─────────────────────────────────────────────────────
164//
165// COORDINATE CONTRACT:
166//   cursor_y = the Y coordinate where the next text BASELINE will be drawn.
167//   After drawing a line of text at cursor_y, decrement cursor_y by line_height.
168//   Shapes (rects, lines) position themselves relative to cursor_y.
169//   Y=0 is page bottom. Y increases upward.
170
171/// PDF code generator — renders the AST to a PDF document using Base14 fonts.
172///
173/// Supports text, headings, tables, lists, code blocks, cards, badges, alerts,
174/// progress bars, images, and page headers/footers with `{page}`/`{pages}` variables.
175pub struct PdfCodegen {
176    objects: Vec<PdfObj>,
177    pages: Vec<usize>,
178    page_width: f64,
179    page_height: f64,
180    margin_top: f64,
181    margin_bottom: f64,
182    margin_left: f64,
183    margin_right: f64,
184    cursor_y: f64,
185    current_stream: ContentStream,
186    fonts: Vec<(String, String)>,
187    current_font: String,
188    current_font_name: String,
189    current_font_size: f64,
190    current_color: (f64, f64, f64),
191    default_font: String,
192    default_font_size: f64,
193    header_stmts: Vec<Statement>,
194    footer_stmts: Vec<Statement>,
195    image_objects: Vec<(usize, f64, f64)>,
196    in_header_footer: bool,
197    page_has_content: bool,
198    current_page_number: usize,
199}
200
201impl PdfCodegen {
202    pub fn new(config: &PdfConfig) -> Self {
203        let (w, h) = page_dimensions(&config.page_size);
204        let mut cg = Self {
205            objects: Vec::new(),
206            pages: Vec::new(),
207            page_width: w,
208            page_height: h,
209            margin_top: config.margins.top,
210            margin_bottom: config.margins.bottom,
211            margin_left: config.margins.left,
212            margin_right: config.margins.right,
213            cursor_y: h - config.margins.top,
214            current_stream: ContentStream::new(),
215            fonts: Vec::new(),
216            current_font: "F1".to_string(),
217            current_font_name: config.default_font.clone(),
218            current_font_size: config.default_font_size,
219            current_color: (0.0, 0.0, 0.0),
220            default_font: config.default_font.clone(),
221            default_font_size: config.default_font_size,
222            header_stmts: Vec::new(),
223            footer_stmts: Vec::new(),
224            image_objects: Vec::new(),
225            in_header_footer: false,
226            page_has_content: false,
227            current_page_number: 0,
228        };
229        cg.register_font(&config.default_font);
230        cg.register_font("Helvetica-Bold");
231        cg.register_font("Courier");
232        cg.register_font("Courier-Bold");
233        cg.register_font("Times-Roman");
234        cg.register_font("Times-Bold");
235        cg
236    }
237
238    fn register_font(&mut self, base_font: &str) -> String {
239        for (tag, name) in &self.fonts {
240            if name == base_font { return tag.clone(); }
241        }
242        let tag = format!("F{}", self.fonts.len() + 1);
243        self.fonts.push((tag.clone(), base_font.to_string()));
244        tag
245    }
246
247    fn font_tag(&self, base_font: &str) -> String {
248        for (tag, name) in &self.fonts {
249            if name == base_font { return tag.clone(); }
250        }
251        "F1".to_string()
252    }
253
254    fn content_width(&self) -> f64 {
255        self.page_width - self.margin_left - self.margin_right
256    }
257
258    fn available_height(&self) -> f64 {
259        self.cursor_y - self.margin_bottom
260    }
261
262    fn check_page_break(&mut self, needed: f64) {
263        if !self.in_header_footer && self.cursor_y - needed < self.margin_bottom {
264            self.finalize_page();
265            self.start_new_page();
266        }
267    }
268
269    fn start_new_page(&mut self) {
270        self.current_page_number += 1;
271        self.cursor_y = self.page_height - self.margin_top;
272        self.current_stream = ContentStream::new();
273        self.page_has_content = false;
274
275        // Emit header in top margin area
276        if !self.header_stmts.is_empty() && !self.in_header_footer {
277            self.in_header_footer = true;
278            let saved = self.cursor_y;
279            // Header baseline: inside top margin, 30pt from page top
280            self.cursor_y = self.page_height - 30.0;
281            let stmts = self.header_stmts.clone();
282            for s in &stmts { self.emit_statement(s); }
283            self.cursor_y = saved;
284            self.in_header_footer = false;
285        }
286    }
287
288    fn finalize_page(&mut self) {
289        // Emit footer in bottom margin area
290        if !self.footer_stmts.is_empty() && !self.in_header_footer {
291            self.in_header_footer = true;
292            let saved = self.cursor_y;
293            // Footer needs space. We give it the full bottom margin area.
294            // Start at margin_bottom - 10 (just above the content area bottom edge)
295            // and work downward. For a Divider + Row, that's ~40pt.
296            // So we start the footer cursor at margin_bottom - 6, which is ~66pt from page bottom.
297            self.cursor_y = self.margin_bottom - 6.0;
298            let stmts = self.footer_stmts.clone();
299            for s in &stmts { self.emit_statement(s); }
300            self.cursor_y = saved;
301            self.in_header_footer = false;
302        }
303
304        let stream_data = std::mem::replace(&mut self.current_stream, ContentStream::new());
305        if stream_data.bytes().is_empty() && !self.page_has_content {
306            return;
307        }
308        let content_id = self.add_stream_object(stream_data.bytes());
309        let page_data = format!(
310            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R >>",
311            fmt_f64(self.page_width), fmt_f64(self.page_height), content_id
312        );
313        let page_obj_id = self.add_object(page_data.as_bytes());
314        self.pages.push(page_obj_id);
315    }
316
317    fn add_object(&mut self, data: &[u8]) -> usize {
318        let id = self.objects.len() + 1;
319        self.objects.push(PdfObj { id, data: data.to_vec() });
320        id
321    }
322
323    fn add_stream_object(&mut self, stream_data: &[u8]) -> usize {
324        let header = format!("<< /Length {} >>", stream_data.len());
325        let mut data = Vec::new();
326        data.extend_from_slice(header.as_bytes());
327        data.extend_from_slice(b"\nstream\n");
328        data.extend_from_slice(stream_data);
329        data.extend_from_slice(b"\nendstream");
330        self.add_object(&data)
331    }
332
333    fn mark_content(&mut self) { self.page_has_content = true; }
334
335    fn substitute_page_vars(&self, text: &str) -> String {
336        if !self.in_header_footer { return text.to_string(); }
337        text.replace("{page}", &self.current_page_number.to_string())
338            .replace("{pages}", "###")
339    }
340
341    fn fixup_total_pages(&mut self) {
342        let total = self.current_page_number;
343        let total_str = format!("{:<3}", total);
344        let placeholder_hex = "232323";
345        let replacement_hex: String = total_str.chars()
346            .map(|ch| format!("{:02X}", ch as u8)).collect();
347        for obj in &mut self.objects {
348            // Only process stream objects (they contain hex text)
349            let data_str = String::from_utf8_lossy(&obj.data).to_string();
350            if data_str.contains(placeholder_hex) {
351                obj.data = data_str.replace(placeholder_hex, &replacement_hex).into_bytes();
352            }
353        }
354    }
355
356    pub fn page_count(&self) -> usize { self.pages.len() }
357
358    // ─── Main Entry ──────────────────────────────────────────
359
360    pub fn generate(&mut self, program: &Program) -> Vec<u8> {
361        self.start_new_page();
362        for decl in &program.declarations {
363            if let Declaration::Page(page) = decl {
364                for stmt in &page.body { self.emit_statement(stmt); }
365            }
366        }
367        self.finalize_page();
368        self.fixup_total_pages();
369        self.serialize()
370    }
371
372    // ─── Statement Dispatch ─────────────────────────────────
373
374    fn emit_statement(&mut self, stmt: &Statement) {
375        match stmt {
376            Statement::UIElement(ui) => self.emit_ui_element(ui),
377            Statement::If(if_stmt) => {
378                for s in &if_stmt.then_body { self.emit_statement(s); }
379            }
380            Statement::For(for_stmt) => {
381                if let Expr::ListLiteral(items) = &for_stmt.iterable {
382                    for _item in items {
383                        for s in &for_stmt.body { self.emit_statement(s); }
384                    }
385                }
386            }
387            _ => {}
388        }
389    }
390
391    fn emit_ui_element(&mut self, ui: &UIElement) {
392        let name = match &ui.component {
393            ComponentRef::BuiltIn(n) => n.clone(),
394            ComponentRef::SubComponent(p, s) => format!("{}.{}", p, s),
395            ComponentRef::UserDefined(_) => {
396                for c in &ui.children { self.emit_statement(c); }
397                return;
398            }
399        };
400        match name.as_str() {
401            "Document"  => self.emit_document(ui),
402            "Section"   => self.emit_section(ui),
403            "Paragraph" => self.emit_paragraph(ui),
404            "Header"    => { self.header_stmts = ui.children.clone(); }
405            "Footer"    => { self.footer_stmts = ui.children.clone(); }
406            "PageBreak" => { self.finalize_page(); self.start_new_page(); }
407            "Text"      => self.emit_text(ui),
408            "Heading"   => self.emit_heading(ui),
409            "Table"     => self.emit_table(ui),
410            "Thead" | "Tbody" => { for c in &ui.children { self.emit_statement(c); } }
411            "Trow"      => {}
412            "Code"      => self.emit_code(ui),
413            "Blockquote"=> self.emit_blockquote(ui),
414            "Image"     => self.emit_image(ui),
415            "Divider"   => self.emit_divider(),
416            "Row"       => self.emit_row(ui),
417            "Container" | "Column" | "Grid" | "Stack" => {
418                for c in &ui.children { self.emit_statement(c); }
419            }
420            "Spacer"    => { self.cursor_y -= self.get_spacer_size(ui); }
421            "List" | "TypeList" => self.emit_list(ui),
422            "Badge" | "Tag" => self.emit_badge(ui),
423            "Alert"     => self.emit_alert(ui),
424            "Progress"  => self.emit_progress(ui),
425            "Card"      => self.emit_card(ui),
426            "Card.Header" | "Card.Body" | "Card.Footer" => {
427                for c in &ui.children { self.emit_statement(c); }
428            }
429            "Avatar" | "Icon" => {}
430            _ => { for c in &ui.children { self.emit_statement(c); } }
431        }
432    }
433
434    // ─── Row (justify:between) ──────────────────────────────
435
436    fn emit_row(&mut self, ui: &UIElement) {
437        let mut justify_between = false;
438        for arg in &ui.args {
439            if let crate::parser::Arg::Named(name, value) = arg {
440                if name == "justify" {
441                    if let Expr::Identifier(v) = value {
442                        if v == "between" { justify_between = true; }
443                    }
444                }
445            }
446        }
447
448        if justify_between && ui.children.len() >= 2 {
449            let mut items: Vec<(String, String, f64, (f64, f64, f64))> = Vec::new();
450            for c in &ui.children {
451                if let Statement::UIElement(cu) = c {
452                    let t = self.extract_text_content(cu);
453                    let (f, s, col, _) = self.extract_text_style(cu);
454                    items.push((t, f, s, col));
455                }
456            }
457            if items.len() >= 2 {
458                let lh = items.iter().map(|(_, _, s, _)| *s * 1.5).fold(0.0f64, f64::max);
459                self.check_page_break(lh);
460
461                // Left item
462                let (ref t, ref f, sz, col) = items[0];
463                if !t.is_empty() {
464                    let ft = self.font_tag(f);
465                    self.current_stream.set_color(col.0, col.1, col.2);
466                    self.current_stream.text_at(self.margin_left, self.cursor_y, &ft, sz, t);
467                }
468                // Right item
469                let last = items.len() - 1;
470                let (ref t, ref f, sz, col) = items[last];
471                if !t.is_empty() {
472                    let ft = self.font_tag(f);
473                    let tw = text_width(t, f, sz);
474                    let x = self.page_width - self.margin_right - tw;
475                    self.current_stream.set_color(col.0, col.1, col.2);
476                    self.current_stream.text_at(x, self.cursor_y, &ft, sz, t);
477                }
478                self.cursor_y -= lh;
479                self.mark_content();
480                return;
481            }
482        }
483        for c in &ui.children { self.emit_statement(c); }
484    }
485
486    // ─── Document / Section ─────────────────────────────────
487
488    fn emit_document(&mut self, ui: &UIElement) {
489        for arg in &ui.args {
490            if let crate::parser::Arg::Named(name, value) = arg {
491                if name == "page_size" || name == "pageSize" {
492                    if let Expr::StringLiteral(s) = value {
493                        let (w, h) = page_dimensions(s);
494                        self.page_width = w;
495                        self.page_height = h;
496                        self.cursor_y = h - self.margin_top;
497                    }
498                }
499            }
500        }
501        for c in &ui.children { self.emit_statement(c); }
502    }
503
504    fn emit_section(&mut self, ui: &UIElement) {
505        self.cursor_y -= 8.0;
506        for c in &ui.children { self.emit_statement(c); }
507        self.cursor_y -= 4.0;
508    }
509
510    fn emit_paragraph(&mut self, ui: &UIElement) {
511        let (font, size, color, align) = self.extract_text_style(ui);
512        for c in &ui.children {
513            if let Statement::UIElement(tu) = c {
514                let text = self.extract_text_content(tu);
515                if !text.is_empty() {
516                    let (cf, cs, cc, ca) = self.extract_text_style(tu);
517                    let f = if cf != self.default_font { &cf } else { &font };
518                    let s = if (cs - self.default_font_size).abs() > 0.1 { cs } else { size };
519                    let c = if cc != (0.0, 0.0, 0.0) { cc } else { color };
520                    let a = if !ca.is_empty() { &ca } else { &align };
521                    self.render_wrapped_text(&text, f, s, c, a);
522                }
523            }
524        }
525        self.cursor_y -= self.current_font_size * 0.5;
526    }
527
528    // ─── Text ───────────────────────────────────────────────
529
530    fn emit_text(&mut self, ui: &UIElement) {
531        let text = self.extract_text_content(ui);
532        if text.is_empty() { return; }
533        let (font, size, color, align) = self.extract_text_style(ui);
534        self.render_wrapped_text(&text, &font, size, color, &align);
535    }
536
537    // ─── Heading ────────────────────────────────────────────
538
539    fn emit_heading(&mut self, ui: &UIElement) {
540        let text = self.extract_text_content(ui);
541        if text.is_empty() { return; }
542
543        let mut level = 2;
544        let mut color = (0.0, 0.0, 0.0);
545        for m in &ui.modifiers {
546            match m.as_str() {
547                "h1" => level = 1, "h2" => level = 2, "h3" => level = 3,
548                "h4" => level = 4, "h5" => level = 5, "h6" => level = 6,
549                "primary" => color = (0.098, 0.098, 0.647),
550                "muted" => color = (0.4, 0.4, 0.4),
551                _ => {}
552            }
553        }
554        let size = match level { 1=>28.0, 2=>22.0, 3=>18.0, 4=>16.0, 5=>14.0, _=>12.0 };
555        let font = "Helvetica-Bold".to_string();
556        let (_, _, sc, align) = self.extract_text_style(ui);
557        if sc != (0.0, 0.0, 0.0) { color = sc; }
558
559        self.cursor_y -= size * 0.4;
560        self.check_page_break(size * 1.5);
561        self.render_wrapped_text(&text, &font, size, color, &align);
562        self.cursor_y -= size * 0.3;
563    }
564
565    // ─── Divider ────────────────────────────────────────────
566    //
567    // Contract: consume space, draw line in the middle of the gap.
568    // cursor_y before: points to where next text baseline would go.
569    // We move cursor_y down by total_gap. The line is drawn at
570    // old_cursor_y - half_gap (the vertical midpoint of the gap).
571
572    fn emit_divider(&mut self) {
573        let total_gap = 20.0;
574        let half = total_gap / 2.0;
575
576        self.check_page_break(total_gap);
577
578        // Line at the midpoint: cursor_y - half
579        let line_y = self.cursor_y - half;
580        self.current_stream.set_stroke_color(0.80, 0.80, 0.80);
581        self.current_stream.set_line_width(0.5);
582        self.current_stream.line(self.margin_left, line_y, self.page_width - self.margin_right, line_y);
583
584        self.cursor_y -= total_gap;
585        self.mark_content();
586    }
587
588    // ─── Table ──────────────────────────────────────────────
589
590    fn emit_table(&mut self, ui: &UIElement) {
591        let mut rows: Vec<Vec<String>> = Vec::new();
592        let mut is_header: Vec<bool> = Vec::new();
593
594        for child in &ui.children {
595            if let Statement::UIElement(section) = child {
596                let sn = match &section.component {
597                    ComponentRef::BuiltIn(n) => n.clone(), _ => String::new(),
598                };
599                let hdr = sn == "Thead";
600                for rs in &section.children {
601                    if let Statement::UIElement(row) = rs {
602                        let cells: Vec<String> = row.children.iter().filter_map(|c| {
603                            if let Statement::UIElement(cell) = c { Some(self.extract_text_content(cell)) } else { None }
604                        }).collect();
605                        if !cells.is_empty() { rows.push(cells); is_header.push(hdr); }
606                    }
607                }
608            }
609        }
610        if rows.is_empty() { return; }
611
612        let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(1);
613        let cw = self.content_width();
614        let col_w = cw / num_cols as f64;
615        let cell_pad = 4.0;
616        let cell_tw = col_w - cell_pad * 2.0;
617        let fs = self.current_font_size;
618        let lh = fs * 1.5;
619
620        let mut row_heights: Vec<f64> = Vec::new();
621        for row in &rows {
622            let mut mx = 1usize;
623            for ct in row {
624                let l = self.count_wrapped_lines(ct, &self.default_font.clone(), fs, cell_tw);
625                if l > mx { mx = l; }
626            }
627            row_heights.push(((mx as f64 * lh) + cell_pad * 2.0).max(fs * 2.0));
628        }
629
630        let init_h: f64 = row_heights.iter().take(2).sum();
631        self.check_page_break(init_h);
632        let table_x = self.margin_left;
633        self.mark_content();
634        self.current_stream.set_line_width(0.5);
635
636        for (ri, row) in rows.iter().enumerate() {
637            let rh = row_heights[ri];
638            if !self.in_header_footer && self.cursor_y - rh < self.margin_bottom {
639                self.finalize_page();
640                self.start_new_page();
641            }
642            let top = self.cursor_y;
643            let bot = top - rh;
644
645            if is_header[ri] {
646                self.current_stream.set_color(0.93, 0.93, 0.93);
647                self.current_stream.rect(table_x, bot, cw, rh);
648                self.current_stream.fill();
649            }
650            self.current_stream.set_stroke_color(0.75, 0.75, 0.75);
651            self.current_stream.rect(table_x, bot, cw, rh);
652            self.current_stream.stroke();
653
654            let fn_ = if is_header[ri] { "Helvetica-Bold".to_string() } else { self.default_font.clone() };
655            let ft = self.font_tag(&fn_);
656            for (ci, ct) in row.iter().enumerate() {
657                let cx = table_x + ci as f64 * col_w;
658                if ci > 0 {
659                    self.current_stream.set_stroke_color(0.75, 0.75, 0.75);
660                    self.current_stream.line(cx, top, cx, bot);
661                }
662                self.current_stream.set_color(0.0, 0.0, 0.0);
663                self.render_cell_text(ct, &fn_, &ft, fs, cx + cell_pad, top - cell_pad - fs, cell_tw, lh);
664            }
665            self.cursor_y = bot;
666        }
667        self.cursor_y -= 8.0;
668    }
669
670    fn count_wrapped_lines(&self, text: &str, font: &str, size: f64, max_w: f64) -> usize {
671        if max_w <= 0.0 || text.is_empty() { return 1; }
672        let sw = text_width(" ", font, size);
673        let mut count = 0usize;
674        for line in text.split('\n') {
675            count += 1;
676            let words: Vec<&str> = line.split_whitespace().collect();
677            if words.is_empty() { continue; }
678            let mut cw = 0.0;
679            for (i, w) in words.iter().enumerate() {
680                let ww = text_width(w, font, size);
681                let need = if i > 0 { sw + ww } else { ww };
682                if cw > 0.0 && cw + need > max_w { count += 1; cw = ww; }
683                else { cw += need; }
684            }
685        }
686        count.max(1)
687    }
688
689    fn render_cell_text(&mut self, text: &str, font: &str, font_tag: &str, size: f64,
690                        x: f64, mut y: f64, max_w: f64, lh: f64) {
691        let sw = text_width(" ", font, size);
692        for line in text.split('\n') {
693            let words: Vec<&str> = line.split_whitespace().collect();
694            if words.is_empty() { y -= lh; continue; }
695            let mut cl = String::new();
696            let mut cw = 0.0;
697            for w in &words {
698                let ww = text_width(w, font, size);
699                if !cl.is_empty() && cw + sw + ww > max_w {
700                    self.current_stream.text_at(x, y, font_tag, size, &cl);
701                    y -= lh; cl = w.to_string(); cw = ww;
702                } else {
703                    if !cl.is_empty() { cl.push(' '); cw += sw; }
704                    cl.push_str(w); cw += ww;
705                }
706            }
707            if !cl.is_empty() {
708                if cw > max_w { cl = truncate_text(&cl, font, size, max_w); }
709                self.current_stream.text_at(x, y, font_tag, size, &cl);
710                y -= lh;
711            }
712        }
713    }
714
715    // ─── List ───────────────────────────────────────────────
716    //
717    // Draws marker via text_at at margin_left, then renders body
718    // text with increased margin_left so body is indented past marker.
719    // Both share the same cursor_y so they're on the same line.
720
721    fn emit_list(&mut self, ui: &UIElement) {
722        let ordered = ui.modifiers.iter().any(|m| m == "ordered");
723        let fs = self.current_font_size;
724        let lh = fs * 1.5;
725        let df = self.default_font.clone();
726        let ft = self.font_tag(&df);
727        let indent = 16.0;
728
729        for (idx, child) in ui.children.iter().enumerate() {
730            // List children might be bare UIElements or wrapped in other statements
731            let item_opt = match child {
732                Statement::UIElement(u) => Some(u),
733                _ => None,
734            };
735            if let Some(item) = item_opt {
736                let text = self.extract_text_content(item);
737                if text.is_empty() { continue; }
738
739                self.check_page_break(lh);
740
741                // Draw marker at margin_left, same baseline as text
742                let marker = if ordered { format!("{}.", idx + 1) } else { "-".to_string() };
743                let marker_font = self.font_tag(&df);
744                self.current_stream.set_color(0.35, 0.35, 0.35);
745                self.current_stream.text_at(self.margin_left, self.cursor_y, &marker_font, fs, &marker);
746
747                // Draw body text indented past the marker
748                let saved_ml = self.margin_left;
749                self.margin_left += indent;
750                self.render_wrapped_text(&text, &df, fs, (0.1, 0.1, 0.1), "");
751                self.margin_left = saved_ml;
752
753                self.mark_content();
754            }
755        }
756        self.cursor_y -= 4.0;
757    }
758
759    // ─── Code Block ─────────────────────────────────────────
760
761    fn emit_code(&mut self, ui: &UIElement) {
762        let text = self.extract_text_content(ui);
763        if text.is_empty() { return; }
764        let is_block = ui.modifiers.iter().any(|m| m == "block");
765
766        if is_block {
767            let cfs = 10.0;
768            let clh = cfs * 1.4;
769            let pv = 12.0;
770            let ph = 8.0;
771            let lines: Vec<&str> = text.split('\n').collect();
772            let total_h = lines.len() as f64 * clh + pv * 2.0;
773
774            if total_h <= self.available_height() {
775                self.render_code_block(&lines, cfs, clh, pv, ph, total_h);
776            } else {
777                let mut rem = &lines[..];
778                while !rem.is_empty() {
779                    let avail = self.available_height();
780                    let max_l = ((avail - pv * 2.0) / clh).floor().max(1.0) as usize;
781                    let take = rem.len().min(max_l);
782                    let chunk = &rem[..take];
783                    rem = &rem[take..];
784                    let ch = chunk.len() as f64 * clh + pv * 2.0;
785                    self.render_code_block(chunk, cfs, clh, pv, ph, ch);
786                    if !rem.is_empty() { self.finalize_page(); self.start_new_page(); }
787                }
788            }
789        } else {
790            let ft = self.font_tag("Courier");
791            let lh = self.current_font_size * 1.5;
792            self.check_page_break(lh);
793            self.current_stream.set_color(0.2, 0.2, 0.2);
794            self.current_stream.text_at(self.margin_left, self.cursor_y, &ft, self.current_font_size, &text);
795            self.cursor_y -= lh;
796            self.mark_content();
797        }
798    }
799
800    fn render_code_block(&mut self, lines: &[&str], fs: f64, lh: f64, pv: f64, ph: f64, bh: f64) {
801        let ft = self.font_tag("Courier");
802        let mtw = self.content_width() - ph * 2.0;
803
804        // Background (drawn FIRST, behind text)
805        self.current_stream.set_color(0.95, 0.95, 0.95);
806        self.current_stream.rect(self.margin_left, self.cursor_y - bh, self.content_width(), bh);
807        self.current_stream.fill();
808        self.current_stream.set_stroke_color(0.85, 0.85, 0.85);
809        self.current_stream.set_line_width(0.5);
810        self.current_stream.rect(self.margin_left, self.cursor_y - bh, self.content_width(), bh);
811        self.current_stream.stroke();
812
813        // Text (drawn AFTER background)
814        self.current_stream.set_color(0.2, 0.2, 0.2);
815        let mut y = self.cursor_y - pv - fs;
816        for line in lines {
817            let dl = truncate_text(line, "Courier", fs, mtw);
818            self.current_stream.text_at(self.margin_left + ph, y, &ft, fs, &dl);
819            y -= lh;
820        }
821        self.cursor_y -= bh + 8.0;
822        self.mark_content();
823    }
824
825    // ─── Blockquote ─────────────────────────────────────────
826    //
827    // Strategy: render text FIRST to know exact height, THEN draw the bar.
828    // The bar is drawn on top of nothing (it's to the left of the text),
829    // so z-order doesn't matter.
830
831    fn emit_blockquote(&mut self, ui: &UIElement) {
832        let bar_width = 3.0;
833        let bar_x = 8.0;       // offset from margin_left
834        let text_indent = 20.0; // text starts here from margin_left
835
836        let mut texts = Vec::new();
837        for c in &ui.children {
838            if let Statement::UIElement(tu) = c {
839                let t = self.extract_text_content(tu);
840                if !t.is_empty() { texts.push(t); }
841            }
842        }
843        if texts.is_empty() { return; }
844
845        let full = texts.join("\n");
846        let font = self.default_font.clone();
847        let size = self.current_font_size;
848        let lh = size * 1.5;
849
850        let avail = self.content_width() - text_indent;
851        let lc = self.count_wrapped_lines(&full, &font, size, avail);
852        let th = lc as f64 * lh;
853        self.check_page_break(th.min(lh * 2.0));
854
855        // Record start Y (before text renders)
856        let y_before = self.cursor_y;
857
858        // Render text indented
859        let saved = self.margin_left;
860        self.margin_left += text_indent;
861        self.render_wrapped_text(&full, &font, size, (0.30, 0.30, 0.30), "");
862        self.margin_left = saved;
863
864        // Record end Y (after text)
865        let y_after = self.cursor_y;
866
867        // Now draw the bar AFTER text, using exact positions.
868        // Bar top = y_before + ascender height (~75% of font size)
869        // Bar bottom = y_after + a small upward offset so bar doesn't extend too far below
870        let bar_top = y_before + size * 0.75;
871        let bar_bot = y_after + lh * 0.35;
872        let bar_h = bar_top - bar_bot;
873        if bar_h > 0.0 {
874            self.current_stream.set_color(0.70, 0.70, 0.70);
875            self.current_stream.rounded_rect(
876                saved + bar_x, bar_bot, bar_width, bar_h, 1.5,
877            );
878            self.current_stream.fill();
879        }
880
881        self.cursor_y -= 8.0;
882    }
883
884    // ─── Image ──────────────────────────────────────────────
885
886    fn emit_image(&mut self, ui: &UIElement) {
887        let mut width = 200.0f64;
888        let mut height = 150.0f64;
889        for arg in &ui.args {
890            if let crate::parser::Arg::Named(name, value) = arg {
891                if let Expr::StringLiteral(_s) = value {
892                    match name.as_str() { "src" => {} _ => {} }
893                }
894            }
895        }
896        if let Some(style) = &ui.style_block {
897            for sp in &style.properties {
898                match sp.name.as_str() {
899                    "width"  => { if let Expr::NumberLiteral(n) = sp.value { width = n; } }
900                    "height" => { if let Expr::NumberLiteral(n) = sp.value { height = n; } }
901                    _ => {}
902                }
903            }
904        }
905        self.check_page_break(height + 8.0);
906        self.current_stream.set_color(0.93, 0.93, 0.93);
907        self.current_stream.rect(self.margin_left, self.cursor_y - height, width, height);
908        self.current_stream.fill();
909        self.current_stream.set_stroke_color(0.8, 0.8, 0.8);
910        self.current_stream.set_line_width(1.0);
911        self.current_stream.rect(self.margin_left, self.cursor_y - height, width, height);
912        self.current_stream.stroke();
913        let ft = self.font_tag(&self.default_font.clone());
914        let lbl = "[Image]";
915        let lw = text_width(lbl, &self.default_font, 10.0);
916        self.current_stream.set_color(0.5, 0.5, 0.5);
917        self.current_stream.text_at(self.margin_left + (width - lw) / 2.0, self.cursor_y - height / 2.0, &ft, 10.0, lbl);
918        self.cursor_y -= height + 8.0;
919        self.mark_content();
920    }
921
922    // ─── Card ───────────────────────────────────────────────
923    //
924    // Draw border AFTER content (stroke only, no fill that covers text).
925
926    fn emit_card(&mut self, ui: &UIElement) {
927        let pad = 14.0;
928        let margin = 6.0;
929        let radius = 4.0;
930        let saved_l = self.margin_left;
931        let saved_r = self.margin_right;
932
933        self.cursor_y -= margin;
934        self.margin_left += pad + margin;
935        self.margin_right += pad + margin;
936
937        let start_y = self.cursor_y;
938        let start_pages = self.pages.len();
939        self.cursor_y -= pad;
940
941        for c in &ui.children { self.emit_statement(c); }
942
943        self.cursor_y -= pad;
944        let end_y = self.cursor_y;
945
946        self.margin_left = saved_l;
947        self.margin_right = saved_r;
948
949        // Only draw border (no fill!) so we don't cover content
950        if self.pages.len() == start_pages {
951            let h = start_y - end_y;
952            let x = saved_l + margin;
953            let w = self.page_width - saved_l - saved_r - margin * 2.0;
954            self.current_stream.set_stroke_color(0.82, 0.82, 0.82);
955            self.current_stream.set_line_width(0.75);
956            self.current_stream.rounded_rect(x, end_y, w, h, radius);
957            self.current_stream.stroke();
958        }
959        self.cursor_y -= margin;
960    }
961
962    // ─── Badge ──────────────────────────────────────────────
963
964    fn emit_badge(&mut self, ui: &UIElement) {
965        let text = self.extract_text_content(ui);
966        if text.is_empty() { return; }
967
968        let sz = 9.0;
969        let ft = self.font_tag("Helvetica-Bold");
970        let tw = text_width(&text, "Helvetica-Bold", sz);
971        let pad = 6.0;
972        let bh = sz + pad * 2.0;
973        let bw = tw + pad * 2.0;
974        let br = bh / 2.0;
975
976        self.check_page_break(bh + 4.0);
977
978        // Pill background
979        let color = self.modifier_color(&ui.modifiers);
980        let pill_y = self.cursor_y - sz - pad; // bottom of pill
981        self.current_stream.set_color(color.0, color.1, color.2);
982        self.current_stream.rounded_rect(self.margin_left, pill_y, bw, bh, br);
983        self.current_stream.fill();
984
985        // Text centered in pill
986        let text_y = pill_y + pad;
987        self.current_stream.set_color(1.0, 1.0, 1.0);
988        self.current_stream.text_at(self.margin_left + pad, text_y, &ft, sz, &text);
989
990        self.cursor_y -= bh + 6.0;
991        self.mark_content();
992    }
993
994    // ─── Alert ──────────────────────────────────────────────
995
996    fn emit_alert(&mut self, ui: &UIElement) {
997        let text = self.extract_text_content(ui);
998        if text.is_empty() { return; }
999
1000        let color = self.modifier_color(&ui.modifiers);
1001        let pad = 12.0;
1002        let font = self.default_font.clone();
1003        let fs = self.current_font_size;
1004        let lh = fs * 1.5;
1005        let tw_avail = self.content_width() - pad * 2.0 - 4.0;
1006        let lc = self.count_wrapped_lines(&text, &font, fs, tw_avail);
1007        let box_h = lc as f64 * lh + pad * 2.0;
1008
1009        self.check_page_break(box_h.min(lh * 3.0 + pad * 2.0));
1010
1011        let box_top = self.cursor_y;
1012        let box_bot = box_top - box_h;
1013
1014        // Background (drawn first)
1015        self.current_stream.set_color(color.0 * 0.15 + 0.85, color.1 * 0.15 + 0.85, color.2 * 0.15 + 0.85);
1016        self.current_stream.rect(self.margin_left, box_bot, self.content_width(), box_h);
1017        self.current_stream.fill();
1018
1019        // Left accent bar
1020        self.current_stream.set_color(color.0, color.1, color.2);
1021        self.current_stream.rect(self.margin_left, box_bot, 3.0, box_h);
1022        self.current_stream.fill();
1023
1024        // Text (drawn after background)
1025        let ft = self.font_tag(&font);
1026        let text_x = self.margin_left + pad + 4.0;
1027        let mut ty = box_top - pad - fs;
1028        let sw = text_width(" ", &font, fs);
1029        self.current_stream.set_color(0.15, 0.15, 0.15);
1030
1031        for raw_line in text.split('\n') {
1032            let words: Vec<&str> = raw_line.split_whitespace().collect();
1033            if words.is_empty() { ty -= lh; continue; }
1034            let mut cl = String::new();
1035            let mut cw = 0.0;
1036            for w in &words {
1037                let ww = text_width(w, &font, fs);
1038                if !cl.is_empty() && cw + sw + ww > tw_avail {
1039                    self.current_stream.text_at(text_x, ty, &ft, fs, &cl);
1040                    ty -= lh; cl = w.to_string(); cw = ww;
1041                } else {
1042                    if !cl.is_empty() { cl.push(' '); cw += sw; }
1043                    cl.push_str(w); cw += ww;
1044                }
1045            }
1046            if !cl.is_empty() {
1047                self.current_stream.text_at(text_x, ty, &ft, fs, &cl);
1048                ty -= lh;
1049            }
1050        }
1051
1052        self.cursor_y = box_bot - 8.0;
1053        self.mark_content();
1054    }
1055
1056    // ─── Progress ───────────────────────────────────────────
1057
1058    fn emit_progress(&mut self, ui: &UIElement) {
1059        let mut val = 0.0f64;
1060        let mut max = 100.0f64;
1061        for arg in &ui.args {
1062            if let crate::parser::Arg::Named(n, v) = arg {
1063                if let Expr::NumberLiteral(num) = v {
1064                    match n.as_str() { "value" => val = *num, "max" => max = *num, _ => {} }
1065                }
1066            }
1067        }
1068        let bh = 8.0;
1069        let br = bh / 2.0;
1070        let frac = (val / max).min(1.0).max(0.0);
1071
1072        self.check_page_break(bh + 8.0);
1073
1074        // Track (behind fill)
1075        let bar_y = self.cursor_y - bh;
1076        self.current_stream.set_color(0.9, 0.9, 0.9);
1077        self.current_stream.rounded_rect(self.margin_left, bar_y, self.content_width(), bh, br);
1078        self.current_stream.fill();
1079
1080        // Fill
1081        let fw = self.content_width() * frac;
1082        if fw > 0.5 {
1083            let color = self.modifier_color(&ui.modifiers);
1084            self.current_stream.set_color(color.0, color.1, color.2);
1085            self.current_stream.rounded_rect(self.margin_left, bar_y, fw, bh, br);
1086            self.current_stream.fill();
1087        }
1088        self.cursor_y -= bh + 8.0;
1089        self.mark_content();
1090    }
1091
1092    // ─── Text Rendering (with word wrap) ────────────────────
1093    //
1094    // Renders text at cursor_y (baseline), then decrements cursor_y
1095    // by line_height for each line rendered.
1096
1097    fn render_wrapped_text(&mut self, text: &str, font: &str, size: f64, color: (f64, f64, f64), align: &str) {
1098        let ft = self.font_tag(font);
1099        let lh = size * 1.5;
1100        let aw = self.content_width();
1101        let sw = text_width(" ", font, size);
1102
1103        for line in text.split('\n') {
1104            let words: Vec<&str> = line.split_whitespace().collect();
1105            if words.is_empty() { self.cursor_y -= lh * 0.5; continue; }
1106
1107            let mut cl = String::new();
1108            let mut cw = 0.0;
1109
1110            for w in &words {
1111                let ww = text_width(w, font, size);
1112                if !cl.is_empty() && cw + sw + ww > aw {
1113                    self.check_page_break(lh);
1114                    let x = self.text_x_for_align(&cl, font, size, align);
1115                    self.current_stream.set_color(color.0, color.1, color.2);
1116                    self.current_stream.text_at(x, self.cursor_y, &ft, size, &cl);
1117                    self.cursor_y -= lh;
1118                    self.mark_content();
1119                    cl = w.to_string(); cw = ww;
1120                } else {
1121                    if !cl.is_empty() { cl.push(' '); cw += sw; }
1122                    cl.push_str(w); cw += ww;
1123                }
1124            }
1125            if !cl.is_empty() {
1126                self.check_page_break(lh);
1127                let x = self.text_x_for_align(&cl, font, size, align);
1128                self.current_stream.set_color(color.0, color.1, color.2);
1129                self.current_stream.text_at(x, self.cursor_y, &ft, size, &cl);
1130                self.cursor_y -= lh;
1131                self.mark_content();
1132            }
1133        }
1134    }
1135
1136    fn text_x_for_align(&self, text: &str, font: &str, size: f64, align: &str) -> f64 {
1137        match align {
1138            "center" => self.margin_left + (self.content_width() - text_width(text, font, size)) / 2.0,
1139            "right"  => self.page_width - self.margin_right - text_width(text, font, size),
1140            _        => self.margin_left,
1141        }
1142    }
1143
1144    // ─── Helpers ────────────────────────────────────────────
1145
1146    fn extract_text_content(&self, ui: &UIElement) -> String {
1147        for arg in &ui.args {
1148            if let crate::parser::Arg::Positional(expr) = arg {
1149                let text = self.expr_to_string(expr);
1150                return self.substitute_page_vars(&text);
1151            }
1152        }
1153        String::new()
1154    }
1155
1156    fn expr_to_string(&self, expr: &Expr) -> String {
1157        match expr {
1158            Expr::StringLiteral(s) => s.clone(),
1159            Expr::InterpolatedString(parts) => {
1160                let mut out = String::new();
1161                for p in parts {
1162                    match p {
1163                        StringPart::Literal(s) => out.push_str(s),
1164                        StringPart::Expression(e) => out.push_str(&self.expr_to_string(e)),
1165                    }
1166                }
1167                out
1168            }
1169            Expr::NumberLiteral(n) => format!("{}", n),
1170            Expr::BoolLiteral(b) => format!("{}", b),
1171            Expr::Identifier(name) => format!("{{{}}}", name),
1172            _ => String::new(),
1173        }
1174    }
1175
1176    fn extract_text_style(&self, ui: &UIElement) -> (String, f64, (f64, f64, f64), String) {
1177        let mut font = self.default_font.clone();
1178        let mut size = self.default_font_size;
1179        let mut color = (0.0, 0.0, 0.0);
1180        let mut align = String::new();
1181
1182        for m in &ui.modifiers {
1183            match m.as_str() {
1184                "bold" => font = format!("{}-Bold", font.split('-').next().unwrap_or("Helvetica")),
1185                "muted"   => color = (0.4, 0.4, 0.4),
1186                "primary" => color = (0.098, 0.098, 0.647),
1187                "danger"  => color = (0.86, 0.21, 0.27),
1188                "success" => color = (0.16, 0.65, 0.27),
1189                "warning" => color = (0.90, 0.56, 0.0),
1190                "info"    => color = (0.0, 0.47, 0.84),
1191                "small"   => size = self.default_font_size * 0.85,
1192                "large"   => size = self.default_font_size * 1.25,
1193                "center"  => align = "center".to_string(),
1194                "right"   => align = "right".to_string(),
1195                _ => {}
1196            }
1197        }
1198        if let Some(style) = &ui.style_block {
1199            for sp in &style.properties {
1200                match sp.name.as_str() {
1201                    "font-size" => { if let Expr::NumberLiteral(n) = sp.value { size = n; } }
1202                    "font-family" | "font" => { if let Expr::StringLiteral(s) = &sp.value { font = s.clone(); } }
1203                    "color" => { if let Expr::StringLiteral(s) = &sp.value { color = parse_color(s); } }
1204                    "text-align" => { if let Expr::StringLiteral(s) = &sp.value { align = s.clone(); } }
1205                    _ => {}
1206                }
1207            }
1208        }
1209        (font, size, color, align)
1210    }
1211
1212    fn modifier_color(&self, mods: &[String]) -> (f64, f64, f64) {
1213        for m in mods {
1214            match m.as_str() {
1215                "primary" => return (0.39, 0.39, 0.95),
1216                "success" => return (0.16, 0.65, 0.27),
1217                "danger"  => return (0.86, 0.21, 0.27),
1218                "warning" => return (0.90, 0.56, 0.0),
1219                "info"    => return (0.0, 0.47, 0.84),
1220                _ => {}
1221            }
1222        }
1223        (0.39, 0.39, 0.95)
1224    }
1225
1226    fn get_spacer_size(&self, ui: &UIElement) -> f64 {
1227        for m in &ui.modifiers {
1228            match m.as_str() {
1229                "small" | "sm" => return 8.0,
1230                "medium" | "md" => return 16.0,
1231                "large" | "lg" => return 24.0,
1232                "xl" => return 32.0,
1233                _ => {}
1234            }
1235        }
1236        16.0
1237    }
1238
1239    // ─── PDF Serialization ──────────────────────────────────
1240
1241    fn serialize(&self) -> Vec<u8> {
1242        let mut out = Vec::new();
1243        let mut offsets: Vec<usize> = Vec::new();
1244
1245        out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
1246
1247        let font_start = 3;
1248        let num_fonts = self.fonts.len();
1249        let resources_id = font_start + num_fonts;
1250
1251        let mut final_objects: Vec<(usize, Vec<u8>)> = Vec::new();
1252
1253        // 1: Catalog
1254        final_objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
1255
1256        // Fonts
1257        for (i, (_, bf)) in self.fonts.iter().enumerate() {
1258            final_objects.push((font_start + i,
1259                format!("<< /Type /Font /Subtype /Type1 /BaseFont /{} /Encoding /WinAnsiEncoding >>", bf).into_bytes()));
1260        }
1261
1262        // Resources
1263        let mut fe = String::new();
1264        for (i, (tag, _)) in self.fonts.iter().enumerate() {
1265            fe.push_str(&format!("/{} {} 0 R ", tag, font_start + i));
1266        }
1267        final_objects.push((resources_id, format!("<< /Font << {} >> >>", fe).into_bytes()));
1268
1269        // Content streams + Pages
1270        let mut new_page_ids: Vec<usize> = Vec::new();
1271        let mut next_id = resources_id + 1;
1272        let mut i = 0;
1273        while i + 1 < self.objects.len() {
1274            let cid = next_id;
1275            final_objects.push((cid, self.objects[i].data.clone()));
1276            next_id += 1;
1277            let pid = next_id;
1278            final_objects.push((pid, format!(
1279                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R /Resources {} 0 R >>",
1280                fmt_f64(self.page_width), fmt_f64(self.page_height), cid, resources_id
1281            ).into_bytes()));
1282            new_page_ids.push(pid);
1283            next_id += 1;
1284            i += 2;
1285        }
1286
1287        // 2: Pages
1288        let kids: Vec<String> = new_page_ids.iter().map(|id| format!("{} 0 R", id)).collect();
1289        final_objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), new_page_ids.len()).into_bytes()));
1290
1291        final_objects.sort_by_key(|(id, _)| *id);
1292
1293        let total_objects = final_objects.last().map(|(id, _)| *id).unwrap_or(0);
1294        offsets.resize(total_objects + 1, 0);
1295
1296        for (id, data) in &final_objects {
1297            offsets[*id] = out.len();
1298            out.extend_from_slice(format!("{} 0 obj\n", id).as_bytes());
1299            out.extend_from_slice(data);
1300            out.extend_from_slice(b"\nendobj\n\n");
1301        }
1302
1303        let xref_offset = out.len();
1304        out.extend_from_slice(format!("xref\n0 {}\n", total_objects + 1).as_bytes());
1305        out.extend_from_slice(b"0000000000 65535 f \n");
1306        for id in 1..=total_objects {
1307            out.extend_from_slice(format!("{:010} 00000 n \n", offsets.get(id).copied().unwrap_or(0)).as_bytes());
1308        }
1309        out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", total_objects + 1, xref_offset).as_bytes());
1310        out
1311    }
1312}
1313
1314// ─── Utility Functions ──────────────────────────────────────────────
1315
1316fn fmt_f64(v: f64) -> String {
1317    if v == v.floor() { format!("{:.0}", v) } else { format!("{:.2}", v) }
1318}
1319
1320fn char_to_winansi(ch: char) -> u8 {
1321    match ch {
1322        '\u{FFFE}' => b'{', '\u{FFFF}' => b'}',
1323        '\u{2014}' => 0x97, '\u{2013}' => 0x96,
1324        '\u{2018}' => 0x91, '\u{2019}' => 0x92,
1325        '\u{201C}' => 0x93, '\u{201D}' => 0x94,
1326        '\u{2022}' => 0x95, '\u{2026}' => 0x85,
1327        '\u{2122}' => 0x99, '\u{00A9}' => 0xA9,
1328        '\u{00AE}' => 0xAE, '\u{00B0}' => 0xB0,
1329        '\u{20AC}' => 0x80,
1330        c if (c as u32) < 256 => c as u8,
1331        _ => b'?',
1332    }
1333}
1334
1335fn text_to_pdf_hex(text: &str) -> String {
1336    text.chars().map(|ch| format!("{:02X}", char_to_winansi(ch))).collect()
1337}
1338
1339fn parse_color(s: &str) -> (f64, f64, f64) {
1340    let s = s.trim_start_matches('#');
1341    if s.len() >= 6 {
1342        let r = u8::from_str_radix(&s[0..2], 16).unwrap_or(0) as f64 / 255.0;
1343        let g = u8::from_str_radix(&s[2..4], 16).unwrap_or(0) as f64 / 255.0;
1344        let b = u8::from_str_radix(&s[4..6], 16).unwrap_or(0) as f64 / 255.0;
1345        (r, g, b)
1346    } else {
1347        (0.0, 0.0, 0.0)
1348    }
1349}