1pub 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
24pub 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
51pub 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 #[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 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 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 #[test]
317 fn render_svg_only_year_precision_basic_structure() {
318 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 assert!(svg.starts_with("<svg"), "SVG should start with <svg");
371 assert!(svg.contains("</svg>"), "SVG should end with </svg>");
372 assert!(svg.contains("tdsl-span"), "should contain span element");
374 assert!(
375 svg.contains("tdsl-event-dot"),
376 "should contain event element"
377 );
378 assert!(svg.contains("漢"), "should contain lane label");
380 }
381
382 #[test]
383 fn render_svg_only_month_day_mix_precision() {
384 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 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 #[test]
480 fn vertical_layout_dimensions_are_swapped() {
481 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(); 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 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 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 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 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 assert!(svg.starts_with("<svg"), "SVG should start with <svg");
576 assert!(svg.contains("</svg>"), "SVG should end with </svg>");
577 assert!(
579 svg.contains(r#"class="tdsl-axis-baseline""#),
580 "vertical SVG must contain axis baseline element"
581 );
582 assert!(svg.contains("tdsl-span"), "should contain span element");
584 }
585
586 #[test]
587 fn vertical_svg_span_item_dimensions_are_vertical() {
588 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 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 assert_eq!(html_default, html_explicit);
654 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 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 #[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 assert!(
791 html.contains("漢"),
792 "show_table=true must include item label in table"
793 );
794 }
795
796 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}