-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathrender_slide_spec.py
More file actions
1774 lines (1577 loc) · 82.2 KB
/
Copy pathrender_slide_spec.py
File metadata and controls
1774 lines (1577 loc) · 82.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Render a slide spec JSON file into a styled 16:9 SVG slide.
Implements the visual system in references/style-system.md for a subset of
patterns from references/visualization-patterns.md (22 total):
waterfall, gap, before_after, time_series, benchmark_table,
summary_strip, process_flow, funnel, heatmap, gantt, kpi_scorecard,
two_by_two, scatter, distribution, small_multiples, cover,
section_divider, end_cover, agenda, bullet_list, closing, quote
The last six are structural slide furniture (dividers, agenda, action-title
bullets, closing/next-steps, quote, back cover) rather than charts.
section_divider and end_cover are full-bleed navy slides that bypass the
standard header/footer chrome (see CHROMELESS); the rest use it like any
chart pattern.
Patterns not in this list are spec-only: the skill produces a structured spec
or an image-generation prompt for them, not an SVG (see SKILL.md).
Usage:
python3 scripts/render_slide_spec.py examples/render-specs/arr-waterfall.json -o out.svg
"""
from __future__ import annotations
import argparse
import json
import sys
import unicodedata
from pathlib import Path
# Canvas — design tokens (single source: references/style-system.md "Design Tokens")
# Base grid unit: 8px. Margins and anchors sit on the grid; chart geometry is data-driven.
W, H = 1280, 720
ML, MR = 80, 80
CHART_TOP, CHART_BOTTOM = 208, 560
# Type scale — a ratio system (references/style-system.md "Typography"), not a
# grab-bag of pixel values: headline : body : chrome holds roughly 4 : 1.6 : 1
# across the deck span (asserted in tests/test_render_slide_spec.py). Every
# text-drawing call site below references one of these tokens; naked size
# literals for a text role are a bug. Two roles stay deliberately small on
# purpose and are exempt from the "reading floor" below: T_TICK (axis ticks /
# range numerals / funnel conversion %, which are referenced, not read at
# length) and T_CHROME (source line, footnotes, page number, classification —
# whisper-small by design, never raised further). Every other text role has a
# floor of T_LABEL (18px).
T_COVER_TITLE = 54 # cover / end_cover title (serif)
T_DIVIDER_TITLE = 48 # section_divider title (serif)
T_HEADLINE = 40 # content headline, <=2 lines (serif bold)
T_HEADLINE_DENSE = 32 # content headline, 3 lines (serif bold)
T_STATEMENT = 32 # quote text (serif); KPI-like big statements
T_KPI_NUM = 44 # kpi_scorecard main numbers (the hero layer)
T_SUBLINE = 20 # headline subline; cover/divider/end_cover subtitle
T_BODY = 22 # bullets, claims, takeaways, actions, agenda item
# titles, process step titles, benchmark row labels
T_LABEL = 18 # reading labels: sub-bullets, proofs/implications,
# agenda details, owner-timing metas, chart
# category/axis/value labels, gantt row labels,
# small-multiples labels, rail items on dividers
T_NUM_AGENDA = 28 # agenda numbers (serif navy)
T_NUM_CLOSING = 26 # closing takeaway numbers (serif navy)
T_TICK = 14 # axis ticks, min/max range numerals, funnel
# conversion %; heatmap/benchmark cell values may
# hold a size between this and T_LABEL when cells
# are tight, but never drop below this floor
T_KICKER_LABEL = 15 # structural small-caps labels (SECTION NN, KEY
# TAKEAWAYS, NEXT STEPS), letter-spaced
T_ANNOTATION = 22 # footer takeaway/annotation line (weight 600, BLUE)
T_CHROME = 13 # source line, footnotes, page number,
# classification — deliberately the smallest text
# on the slide; do not raise it
# Multi-line leading, derived once per token so every wrapped paragraph using
# a given text role reads at the same leading across the whole deck (rather
# than each renderer inventing its own). HALF_LINE_* is exactly half the
# advance, used to vertically center an N-line block within a fixed row: a
# 1-line block gets no offset, a 2-line block is nudged up by one half-step,
# and so on — see render_gap/render_funnel/render_heatmap/render_gantt.
LINE_H_BODY = 30 # T_BODY paragraphs (was 24 at the old 16-17px body)
LINE_H_LABEL = 24 # T_LABEL paragraphs (was ~17-20 at the old 13-15px label)
HALF_LINE_BODY = LINE_H_BODY // 2
HALF_LINE_LABEL = LINE_H_LABEL // 2
NUDGE_BODY = 9 # baseline nudge centering one T_BODY line in a row
NUDGE_LABEL = 6 # baseline nudge centering one T_LABEL line in a row
X_AXIS_LABEL_LEAD = 18 # gap from CHART_BOTTOM to a below-axis category
# label's first line (waterfall/before_after/
# distribution): keeps a 2-line label's descender
# clear of the footer annotation band (y=630) —
# verified by scripts/render_slide_spec.py's
# renderers against real specs with both a 2-line
# label and an annotation present
# Palette (references/style-system.md)
# BLUE is deliberately darker than Tailwind blue-900 so that rung-1 fills stay
# distinguishable from GREY_DARK body text in greyscale print (relative-luminance
# ratio >= 1.5, asserted in tests).
BLUE = "#15296B"
BLUE2 = "#2563EB"
BLACK = "#000000"
GREY_DARK = "#374151"
GREY_MED = "#6B7280"
GREY_BORDER = "#D1D5DB"
GREY_FILL = "#F3F4F6"
RED = "#B91C1C"
RED_TINT = "#FBEAEA"
BLUE_TINT = "#EFF3FB"
NAVY_COVER = BLUE # single navy across content and cover slides
WHITE = "#FFFFFF"
SERIF = "Georgia, 'Times New Roman', 'Hiragino Mincho ProN', 'Yu Mincho', serif"
# System Japanese faces come before Noto Sans JP: on machines where Noto is
# only partially installed (commonly just the Black weight), listing it first
# captures body text and renders everything ultra-bold.
SANS = (
"'Helvetica Neue', Helvetica, Arial, "
"'Hiragino Sans', 'Yu Gothic', 'Noto Sans JP', 'Meiryo', sans-serif"
)
ELLIPSIS = "…"
def _rel_luminance(hex_color: str) -> float:
"""WCAG 2.x relative luminance of a #RRGGBB color."""
channels = [int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)]
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def contrast_ratio(color_a: str, color_b: str) -> float:
la, lb = _rel_luminance(color_a), _rel_luminance(color_b)
lighter, darker = max(la, lb), min(la, lb)
return (lighter + 0.05) / (darker + 0.05)
def _cell_text_color(cell_fill: str) -> str:
"""Pick black or white text, whichever clears the higher contrast on the fill."""
return BLACK if contrast_ratio(BLACK, cell_fill) >= contrast_ratio(WHITE, cell_fill) else WHITE
class RenderSpecError(ValueError):
"""Raised when a slide spec is structurally invalid."""
def esc(value: object) -> str:
return (
str(value)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def _char_width(char: str) -> int:
"""Approximate display width in half-width units (CJK/fullwidth = 2)."""
return 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
def _text_width(text: str) -> int:
return sum(_char_width(c) for c in text)
def _tokens(text: str) -> list[tuple[str, bool]]:
"""Split text into wrap tokens as (token, needs_leading_space) pairs.
Whitespace-separated chunks stay word-wrapped; runs of CJK characters are
breakable per character so Japanese/Chinese text wraps instead of
overflowing the canvas.
"""
tokens: list[tuple[str, bool]] = []
for chunk in str(text).split():
first_in_chunk = True
run = ""
kata = ""
for char in chunk:
if _is_katakana(char):
# Katakana loanwords never break mid-word (「オンボーディ/ング」
# is a typographic defect) — a run wraps as one token.
if run:
tokens.append((run, first_in_chunk))
run, first_in_chunk = "", False
kata += char
continue
if kata:
tokens.append((kata, first_in_chunk))
kata, first_in_chunk = "", False
if _char_width(char) == 2:
if run:
tokens.append((run, first_in_chunk))
run, first_in_chunk = "", False
tokens.append((char, first_in_chunk))
first_in_chunk = False
else:
run += char
if kata:
tokens.append((kata, first_in_chunk))
elif run:
tokens.append((run, first_in_chunk))
return tokens
def _is_katakana(char: str) -> bool:
"""Fullwidth katakana and the long-vowel mark; the middle dot (・)
stays out so it remains a legitimate break point between words."""
return ("ァ" <= char <= "ヺ") or char == "ー"
# Line-start kinsoku (行頭禁則): closing punctuation that must not begin a
# line. Resolved by hanging it off the previous line (ぶら下がり組) — a
# one-character overhang reads better than orphaned punctuation.
KINSOKU_HEAD = "。、.,)」』】〉》〕!?"
# Line-end kinsoku (行末禁則): opening brackets that must not end a line —
# they move down to rejoin what they open.
KINSOKU_TAIL = "(「『【〈《〔"
# Bunsetsu-ish break preference: when a Japanese line has to break, breaking
# right after one of these (punctuation or a particle) reads as a phrase
# boundary; breaking mid-word (「規定す/る」) is a defect. Heuristic, not
# morphology — but particles ARE where Japanese phrases end, and the
# backtrack is capped so a boundary-poor line still fills its width.
_BREAK_AFTER = "、。.,)」』】〉》〕!?:;・のはがをにへとでも"
def _prefer_boundary(line: str, min_width: int) -> tuple[str, str]:
"""Split a full line at the rightmost phrase boundary that keeps the
line at least min_width wide. Returns (line, carry-to-next-line);
carry is empty when no acceptable boundary exists (line stays as-is)."""
for k in range(len(line) - 1, 0, -1):
if line[k - 1] in _BREAK_AFTER and _text_width(line[:k]) >= min_width:
return line[:k], line[k:]
return line, ""
def _apply_kinsoku(lines: list[str]) -> list[str]:
# Tail pass first: an opener at a line end moves down to what it opens.
for i in range(len(lines) - 1):
while lines[i] and lines[i][-1] in KINSOKU_TAIL:
lines[i + 1] = lines[i][-1] + lines[i + 1]
lines[i] = lines[i][:-1]
for i in range(1, len(lines)):
moved = ""
while lines[i] and lines[i][0] in KINSOKU_HEAD:
moved += lines[i][0]
lines[i] = lines[i][1:]
if moved:
lines[i - 1] += moved
return [line for line in lines if line]
def wrap(text: str, width: int, max_lines: int = 0) -> list[str]:
"""Wrap text to a width given in half-width character units.
ASCII counts 1 per character, CJK counts 2, so existing English widths keep
their meaning while Japanese wraps at roughly half the character count.
Closing punctuation (。、」 …) never starts a line: it hangs off the end of
the previous line instead (line-start kinsoku). When max_lines > 0 the
result is clamped and a trailing ellipsis marks any dropped content —
nothing is truncated silently.
"""
lines: list[str] = []
current = ""
for token, needs_space in _tokens(text):
candidate = f"{current} {token}" if (current and needs_space) else f"{current}{token}"
if _text_width(candidate) > width and current:
# Break at a phrase boundary when one exists in the last 40% of
# the line (bunsetsu-ish wrapping); the carry rejoins the next
# line so no character is lost.
head, carry = _prefer_boundary(current, max(int(width * 0.6), 1))
lines.append(head)
current = f"{carry} {token}" if (carry and needs_space) else f"{carry}{token}"
else:
current = candidate
while _text_width(current) > width:
# Hard-break tokens with no break opportunity (URLs, codes, IDs)
# instead of letting them overflow the canvas.
head, rest = current, ""
while head and _text_width(head) > width:
head, rest = head[:-1], head[-1] + rest
lines.append(head)
current = rest
if current:
lines.append(current)
lines = _apply_kinsoku(lines)
if max_lines and len(lines) > max_lines:
kept = lines[:max_lines]
last = kept[-1]
while last and _text_width(last + ELLIPSIS) > width:
last = last[:-1].rstrip()
kept[-1] = last + ELLIPSIS
return kept
return lines
def fmt(value: float, unit: str) -> str:
magnitude = abs(value)
text = f"{magnitude:,.1f}".rstrip("0").rstrip(".") if isinstance(value, float) else f"{magnitude:,}"
sign = "-" if value < 0 else ""
if not unit:
return f"{sign}{text}"
if unit[0] in "$€£¥":
return f"{sign}{unit[0]}{text}{unit[1:]}"
return f"{sign}{text}{unit}"
def text_el(
x: float,
y: float,
content: str,
size: int = 14,
fill: str = BLACK,
weight: str = "normal",
family: str = SANS,
anchor: str = "start",
title: str = "",
) -> str:
title_el = f"<title>{esc(title)}</title>" if title else ""
return (
f'<text x="{x:.1f}" y="{y:.1f}" font-family="{family}" font-size="{size}" '
f'fill="{fill}" font-weight="{weight}" text-anchor="{anchor}">{title_el}{esc(content)}</text>'
)
def rect_el(x: float, y: float, w: float, h: float, fill: str, stroke: str = "none") -> str:
return (
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'fill="{fill}" stroke="{stroke}"/>'
)
def line_el(x1: float, y1: float, x2: float, y2: float, stroke: str = GREY_BORDER, dash: str = "") -> str:
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{stroke}"{dash_attr}/>'
def header(spec: dict) -> list[str]:
# No decorative marks: the headline itself anchors the slide (the former
# navy kicker bar above it carried no information and was removed —
# data-ink rule, see style-system.md Ink Discipline).
parts: list[str] = []
headline = spec.get("headline", "")
lines = wrap(headline, 48) # 64 * 30/40 (old size 30 -> T_HEADLINE)
size, line_h, subline_gap = T_HEADLINE, 52, 28
if len(lines) > 2:
lines = wrap(headline, 60, max_lines=3) # 80 * 24/32 (old size 24 -> T_HEADLINE_DENSE)
size, line_h, subline_gap = T_HEADLINE_DENSE, 42, 24
y = 96
last_line_y = y
for line in lines:
parts.append(text_el(ML, y, line, size=size, weight="bold", family=SERIF, title=headline if len(lines) > 2 else ""))
last_line_y = y
y += line_h
subline = spec.get("subline", "")
if subline:
# Anchored to the last *drawn* headline line, not the post-loop `y`
# (which already carries one unused extra line_h). The 2-line case
# gets a 28px gap — enough to clear a descender (p/g/y/q/j) on the
# headline's last line against the subline's own ascender, verified
# by rendering (see deal-size-distribution.svg, whose "roadmap"
# descender touched "Closed-won..." at the smaller gap this replaced).
# 28, not more: scatter/two_by_two draw their own y-axis-label
# caption just above CHART_TOP (see render_scatter/render_two_by_two),
# and a bigger subline gap pushes the subline down into that caption
# instead — see those renderers' `-9` offset, tuned against this
# exact gap. The 3-line dense case gets a tighter 24px gap instead of
# 28, because CHART_TOP=208 leaves no room for more: this keeps the
# tightest case (3-line dense + subline) at baseline 204, 4px clear
# of the chart band, accepting a closer (but still non-overlapping)
# fit as the deliberate tradeoff for that rare combination.
parts.append(text_el(ML, last_line_y + subline_gap, subline, size=T_SUBLINE, fill=GREY_MED))
classification = spec.get("classification", "")
if classification:
# Chrome: deliberately the smallest text on the slide, not scaled up.
parts.append(text_el(W - MR, 40, classification.upper(), size=T_CHROME, fill=GREY_MED, weight="600", anchor="end"))
return parts
def footer(spec: dict) -> list[str]:
parts: list[str] = []
annotation = spec.get("annotation", "")
if annotation:
# Emphasis through typography only — no decorative accent bar (data-ink rule).
y = 630
for line in wrap(annotation, 81, max_lines=2): # 112 * 16/22 (old size 16 -> T_ANNOTATION)
parts.append(text_el(ML, y, line, size=T_ANNOTATION, fill=BLUE, weight="600", title=annotation))
y += 30 # 22 * 22/16, scaled with T_ANNOTATION
footnotes = spec.get("footnotes", [])[:2]
source = spec.get("source", "")
note_y = 692 - 18 * len(footnotes) # 16 * 13/11, scaled with T_CHROME
for i, note in enumerate(footnotes):
marker = "¹²"[i]
parts.append(
text_el(ML, note_y, f"{marker} {wrap(note, 126, max_lines=1)[0]}", size=T_CHROME, fill=GREY_MED, title=note) # 150 * 11/13
)
note_y += 18
if source:
# Chrome: source/footnotes/page number stay the smallest text on the slide.
parts.append(text_el(ML, 692, source, size=T_CHROME, fill=GREY_MED))
page_number = spec.get("page_number", "")
if page_number != "":
parts.append(text_el(W - MR, 692, str(page_number), size=T_CHROME, fill=GREY_MED, anchor="end"))
return parts
def render_waterfall(spec: dict) -> list[str]:
unit = spec.get("unit", "")
start = spec["start"]
drivers = spec["drivers"]
end_value = start["value"] + sum(d["value"] for d in drivers)
end_label = spec.get("end_label", "End")
cumulative = [start["value"]]
for driver in drivers:
cumulative.append(cumulative[-1] + driver["value"])
# Scale to the full cumulative range, floored at zero, so a run of negative
# drivers can never push bars outside the chart band.
raw_top = max(max(cumulative), start["value"], end_value, 0)
raw_bottom = min(min(cumulative), start["value"], end_value, 0)
value_range = (raw_top - raw_bottom) or 1
top = raw_top + value_range * 0.18
bottom = raw_bottom - (value_range * 0.10 if raw_bottom < 0 else 0)
n = len(drivers) + 2
span = W - ML - MR
bar_w = min(110.0, span / n * 0.62)
step = span / n
def x_at(i: int) -> float:
return ML + step * i + (step - bar_w) / 2
def y_at(value: float) -> float:
return CHART_BOTTOM - ((value - bottom) / (top - bottom)) * (CHART_BOTTOM - CHART_TOP)
zero_y = y_at(0)
parts = [
line_el(ML, zero_y, W - MR, zero_y, GREY_DARK),
text_el(ML - 10, zero_y + 4, "0", size=T_TICK, fill=GREY_MED, anchor="end"),
]
# Category-label wrap width scales with the column pitch (step), not the
# bar width (bar_w is capped at 110px purely for visual bar weight — the
# label sits in the full step gutter below it and only risks touching a
# neighboring column's label, never the bar edge). Same step-based sizing
# already used by render_distribution below; a flat bar_w-sized budget
# here clamped real driver labels ("Enterprise new customers", "Existing
# customer expansion") to 2 lines of 11 half-width units and silently
# dropped the last word behind the ellipsis once bar count was low enough
# to leave a wide step. floor 6 matches the other step-derived wraps.
label_width = max(int(step / 11), 6)
def bar(i: int, base: float, value_top: float, fill: str, label: str, value_text: str) -> None:
x = x_at(i)
y1, y2 = y_at(max(base, value_top)), y_at(min(base, value_top))
parts.append(rect_el(x, y1, bar_w, max(y2 - y1, 2), fill))
parts.append(text_el(x + bar_w / 2, y1 - 10, value_text, size=T_LABEL, weight="bold", anchor="middle"))
for j, line in enumerate(wrap(label, label_width, max_lines=2)):
parts.append(
text_el(x + bar_w / 2, CHART_BOTTOM + X_AXIS_LABEL_LEAD + j * LINE_H_LABEL, line, size=T_LABEL, fill=GREY_DARK, anchor="middle", title=label)
)
bar(0, 0, start["value"], BLUE, start["label"], fmt(start["value"], unit))
running = start["value"]
for i, driver in enumerate(drivers, start=1):
value = driver["value"]
fill = BLUE2 if value >= 0 else RED
sign = "+" if value >= 0 else "−"
bar(i, running, running + value, fill, driver["label"], f"{sign}{fmt(abs(value), unit)}")
parts.append(line_el(x_at(i - 1) + bar_w, y_at(running), x_at(i), y_at(running), GREY_BORDER, "4 3"))
running += value
parts.append(line_el(x_at(n - 2) + bar_w, y_at(running), x_at(n - 1), y_at(running), GREY_BORDER, "4 3"))
bar(n - 1, 0, end_value, BLUE, end_label, fmt(end_value, unit))
return parts
def render_gap(spec: dict) -> list[str]:
unit = spec.get("unit", "")
items = spec["items"]
top = max(item["value"] for item in items) or 1
span = W - ML - MR - 280
# No row-height cap: like render_agenda, divide the full chart band by
# item count so a short list fills the band instead of stopping short.
row_h = (CHART_BOTTOM - CHART_TOP - 30) / len(items)
bar_h = row_h * 0.52
parts: list[str] = []
for i, item in enumerate(items):
y = CHART_TOP + 30 + i * row_h
width = span * item["value"] / top
fill = BLUE if item.get("emphasis") else GREY_FILL
# Flat fill only — grey reference bars never carry a border; the
# fill/no-fill contrast alone marks emphasis vs. context.
label_lines = wrap(item["label"], 22, max_lines=2) # 25 * 16/18
label_y = y + bar_h / 2 + NUDGE_LABEL - (len(label_lines) - 1) * HALF_LINE_LABEL
for line in label_lines:
parts.append(text_el(ML, label_y, line, size=T_LABEL, fill=GREY_DARK))
label_y += LINE_H_LABEL
parts.append(rect_el(ML + 220, y, width, bar_h, fill))
value_fill = BLUE if item.get("emphasis") else GREY_DARK
parts.append(
text_el(ML + 232 + width, y + bar_h / 2 + NUDGE_LABEL + 1, fmt(item["value"], unit), size=T_LABEL, fill=value_fill, weight="bold")
)
gap_label = spec.get("gap_label", "")
if gap_label:
parts.append(text_el(W - MR, CHART_TOP + 6, gap_label, size=T_LABEL, fill=BLUE, weight="bold", anchor="end"))
return parts
def render_before_after(spec: dict) -> list[str]:
pairs = spec["pairs"]
top = max(max(p["before"], p["after"]) for p in pairs) * 1.15 or 1
span = W - ML - MR
step = span / len(pairs)
bar_w = min(72.0, step * 0.24)
def y_at(value: float) -> float:
return CHART_BOTTOM - (value / top) * (CHART_BOTTOM - CHART_TOP)
parts = [
line_el(ML, CHART_BOTTOM, W - MR, CHART_BOTTOM, GREY_DARK),
text_el(ML - 10, CHART_BOTTOM + 4, "0", size=T_TICK, fill=GREY_MED, anchor="end"),
]
before_label = spec.get("before_label", "Before")
after_label = spec.get("after_label", "After")
for i, pair in enumerate(pairs):
unit = pair.get("unit", spec.get("unit", ""))
cx = ML + step * i + step / 2
bx, ax = cx - bar_w - 8, cx + 8
by, ay = y_at(pair["before"]), y_at(pair["after"])
# Flat fill only — the grey "before" bar never carries a border.
parts.append(rect_el(bx, by, bar_w, CHART_BOTTOM - by, GREY_FILL))
parts.append(rect_el(ax, ay, bar_w, CHART_BOTTOM - ay, BLUE))
# Value/legend/delta stack above the bar top by one LINE_H_LABEL
# step each — not a flat scale of the old offsets, which pushed the
# stack high enough to collide with the subline on a tall bar (the
# chart's fixed 15% headroom above the tallest value didn't grow
# just because the font did; see collision check in the type-scale
# brief's acceptance criteria).
parts.append(text_el(bx + bar_w / 2, by - 10, fmt(pair["before"], unit), size=T_LABEL, fill=GREY_MED, anchor="middle"))
parts.append(text_el(ax + bar_w / 2, ay - 10, fmt(pair["after"], unit), size=T_LABEL, weight="bold", anchor="middle"))
delta = pair["after"] - pair["before"]
sign = "+" if delta >= 0 else "−"
delta_y = min(by, ay) - (58 if i == 0 else 34)
parts.append(
text_el(cx, delta_y, f"{sign}{fmt(abs(delta), unit)}", size=T_LABEL, fill=BLUE2, weight="bold", anchor="middle")
)
if i == 0:
# Direct labels on the first pair replace a legend (style rule:
# avoid legend hunting).
parts.append(text_el(bx + bar_w / 2, by - 34, before_label, size=T_LABEL, fill=GREY_MED, anchor="middle"))
parts.append(text_el(ax + bar_w / 2, ay - 34, after_label, size=T_LABEL, fill=GREY_DARK, weight="600", anchor="middle"))
for j, line in enumerate(wrap(pair["label"], 17, max_lines=2)): # 22 * 14/18
parts.append(
text_el(cx, CHART_BOTTOM + X_AXIS_LABEL_LEAD + j * LINE_H_LABEL, line, size=T_LABEL, fill=GREY_DARK, anchor="middle", title=pair["label"])
)
return parts
def render_time_series(spec: dict) -> list[str]:
unit = spec.get("unit", "")
labels = spec["x_labels"]
values = spec["series"][0]["values"]
top = max(values) * 1.2 or 1
span = W - ML - MR
def pt(i: int) -> tuple[float, float]:
x = ML + span * (i / max(len(values) - 1, 1))
y = CHART_BOTTOM - (values[i] / top) * (CHART_BOTTOM - CHART_TOP)
return x, y
parts = [
line_el(ML, CHART_BOTTOM, W - MR, CHART_BOTTOM, GREY_DARK),
text_el(ML - 10, CHART_BOTTOM + 4, "0", size=T_TICK, fill=GREY_MED, anchor="end"),
]
points = " ".join(f"{x:.1f},{y:.1f}" for x, y in (pt(i) for i in range(len(values))))
parts.append(f'<polyline points="{points}" fill="none" stroke="{BLUE}" stroke-width="3"/>')
for i, value in enumerate(values):
x, y = pt(i)
parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="5" fill="{BLUE}"/>')
parts.append(text_el(x, y - 18, fmt(value, unit), size=T_LABEL, weight="bold", anchor="middle"))
parts.append(text_el(x, CHART_BOTTOM + X_AXIS_LABEL_LEAD, labels[i], size=T_LABEL, fill=GREY_DARK, anchor="middle"))
parts.append(text_el(ML, CHART_TOP - 12, spec["series"][0].get("label", ""), size=T_LABEL, fill=GREY_MED))
return parts
def render_benchmark_table(spec: dict) -> list[str]:
columns = spec["columns"]
rows = spec["rows"]
leaders = {tuple(pair) for pair in spec.get("leaders", [])}
label_w = 230.0
col_w = (W - ML - MR - label_w) / len(columns)
row_h = min(64.0, (CHART_BOTTOM + 40 - CHART_TOP) / (len(rows) + 1))
top_y = CHART_TOP - 10
parts: list[str] = []
for j, column in enumerate(columns):
cx = ML + label_w + col_w * j + col_w / 2
for k, line in enumerate(wrap(column, 13, max_lines=2)): # 18 * 13/18
parts.append(text_el(cx, top_y + 28 + k * LINE_H_LABEL, line, size=T_LABEL, fill=GREY_MED, weight="600", anchor="middle"))
parts.append(line_el(ML, top_y + row_h, W - MR, top_y + row_h, GREY_DARK))
# Dense fallback (>6 columns) stays above the T_TICK floor (14px) — see
# references/style-system.md Typography.
value_size = T_LABEL if len(columns) <= 6 else 16
cell_width_units = max(int(col_w / (value_size * 0.62)), 6)
value_line_h = value_size + 2
value_nudge = round(value_size / 3)
value_half = value_line_h / 2
for i, row in enumerate(rows):
y = top_y + row_h * (i + 1)
label_lines = wrap(row["label"], 19, max_lines=2) # 28 * 15/22 (old size 15 -> T_BODY)
ly = y + row_h / 2 + NUDGE_BODY - (len(label_lines) - 1) * HALF_LINE_BODY
for line in label_lines:
parts.append(text_el(ML, ly, line, size=T_BODY, fill=BLACK, weight="600", title=row["label"]))
ly += LINE_H_BODY
for j, value in enumerate(row["values"]):
cx = ML + label_w + col_w * j + col_w / 2
cell_lines = wrap(str(value), cell_width_units, max_lines=2)
cy = y + row_h / 2 + value_nudge - (len(cell_lines) - 1) * value_half
if (i, j) in leaders:
parts.append(rect_el(ML + label_w + col_w * j + 6, y + 7, col_w - 12, row_h - 14, BLUE))
for line in cell_lines:
parts.append(text_el(cx, cy, line, size=value_size, fill="#FFFFFF", weight="bold", anchor="middle", title=str(value)))
cy += value_line_h
else:
for line in cell_lines:
parts.append(text_el(cx, cy, line, size=value_size, fill=GREY_DARK, anchor="middle", title=str(value)))
cy += value_line_h
parts.append(line_el(ML, y + row_h, W - MR, y + row_h, GREY_BORDER))
return parts
def render_summary_strip(spec: dict) -> list[str]:
blocks = spec["blocks"]
span = W - ML - MR
col_w = span / len(blocks)
# Columns are separated by whitespace only — no vertical divider rule
# (ink discipline: organization comes from spacing/alignment, not marks).
# Equal 28px inner margin on every column keeps the gutter symmetric.
pad = 28.0
claim_width = int((col_w - pad * 2) / (T_BODY * 0.62))
label_width = int((col_w - pad * 2) / (T_LABEL * 0.62))
claim_gap, proof_gap = 10.0, 13.0 # 8 * 18/14, 10 * 18/14 (old label size 14 -> T_LABEL)
# 2026-08-02 panel round 3: a whole-block vertical center here (matching
# process_flow's box_h/y technique) fixed the 44% dead-space complaint
# but overshot into a different offense — it opened a 135-205px gap
# between the subhead and the claim line that no other text pattern on
# the deck has. The subhead directly above this band (e.g. "Board
# takeaways for the Q4 decision") functions as the same kind of
# immediately-preceding label that "KEY TAKEAWAYS" is for `closing` and
# the headline is for `bullet_list`, so it gets the same fixed top anchor
# instead of being centered away from its content: band_start =
# CHART_TOP + 20, identical to bullet_list's band_start. Column start
# position no longer depends on content height, so per-block height no
# longer needs computing here — height differences between columns show
# up only at each column's end, not at its shared start.
strip_top = CHART_TOP + 20
parts: list[str] = []
for i, block in enumerate(blocks):
x = ML + col_w * i
inner_x = x + pad
y = strip_top + 23 # 18 * 22/17, scaled with T_BODY (claim role)
for line in wrap(block["claim"], claim_width, max_lines=3):
parts.append(text_el(inner_x, y, line, size=T_BODY, weight="bold"))
y += LINE_H_BODY
y += claim_gap
for line in wrap(block["proof"], label_width, max_lines=4):
parts.append(text_el(inner_x, y, line, size=T_LABEL, fill=GREY_MED))
y += LINE_H_LABEL
y += proof_gap
for line in wrap(block["implication"], label_width, max_lines=3):
parts.append(text_el(inner_x, y, line, size=T_LABEL, fill=BLUE, weight="600"))
y += LINE_H_LABEL
return parts
def render_process_flow(spec: dict) -> list[str]:
steps = spec["steps"]
highlight = spec.get("highlight", -1)
span = W - ML - MR
gap = 26.0
box_w = (span - gap * (len(steps) - 1)) / len(steps)
# 152px — grown from 104 to hold the same worst-case content (2-line
# title, 2-line detail) at T_BODY/T_LABEL instead of a hollow box with
# leftover whitespace below (see examples/render-specs and templates/decks
# for the longest step content actually seen).
box_h = 152.0
y = (CHART_TOP + CHART_BOTTOM) / 2 - box_h / 2
parts: list[str] = []
for i, step in enumerate(steps):
x = ML + i * (box_w + gap)
is_hot = i == highlight
# Flat fill only — grey step boxes never carry a border.
parts.append(rect_el(x, y, box_w, box_h, BLUE if is_hot else GREY_FILL))
title_fill = "#FFFFFF" if is_hot else BLACK
detail_fill = "#E5E7EB" if is_hot else GREY_MED
ty, text_width = y + 38, int(box_w / (T_BODY * 0.62))
parts.append(text_el(x + 16, ty - 16, f"{i + 1:02d}", size=T_LABEL, fill=BLUE2 if not is_hot else "#E5E7EB", weight="bold"))
for line in wrap(step["label"], text_width, max_lines=2):
parts.append(text_el(x + 16, ty + 8, line, size=T_BODY, fill=title_fill, weight="bold"))
ty += LINE_H_BODY
detail_width = int(box_w / (T_LABEL * 0.62))
for line in wrap(step.get("detail", ""), detail_width, max_lines=3):
parts.append(text_el(x + 16, ty + 14, line, size=T_LABEL, fill=detail_fill))
ty += LINE_H_LABEL
if i < len(steps) - 1:
ax = x + box_w + gap / 2
ay = y + box_h / 2
parts.append(
f'<path d="M {ax - 7:.1f} {ay - 8:.1f} L {ax + 7:.1f} {ay:.1f} L {ax - 7:.1f} {ay + 8:.1f} Z" fill="{GREY_MED}"/>'
)
return parts
def _lerp_color(start: str, end: str, t: float) -> str:
s = [int(start[i : i + 2], 16) for i in (1, 3, 5)]
e = [int(end[i : i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(f"{round(s[i] + (e[i] - s[i]) * t):02X}" for i in range(3))
def render_funnel(spec: dict) -> list[str]:
unit = spec.get("unit", "")
stages = spec["stages"]
top_value = max(s["value"] for s in stages) or 1
row_h = (CHART_BOTTOM - CHART_TOP) / len(stages)
bar_h = min(row_h * 0.66, 60.0)
span = W - ML - MR - 330
cx = ML + 210 + span / 2
parts: list[str] = []
for i, stage in enumerate(stages):
y = CHART_TOP + row_h * i + (row_h - bar_h) / 2
bw = max(span * stage["value"] / top_value, 6)
parts.append(rect_el(cx - bw / 2, y, bw, bar_h, BLUE))
label_lines = wrap(stage["label"], 20, max_lines=2) # 24 * 15/18
ly = y + bar_h / 2 + NUDGE_LABEL - (len(label_lines) - 1) * HALF_LINE_LABEL
for line in label_lines:
parts.append(text_el(ML, ly, line, size=T_LABEL, fill=GREY_DARK))
ly += LINE_H_LABEL
value_text = fmt(stage["value"], unit)
if bw > 110:
parts.append(text_el(cx, y + bar_h / 2 + NUDGE_LABEL + 1, value_text, size=T_LABEL, fill="#FFFFFF", weight="bold", anchor="middle"))
else:
parts.append(text_el(cx + bw / 2 + 11, y + bar_h / 2 + NUDGE_LABEL + 1, value_text, size=T_LABEL, weight="bold"))
if i > 0:
previous_value = stages[i - 1]["value"]
conversion = "n/a" if previous_value == 0 else f"{stage['value'] / previous_value * 100:.0f}%"
# Funnel conversion % is explicitly a T_TICK role (referenced, not read at length).
parts.append(
text_el(cx + span / 2 + 28, CHART_TOP + row_h * i + 5, f"↓ {conversion}", size=T_TICK, fill=BLUE2, weight="600")
)
return parts
def _heatmap_cell_fill(value: float, vmin: float, vmax: float, diverging: bool) -> str:
"""Sequential single-hue ramp for non-negative data; diverging ramp anchored
at zero (white) when the data carries sign, so negative and positive cells
can never read as the same tone."""
if diverging:
extent = max(abs(vmin), abs(vmax)) or 1
t = value / extent
if t >= 0:
return _lerp_color(WHITE, BLUE, min(t, 1.0))
return _lerp_color(WHITE, RED, min(-t, 1.0))
t = (value - vmin) / (vmax - vmin) if vmax > vmin else 0.5
return _lerp_color(BLUE_TINT, BLUE, t)
def _heatmap_value_size(cell_w: float, cell_h: float) -> int:
"""Pick the largest size in the T_LABEL..T_TICK range that comfortably
fits a short value string in the cell — never below the T_TICK floor
(14px) even for a dense grid (see references/style-system.md Typography:
"heatmap cell values may hold a size between this and T_LABEL when cells
are tight, but never drop below this floor")."""
for candidate in (T_LABEL, 16, 15, T_TICK):
if cell_w >= candidate * 3.5 and cell_h >= candidate * 1.6:
return candidate
return T_TICK
def render_heatmap(spec: dict) -> list[str]:
unit = spec.get("unit", "")
rows, columns, values = spec["rows"], spec["columns"], spec["values"]
flat = [v for row in values for v in row]
vmin, vmax = min(flat), max(flat)
diverging = bool(spec.get("diverging", vmin < 0 < vmax))
label_w = 210.0
cell_w = (W - ML - MR - label_w) / len(columns)
cell_h = min(72.0, (CHART_BOTTOM - CHART_TOP - 30) / len(rows))
value_size = _heatmap_value_size(cell_w, cell_h)
value_nudge = round(value_size * 0.36)
parts: list[str] = []
for j, column in enumerate(columns):
cx = ML + label_w + cell_w * j + cell_w / 2
parts.append(text_el(cx, CHART_TOP + 15, column, size=T_LABEL, fill=GREY_MED, weight="600", anchor="middle")) # 12 * 18/14
for i, row_label in enumerate(rows):
y = CHART_TOP + 26 + cell_h * i
label_lines = wrap(row_label, 18, max_lines=2) # 24 * 14/18
ly = y + cell_h / 2 + NUDGE_LABEL - (len(label_lines) - 1) * HALF_LINE_LABEL
for line in label_lines:
parts.append(text_el(ML, ly, line, size=T_LABEL, fill=GREY_DARK, title=row_label))
ly += LINE_H_LABEL
for j, value in enumerate(values[i]):
x = ML + label_w + cell_w * j
fill = _heatmap_cell_fill(value, vmin, vmax, diverging)
parts.append(rect_el(x + 2, y + 2, cell_w - 4, cell_h - 4, fill))
# Pick the value color by measured contrast so mid-tone cells stay
# WCAG-readable instead of trusting a fixed threshold.
parts.append(
text_el(
x + cell_w / 2,
y + cell_h / 2 + value_nudge,
fmt(value, unit),
size=value_size,
fill=_cell_text_color(fill),
weight="600",
anchor="middle",
)
)
return parts
def render_gantt(spec: dict) -> list[str]:
periods = spec["periods"]
bars = spec["bars"]
gates = spec.get("gates", [])
label_w = 250.0
col_w = (W - ML - MR - label_w) / len(periods)
grid_top = CHART_TOP + 14
row_h = min(56.0, (CHART_BOTTOM - grid_top - (26 if gates else 0)) / len(bars))
grid_bottom = grid_top + row_h * len(bars)
parts: list[str] = []
for j, period in enumerate(periods):
x = ML + label_w + col_w * j
parts.append(text_el(x + col_w / 2, CHART_TOP, period, size=T_LABEL, fill=GREY_MED, weight="600", anchor="middle"))
parts.append(line_el(x, grid_top, x, grid_bottom, GREY_BORDER))
parts.append(line_el(W - MR, grid_top, W - MR, grid_bottom, GREY_BORDER))
parts.append(line_el(ML, grid_top, W - MR, grid_top, GREY_DARK))
for i, bar in enumerate(bars):
y = grid_top + row_h * i
label_lines = wrap(bar["label"], 21, max_lines=2) # 28 * 14/18
ly = y + row_h / 2 + NUDGE_LABEL - (len(label_lines) - 1) * HALF_LINE_LABEL
for line in label_lines:
parts.append(text_el(ML, ly, line, size=T_LABEL, fill=GREY_DARK))
ly += LINE_H_LABEL
bx = ML + label_w + col_w * bar["start"] + 3
bw = col_w * (bar["end"] - bar["start"] + 1) - 6
hot = bar.get("highlight")
# Flat fill only — the grey reference bar never carries a border.
parts.append(rect_el(bx, y + (row_h - 24) / 2, bw, 24, BLUE if hot else GREY_FILL))
note = bar.get("note", "")
if note:
# Clamp to whatever room remains between the bar and the right
# margin — a bar late in the timeline leaves little room, and
# T_LABEL is wide enough now that an unclamped note can run well
# past the canvas edge.
note_x = bx + bw + 10
note_width_units = max(int((W - MR - note_x) / (T_LABEL * 0.62)), 6)
note_text = wrap(note, note_width_units, max_lines=1)[0]
parts.append(
text_el(note_x, y + row_h / 2 + NUDGE_LABEL, note_text, size=T_LABEL, fill=GREY_MED, title=note if note_text.endswith(ELLIPSIS) else "")
)
parts.append(line_el(ML, y + row_h, W - MR, y + row_h, GREY_BORDER))
for gate in gates:
gx = ML + label_w + col_w * (gate["period"] + 1)
gy = grid_bottom + 12
parts.append(f'<path d="M {gx:.1f} {gy - 8:.1f} L {gx + 8:.1f} {gy:.1f} L {gx:.1f} {gy + 8:.1f} L {gx - 8:.1f} {gy:.1f} Z" fill="{BLUE}"/>')
parts.append(text_el(gx, gy + 36, gate["label"], size=T_LABEL, fill=BLUE, weight="600", anchor="middle")) # 24 * 18/12
return parts
def render_kpi_scorecard(spec: dict) -> list[str]:
metrics = spec["metrics"]
cols = spec.get("columns", 3)
gap = 24.0
card_w = (W - ML - MR - gap * (cols - 1)) / cols
n_rows = -(-len(metrics) // cols)
card_h = min(150.0, (CHART_BOTTOM - CHART_TOP) / n_rows - 14)
# Label/value baselines are proportional to card_h (not fixed pixel
# offsets), so a taller grid (more metric rows, smaller card_h) still
# clears T_KPI_NUM's larger hero number instead of the value baseline
# colliding with the target line beneath it. At the common card_h=150
# this reproduces the previous fixed offsets (30, 88) exactly.
label_y_offset = round(card_h * 0.2)
value_y_offset = round(card_h * 88 / 150)
parts: list[str] = []
for i, metric in enumerate(metrics):
x = ML + (i % cols) * (card_w + gap)
y = CHART_TOP + (i // cols) * (card_h + 18)
# Flat fill only — no border, no status accent bar (ink discipline: the
# no accent-bar motif on cards). Status still reads
# through the trend color below and the value/target text itself.
parts.append(rect_el(x, y, card_w, card_h, "#FFFFFF"))
parts.append(text_el(x + 24, y + label_y_offset, metric["label"], size=T_LABEL, fill=GREY_MED, weight="600"))
parts.append(text_el(x + 24, y + value_y_offset, str(metric["value"]), size=T_KPI_NUM, weight="bold"))
trend = metric.get("trend", "")
if trend:
trend_fill = RED if metric.get("status") == "risk" else BLUE2
parts.append(text_el(x + card_w - 18, y + value_y_offset, trend, size=T_LABEL, fill=trend_fill, weight="600", anchor="end"))
target = metric.get("target", "")
if target:
parts.append(text_el(x + 24, y + card_h - 16, f"Target: {target}", size=T_LABEL, fill=GREY_MED))
return parts
def render_two_by_two(spec: dict) -> list[str]:
plot_x = ML + 50
plot_w = W - MR - plot_x - 50
plot_y, plot_h = CHART_TOP, float(CHART_BOTTOM - CHART_TOP)
mid_x, mid_y = plot_x + plot_w / 2, plot_y + plot_h / 2
x_axis, y_axis = spec["x_axis"], spec["y_axis"]
quadrants = spec.get("quadrants", [])
parts = [
rect_el(plot_x, plot_y, plot_w, plot_h, "#FFFFFF", GREY_BORDER),
line_el(mid_x, plot_y, mid_x, plot_y + plot_h, GREY_BORDER),
line_el(plot_x, mid_y, plot_x + plot_w, mid_y, GREY_BORDER),
]
corners = [
(plot_x + 21, plot_y + 36, "start"),
(plot_x + plot_w - 21, plot_y + 36, "end"),
(plot_x + 21, plot_y + plot_h - 18, "start"),
(plot_x + plot_w - 21, plot_y + plot_h - 18, "end"),
]
for (qx, qy, anchor), label in zip(corners, quadrants):
parts.append(text_el(qx, qy, label.upper(), size=T_LABEL, fill=GREY_MED, weight="600", anchor=anchor)) # 12 * 18/12
# Axis title and its min/max range numerals share one baseline row —
# the title (T_LABEL) sets the offset; the T_TICK range numbers ride
# the same row rather than sitting on their own slightly-different one.
parts.append(text_el(mid_x, plot_y + plot_h + 39, x_axis["label"], size=T_LABEL, fill=GREY_DARK, weight="600", anchor="middle"))
parts.append(text_el(plot_x, plot_y + plot_h + 39, x_axis.get("low", "Low"), size=T_TICK, fill=GREY_MED))
parts.append(text_el(plot_x + plot_w, plot_y + plot_h + 39, x_axis.get("high", "High"), size=T_TICK, fill=GREY_MED, anchor="end"))
parts.append(text_el(plot_x - 14, plot_y + 10, y_axis.get("high", "High"), size=T_TICK, fill=GREY_MED, anchor="end"))
parts.append(text_el(plot_x - 14, plot_y + plot_h, y_axis.get("low", "Low"), size=T_TICK, fill=GREY_MED, anchor="end"))
# -9, not a bigger negative offset: tuned against header()'s 28px subline
# gap so this caption clears both a present subline above it and the
# plot's own top border below it (see header()'s comment on subline_gap).
parts.append(text_el(ML, plot_y - 9, y_axis["label"], size=T_LABEL, fill=GREY_DARK, weight="600"))
for point in spec["points"]:
px = plot_x + plot_w * point["x"] / 100
py = plot_y + plot_h * (1 - point["y"] / 100)
emphasis = point.get("emphasis")
radius = 9 if emphasis else 7
parts.append(f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{radius}" fill="{BLUE if emphasis else GREY_MED}"/>')
# Flip the label to the point's left when it would otherwise run
# past the right margin — T_LABEL is wide enough now that a point
# near the plot's right edge could push its label off the canvas.
label_w = _text_width(point["label"]) * T_LABEL * 0.62
if px + radius + 8 + label_w > W - MR:
label_x, anchor = px - radius - 8, "end"
else:
label_x, anchor = px + radius + 8, "start"
parts.append(
text_el(
label_x,
py + 6,
point["label"],
size=T_LABEL,
fill=BLACK if emphasis else GREY_DARK,
weight="600" if emphasis else "normal",
anchor=anchor,
)
)
return parts
def render_cover(spec: dict) -> list[str]:
"""Navy cover slide. Bypasses the standard white header/footer chrome."""
parts = [
rect_el(0, 0, W, H, NAVY_COVER),
]
y = 264
for line in wrap(spec.get("title", ""), 38, max_lines=3): # 40 * 52/54
parts.append(text_el(ML, y, line, size=T_COVER_TITLE, fill=WHITE, family=SERIF))
y += 66 # 64 * 54/52
subtitle = spec.get("subtitle", "")
if subtitle:
y += 8
for line in wrap(subtitle, 70, max_lines=2):
parts.append(text_el(ML, y, line, size=T_SUBLINE, fill="#E5E7EB"))
y += 28
meta = " · ".join(str(spec[k]) for k in ("presenter", "date") if spec.get(k))
if meta:
parts.append(text_el(ML, 640, meta, size=T_LABEL, fill="#E5E7EB"))
classification = spec.get("classification", "")
if classification:
parts.append(text_el(W - MR, 640, classification.upper(), size=T_CHROME, fill="#E5E7EB", anchor="end"))
return parts
def render_scatter(spec: dict) -> list[str]:
points = spec["points"]
x_axis, y_axis = spec["x_axis"], spec["y_axis"]
xs = [p["x"] for p in points]
ys = [p["y"] for p in points]
x_min, x_max = min(xs + [0]) if spec.get("x_zero", False) else min(xs), max(xs)