Skip to main content

tdsl_render/
lib.rs

1//! Timeline DSL renderer: `TimelineIr` → standalone HTML with inline SVG.
2//!
3//! The public entry point is [`render_html`]. Internally it:
4//! 1. Computes a [`layout::LayoutModel`] from the IR.
5//! 2. Serializes it to an SVG string.
6//! 3. Wraps the SVG in an HTML document with embedded CSS (hover tooltips only, no JS).
7
8pub mod html;
9pub mod layout;
10#[cfg(feature = "pdf")]
11pub mod pdf;
12#[cfg(feature = "png")]
13pub mod png;
14pub mod svg;
15
16pub use layout::{GridStyle, LayoutModel, Orientation, RenderOptions, Theme};
17#[cfg(feature = "pdf")]
18pub use pdf::{PdfDate, PdfError, PdfOptions, PdfPageSize, render_pdf, svg_to_pdf};
19#[cfg(feature = "png")]
20pub use png::{PngError, PngOptions, render_png, svg_to_png};
21
22use tdsl_core::ir::TimelineIr;
23
24/// Render the given IR as a standalone HTML document string.
25pub fn render_html(ir: &TimelineIr, opts: RenderOptions) -> Result<String, std::fmt::Error> {
26    let layout = LayoutModel::compute(ir, opts.clone());
27    let svg = svg::render_svg(&layout)?;
28    let table_html = if opts.show_table {
29        Some(html::generate_table_html(ir, &ir.lanes))
30    } else {
31        None
32    };
33    if opts.interactive {
34        Ok(html::wrap_html_interactive(
35            &svg,
36            &ir.meta.title,
37            &opts,
38            &ir.lanes,
39            table_html.as_deref(),
40        ))
41    } else {
42        Ok(html::wrap_html(
43            &svg,
44            &ir.meta.title,
45            &opts,
46            table_html.as_deref(),
47        ))
48    }
49}
50
51/// Render the given IR as a standalone SVG string.
52pub fn render_svg_only(ir: &TimelineIr, opts: RenderOptions) -> Result<String, std::fmt::Error> {
53    let layout = LayoutModel::compute(ir, opts);
54    svg::render_svg(&layout)
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use tdsl_core::ir::{Item, Lane, Meta, TimelineIr};
61
62    fn sample_ir() -> TimelineIr {
63        TimelineIr {
64            meta: Meta {
65                title: "サンプル年表".into(),
66                unit: "year".into(),
67                range: (-300, 300),
68                calendar: "proleptic_gregorian".into(),
69                color_map: std::collections::HashMap::new(),
70                ..Default::default()
71            },
72            lanes: vec![Lane {
73                id: "han".into(),
74                label: "漢".into(),
75                kind: "dynasty".into(),
76                order: 10,
77                group: None,
78                source_span: None,
79            }],
80            items: vec![Item::Span {
81                id: "span:han".into(),
82                lane: "han".into(),
83                start: -206,
84                end: 220,
85                label: "漢".into(),
86                tags: vec!["dynasty".into()],
87                source: Some("wd:Q7209".into()),
88                origin: None,
89                start_month: None,
90                start_day: None,
91                end_month: None,
92                end_day: None,
93                source_span: None,
94            }],
95            imports: vec![],
96            sources: vec![],
97        }
98    }
99
100    #[test]
101    fn render_html_produces_complete_document() {
102        let ir = sample_ir();
103        let html = render_html(&ir, RenderOptions::default()).unwrap();
104        assert!(html.starts_with("<!DOCTYPE html>"));
105        assert!(html.contains("<svg"));
106        assert!(html.contains("</svg>"));
107        assert!(html.contains("サンプル年表"));
108        assert!(html.ends_with("</html>\n") || html.ends_with("</html>"));
109    }
110
111    // ─── Render integration tests ──────────────────────────────────────────
112
113    #[test]
114    fn render_html_contains_lane_label() {
115        let ir = sample_ir();
116        let html = render_html(&ir, RenderOptions::default()).unwrap();
117        assert!(html.contains("漢"));
118    }
119
120    #[test]
121    fn render_html_contains_span_label() {
122        let ir = sample_ir();
123        let html = render_html(&ir, RenderOptions::default()).unwrap();
124        assert!(html.contains("漢"));
125    }
126
127    #[test]
128    fn render_html_multiple_lanes_ordered_by_order_field() {
129        let ir = TimelineIr {
130            meta: Meta {
131                title: "Multi-lane".into(),
132                unit: "year".into(),
133                range: (0, 500),
134                calendar: "proleptic_gregorian".into(),
135                color_map: std::collections::HashMap::new(),
136                ..Default::default()
137            },
138            lanes: vec![
139                Lane {
140                    id: "b".into(),
141                    label: "B".into(),
142                    kind: "dynasty".into(),
143                    order: 20,
144                    group: None,
145                    source_span: None,
146                },
147                Lane {
148                    id: "a".into(),
149                    label: "A".into(),
150                    kind: "dynasty".into(),
151                    order: 10,
152                    group: None,
153                    source_span: None,
154                },
155            ],
156            items: vec![],
157            imports: vec![],
158            sources: vec![],
159        };
160        let html = render_html(&ir, RenderOptions::default()).unwrap();
161        // Both lanes should appear in output
162        let pos_a = html.find(">A<").or_else(|| html.find("A</"));
163        let pos_b = html.find(">B<").or_else(|| html.find("B</"));
164        assert!(pos_a.is_some() || html.contains("A"));
165        assert!(pos_b.is_some() || html.contains("B"));
166    }
167
168    #[test]
169    fn render_html_empty_ir_does_not_panic() {
170        let ir = TimelineIr {
171            meta: Meta {
172                title: "Empty".into(),
173                unit: "year".into(),
174                range: (0, 100),
175                calendar: "proleptic_gregorian".into(),
176                color_map: std::collections::HashMap::new(),
177                ..Default::default()
178            },
179            lanes: vec![],
180            items: vec![],
181            imports: vec![],
182            sources: vec![],
183        };
184        let html = render_html(&ir, RenderOptions::default()).unwrap();
185        assert!(html.contains("Empty"));
186    }
187
188    #[test]
189    fn render_html_event_item_appears_in_output() {
190        let ir = TimelineIr {
191            meta: Meta {
192                title: "Events".into(),
193                unit: "year".into(),
194                range: (0, 500),
195                calendar: "proleptic_gregorian".into(),
196                color_map: std::collections::HashMap::new(),
197                ..Default::default()
198            },
199            lanes: vec![Lane {
200                id: "politics".into(),
201                label: "政治".into(),
202                kind: "custom".into(),
203                order: 1,
204                group: None,
205                source_span: None,
206            }],
207            items: vec![Item::Event {
208                id: "event:politics:100".into(),
209                lane: "politics".into(),
210                time: 100,
211                label: "即位".into(),
212                tags: vec![],
213                source: None,
214                origin: None,
215                time_month: None,
216                time_day: None,
217                source_span: None,
218            }],
219            imports: vec![],
220            sources: vec![],
221        };
222        let html = render_html(&ir, RenderOptions::default()).unwrap();
223        assert!(html.contains("即位"));
224    }
225
226    #[test]
227    fn render_html_event_range_item_appears_in_output() {
228        let ir = TimelineIr {
229            meta: Meta {
230                title: "Ranges".into(),
231                unit: "year".into(),
232                range: (0, 500),
233                calendar: "proleptic_gregorian".into(),
234                color_map: std::collections::HashMap::new(),
235                ..Default::default()
236            },
237            lanes: vec![Lane {
238                id: "war".into(),
239                label: "戦争".into(),
240                kind: "custom".into(),
241                order: 1,
242                group: None,
243                source_span: None,
244            }],
245            items: vec![Item::EventRange {
246                id: "event_range:war:100".into(),
247                lane: "war".into(),
248                start: 100,
249                end: 200,
250                label: "大乱".into(),
251                tags: vec![],
252                source: None,
253                origin: None,
254                start_month: None,
255                start_day: None,
256                end_month: None,
257                end_day: None,
258                source_span: None,
259            }],
260            imports: vec![],
261            sources: vec![],
262        };
263        let html = render_html(&ir, RenderOptions::default()).unwrap();
264        assert!(html.contains("大乱"));
265    }
266
267    #[test]
268    fn render_html_custom_scale_changes_width() {
269        let ir = sample_ir();
270        let opts_narrow = RenderOptions {
271            scale: 1.0,
272            ..RenderOptions::default()
273        };
274        let opts_wide = RenderOptions {
275            scale: 5.0,
276            ..RenderOptions::default()
277        };
278        let narrow = render_html(&ir, opts_narrow).unwrap();
279        let wide = render_html(&ir, opts_wide).unwrap();
280        // wider scale → larger viewBox width
281        assert_ne!(narrow, wide);
282    }
283
284    #[test]
285    fn render_html_interactive_contains_script_tag() {
286        let ir = sample_ir();
287        let opts = RenderOptions {
288            interactive: true,
289            ..RenderOptions::default()
290        };
291        let html = render_html(&ir, opts).unwrap();
292        assert!(
293            html.contains("<script>"),
294            "interactive mode must include <script>"
295        );
296        assert!(
297            html.contains("tdsl-search"),
298            "interactive mode must include search input"
299        );
300        assert!(
301            html.contains("tdsl-legend"),
302            "interactive mode must include legend"
303        );
304        assert!(
305            html.contains("tdsl-detail"),
306            "interactive mode must include detail panel"
307        );
308        assert!(
309            html.contains("data-label="),
310            "interactive mode must include data-label attributes on SVG items"
311        );
312    }
313
314    // ─── render_svg_only golden tests ────────────────────────────────────
315
316    #[test]
317    fn render_svg_only_year_precision_basic_structure() {
318        // 年精度のみのシンプルな IR で SVG の基本構造を検証
319        let ir = TimelineIr {
320            meta: Meta {
321                title: "年精度テスト".into(),
322                unit: "year".into(),
323                range: (-300, 300),
324                calendar: "proleptic_gregorian".into(),
325                color_map: std::collections::HashMap::new(),
326                ..Default::default()
327            },
328            lanes: vec![Lane {
329                id: "han".into(),
330                label: "漢".into(),
331                kind: "dynasty".into(),
332                order: 10,
333                group: None,
334                source_span: None,
335            }],
336            items: vec![
337                Item::Span {
338                    id: "span:han".into(),
339                    lane: "han".into(),
340                    start: -206,
341                    end: 220,
342                    label: "漢".into(),
343                    tags: vec![],
344                    source: None,
345                    origin: None,
346                    start_month: None,
347                    start_day: None,
348                    end_month: None,
349                    end_day: None,
350                    source_span: None,
351                },
352                Item::Event {
353                    id: "event:1".into(),
354                    lane: "han".into(),
355                    time: 0,
356                    label: "紀元".into(),
357                    tags: vec![],
358                    source: None,
359                    origin: None,
360                    time_month: None,
361                    time_day: None,
362                    source_span: None,
363                },
364            ],
365            imports: vec![],
366            sources: vec![],
367        };
368        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
369        // SVG の基本構造
370        assert!(svg.starts_with("<svg"), "SVG should start with <svg");
371        assert!(svg.contains("</svg>"), "SVG should end with </svg>");
372        // span と event が含まれる
373        assert!(svg.contains("tdsl-span"), "should contain span element");
374        assert!(
375            svg.contains("tdsl-event-dot"),
376            "should contain event element"
377        );
378        // レーンラベルが含まれる
379        assert!(svg.contains("漢"), "should contain lane label");
380    }
381
382    #[test]
383    fn render_svg_only_month_day_mix_precision() {
384        // 月日精度ミックス: start_month / time_month を持つアイテムが正常に SVG に出力されること
385        let ir = TimelineIr {
386            meta: Meta {
387                title: "月日精度テスト".into(),
388                unit: "year".into(),
389                range: (1939, 1946),
390                calendar: "proleptic_gregorian".into(),
391                color_map: std::collections::HashMap::new(),
392                ..Default::default()
393            },
394            lanes: vec![Lane {
395                id: "ww2".into(),
396                label: "WW2".into(),
397                kind: "conflict".into(),
398                order: 10,
399                group: None,
400                source_span: None,
401            }],
402            items: vec![
403                Item::Span {
404                    id: "span:ww2".into(),
405                    lane: "ww2".into(),
406                    start: 1939,
407                    end: 1945,
408                    label: "第二次世界大戦".into(),
409                    tags: vec![],
410                    source: None,
411                    origin: None,
412                    start_month: Some(9),
413                    start_day: Some(1),
414                    end_month: Some(9),
415                    end_day: Some(2),
416                    source_span: None,
417                },
418                Item::Event {
419                    id: "event:normandy".into(),
420                    lane: "ww2".into(),
421                    time: 1944,
422                    label: "ノルマンディー上陸".into(),
423                    tags: vec![],
424                    source: None,
425                    origin: None,
426                    time_month: Some(6),
427                    time_day: Some(6),
428                    source_span: None,
429                },
430            ],
431            imports: vec![],
432            sources: vec![],
433        };
434        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
435        assert!(svg.starts_with("<svg"), "SVG should start with <svg");
436        assert!(svg.contains("tdsl-span"), "should contain span element");
437        assert!(
438            svg.contains("tdsl-event-dot"),
439            "should contain event element"
440        );
441        // 月日精度がツールチップに反映される
442        assert!(
443            svg.contains("1939 Sep"),
444            "month-precision start should appear in tooltip, got SVG length={}",
445            svg.len()
446        );
447    }
448
449    #[test]
450    fn render_svg_has_tdsl_root_class_on_root_element() {
451        let ir = sample_ir();
452        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
453        assert!(
454            svg.contains(r#"class="tdsl-root""#),
455            "SVG root must have class=tdsl-root for CSS scoping"
456        );
457        assert!(
458            svg.contains(".tdsl-root text"),
459            "SVG style must scope font via .tdsl-root text"
460        );
461    }
462
463    #[test]
464    fn render_svg_custom_font_family_appears_in_style() {
465        let ir = sample_ir();
466        let opts = RenderOptions {
467            font_family: Some("Arial, sans-serif".to_string()),
468            ..RenderOptions::default()
469        };
470        let svg = render_svg_only(&ir, opts).unwrap();
471        assert!(
472            svg.contains("Arial, sans-serif"),
473            "custom font_family must appear in SVG style"
474        );
475    }
476
477    // ─── 垂直レイアウト テスト ──────────────────────────────────────────
478
479    #[test]
480    fn vertical_layout_dimensions_are_swapped() {
481        // 垂直レイアウトでは time_span が高さ方向に反映され、
482        // lanes が幅方向に積まれる (水平と total_width/total_height が入れ替わる)。
483        let ir = TimelineIr {
484            meta: Meta {
485                title: "vert test".into(),
486                unit: "year".into(),
487                range: (0, 1000),
488                calendar: "proleptic_gregorian".into(),
489                color_map: std::collections::HashMap::new(),
490                ..Default::default()
491            },
492            lanes: vec![
493                Lane {
494                    id: "a".into(),
495                    label: "A".into(),
496                    kind: "k".into(),
497                    order: 1,
498                    group: None,
499                    source_span: None,
500                },
501                Lane {
502                    id: "b".into(),
503                    label: "B".into(),
504                    kind: "k".into(),
505                    order: 2,
506                    group: None,
507                    source_span: None,
508                },
509            ],
510            items: vec![],
511            imports: vec![],
512            sources: vec![],
513        };
514        let opts_h = RenderOptions::default(); // horizontal
515        let opts_v = RenderOptions {
516            orientation: Orientation::Vertical,
517            ..RenderOptions::default()
518        };
519        let layout_h = LayoutModel::compute(&ir, opts_h.clone());
520        let layout_v = LayoutModel::compute(&ir, opts_v.clone());
521
522        // 水平: 幅 = left_gutter + 1000*scale + right_margin
523        //       高さ = top_margin + 2*lane_height + bottom_margin
524        let expected_h_w = opts_h.left_gutter + 1000.0 * opts_h.scale + opts_h.right_margin;
525        let expected_h_h = opts_h.top_margin + 2.0 * opts_h.lane_height + opts_h.bottom_margin;
526        assert!(
527            (layout_h.total_width - expected_h_w).abs() < 0.01,
528            "horizontal width mismatch: {} vs {}",
529            layout_h.total_width,
530            expected_h_w
531        );
532        assert!(
533            (layout_h.total_height - expected_h_h).abs() < 0.01,
534            "horizontal height mismatch: {} vs {}",
535            layout_h.total_height,
536            expected_h_h
537        );
538
539        // 垂直: 幅 = left_gutter + 2*lane_height + right_margin
540        //       高さ = top_margin + 1000*scale + bottom_margin
541        let expected_v_w = opts_v.left_gutter + 2.0 * opts_v.lane_height + opts_v.right_margin;
542        let expected_v_h = opts_v.top_margin + 1000.0 * opts_v.scale + opts_v.bottom_margin;
543        assert!(
544            (layout_v.total_width - expected_v_w).abs() < 0.01,
545            "vertical width mismatch: {} vs {}",
546            layout_v.total_width,
547            expected_v_w
548        );
549        assert!(
550            (layout_v.total_height - expected_v_h).abs() < 0.01,
551            "vertical height mismatch: {} vs {}",
552            layout_v.total_height,
553            expected_v_h
554        );
555
556        // 垂直では水平と幅・高さが入れ替わっている(大小関係)
557        assert!(
558            layout_v.total_height > layout_v.total_width,
559            "vertical: height should exceed width for a long time span with few lanes"
560        );
561    }
562
563    #[test]
564    fn vertical_svg_contains_expected_orientation_markers() {
565        // 垂直 SVG が時間軸ティックを Y 方向に配置することを確認する:
566        // - 水平軸のベースライン (y1=y2 の水平線) の代わりに垂直ベースライン (x1=x2) が含まれる
567        // - レーンラベルが上部に配置される (y 値が top_margin 付近)
568        let ir = sample_ir();
569        let opts = RenderOptions {
570            orientation: Orientation::Vertical,
571            ..RenderOptions::default()
572        };
573        let svg = render_svg_only(&ir, opts.clone()).unwrap();
574        // SVG の基本構造
575        assert!(svg.starts_with("<svg"), "SVG should start with <svg");
576        assert!(svg.contains("</svg>"), "SVG should end with </svg>");
577        // 垂直ベースライン: x1=x2 (左端の縦線) が存在するはず
578        assert!(
579            svg.contains(r#"class="tdsl-axis-baseline""#),
580            "vertical SVG must contain axis baseline element"
581        );
582        // span アイテムが含まれる
583        assert!(svg.contains("tdsl-span"), "should contain span element");
584    }
585
586    #[test]
587    fn vertical_svg_span_item_dimensions_are_vertical() {
588        // 垂直レイアウトでは Span の height (時間軸方向) が width より大きくなる (長い span の場合)
589        let ir = TimelineIr {
590            meta: Meta {
591                title: "v-span".into(),
592                unit: "year".into(),
593                range: (0, 500),
594                calendar: "proleptic_gregorian".into(),
595                color_map: std::collections::HashMap::new(),
596                ..Default::default()
597            },
598            lanes: vec![Lane {
599                id: "x".into(),
600                label: "X".into(),
601                kind: "k".into(),
602                order: 1,
603                group: None,
604                source_span: None,
605            }],
606            items: vec![Item::Span {
607                id: "s1".into(),
608                lane: "x".into(),
609                start: 100,
610                end: 400,
611                label: "long span".into(),
612                tags: vec![],
613                source: None,
614                origin: None,
615                start_month: None,
616                start_day: None,
617                end_month: None,
618                end_day: None,
619                source_span: None,
620            }],
621            imports: vec![],
622            sources: vec![],
623        };
624        let opts = RenderOptions {
625            orientation: Orientation::Vertical,
626            scale: 2.0,
627            ..RenderOptions::default()
628        };
629        let layout = LayoutModel::compute(&ir, opts);
630        let span = layout.items.iter().find_map(|i| match i {
631            crate::layout::LaidItem::Span { width, height, .. } => Some((*width, *height)),
632            _ => None,
633        });
634        let (w, h) = span.expect("span should be laid out");
635        // 垂直レイアウトでは height (時間方向) >> width (レーン幅方向)
636        assert!(
637            h > w,
638            "vertical span height ({h}) should exceed width ({w}) for a multi-century span"
639        );
640    }
641
642    #[test]
643    fn render_html_non_interactive_unchanged_behavior() {
644        let ir = sample_ir();
645        let opts_default = RenderOptions::default();
646        let opts_explicit = RenderOptions {
647            interactive: false,
648            ..RenderOptions::default()
649        };
650        let html_default = render_html(&ir, opts_default).unwrap();
651        let html_explicit = render_html(&ir, opts_explicit).unwrap();
652        // interactive:false (default) should produce identical output to explicit false
653        assert_eq!(html_default, html_explicit);
654        // should NOT include interactive-only elements
655        assert!(
656            !html_default.contains("tdsl-search"),
657            "non-interactive mode must not include search input"
658        );
659        assert!(
660            !html_default.contains("tdsl-legend"),
661            "non-interactive mode must not include legend"
662        );
663    }
664
665    // ─── group ヘッダー描画テスト ────────────────────────────────
666
667    fn grouped_ir() -> TimelineIr {
668        TimelineIr {
669            meta: Meta {
670                title: "グループテスト".into(),
671                unit: "year".into(),
672                range: (0, 100),
673                calendar: "proleptic_gregorian".into(),
674                color_map: std::collections::HashMap::new(),
675                ..Default::default()
676            },
677            lanes: vec![
678                Lane {
679                    id: "a".into(),
680                    label: "A".into(),
681                    kind: "custom".into(),
682                    order: 1,
683                    group: Some("グループ1".into()),
684                    source_span: None,
685                },
686                Lane {
687                    id: "b".into(),
688                    label: "B".into(),
689                    kind: "custom".into(),
690                    order: 2,
691                    group: Some("グループ1".into()),
692                    source_span: None,
693                },
694                Lane {
695                    id: "c".into(),
696                    label: "C".into(),
697                    kind: "custom".into(),
698                    order: 10,
699                    group: None,
700                    source_span: None,
701                },
702            ],
703            items: vec![],
704            imports: vec![],
705            sources: vec![],
706        }
707    }
708
709    #[test]
710    fn render_svg_grouped_lanes_contains_group_label() {
711        let ir = grouped_ir();
712        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
713        assert!(
714            svg.contains("グループ1"),
715            "SVG must contain the group label 'グループ1'"
716        );
717        assert!(
718            svg.contains("tdsl-group-label"),
719            "SVG must contain the tdsl-group-label class"
720        );
721    }
722
723    #[test]
724    fn render_svg_no_group_label_when_no_groups() {
725        let ir = sample_ir();
726        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
727        assert!(
728            !svg.contains("tdsl-group-label"),
729            "SVG must not contain group labels when no lanes have groups"
730        );
731    }
732
733    #[test]
734    fn render_svg_group_separator_present() {
735        let ir = grouped_ir();
736        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
737        assert!(
738            svg.contains("tdsl-group-separator"),
739            "SVG must contain the tdsl-group-separator element"
740        );
741    }
742
743    #[test]
744    fn render_html_grouped_lanes_contains_group_label() {
745        let ir = grouped_ir();
746        let html = render_html(&ir, RenderOptions::default()).unwrap();
747        assert!(
748            html.contains("グループ1"),
749            "HTML must contain the group label 'グループ1'"
750        );
751    }
752
753    // ─── show_table tests ─────────────────────────────────────────────────
754
755    #[test]
756    fn render_html_show_table_false_no_table() {
757        let ir = sample_ir();
758        let opts = RenderOptions {
759            show_table: false,
760            ..RenderOptions::default()
761        };
762        let html = render_html(&ir, opts).unwrap();
763        assert!(
764            !html.contains("<div class=\"tdsl-table-wrap\">"),
765            "show_table=false must not include the table-wrap div element"
766        );
767        assert!(
768            !html.contains("<table class=\"tdsl-table\""),
769            "show_table=false must not include table element"
770        );
771    }
772
773    #[test]
774    fn render_html_show_table_true_includes_table() {
775        let ir = sample_ir();
776        let opts = RenderOptions {
777            show_table: true,
778            ..RenderOptions::default()
779        };
780        let html = render_html(&ir, opts).unwrap();
781        assert!(
782            html.contains("<div class=\"tdsl-table-wrap\">"),
783            "show_table=true must include the table-wrap div element"
784        );
785        assert!(
786            html.contains("<table class=\"tdsl-table\""),
787            "show_table=true must include table element"
788        );
789        // item label "漢" must be in the table
790        assert!(
791            html.contains("漢"),
792            "show_table=true must include item label in table"
793        );
794    }
795
796    // ─── Golden SVG snapshot tests ─────────────────────────────────────────
797
798    /// Read an example file relative to the workspace root.
799    fn read_example(name: &str) -> String {
800        let path = format!("../../examples/{name}");
801        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
802    }
803
804    #[test]
805    fn snapshot_china_dynasties_svg() {
806        let src = read_example("china_dynasties.tdsl");
807        let file = tdsl_parser::parse(&src).unwrap();
808        let ir = tdsl_core::lower::lower_static(&file).unwrap();
809        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
810        insta::assert_snapshot!(svg);
811    }
812
813    #[test]
814    fn snapshot_world_wars_svg() {
815        let src = read_example("world_wars.tdsl");
816        let file = tdsl_parser::parse(&src).unwrap();
817        let ir = tdsl_core::lower::lower_static(&file).unwrap();
818        let svg = render_svg_only(&ir, RenderOptions::default()).unwrap();
819        insta::assert_snapshot!(svg);
820    }
821}