cctally 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/_lib_share.py +1551 -0
- package/package.json +3 -1
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
"""Pure-function render kernel for shareable reports.
|
|
2
|
+
|
|
3
|
+
Imported lazily from bin/cctally only when a headliner subcommand is invoked
|
|
4
|
+
with --format. Stdlib-only, no I/O, no DB, no filesystem, no locks.
|
|
5
|
+
|
|
6
|
+
Spec: docs/superpowers/specs/2026-05-08-shareable-reports-design.md
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Version + digest ---
|
|
20
|
+
#
|
|
21
|
+
# KERNEL_VERSION is the contract version of the share renderer. Bump when
|
|
22
|
+
# output shape changes in a way that requires re-rendering historical
|
|
23
|
+
# basket items / share-history entries. The dashboard composer reads this
|
|
24
|
+
# off basket snapshots and tags rows whose stored version != current.
|
|
25
|
+
KERNEL_VERSION: int = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _data_digest(payload: object) -> str:
|
|
29
|
+
"""Stable sha256 of a JSON-serializable payload.
|
|
30
|
+
|
|
31
|
+
Used by share-snapshot envelopes to let the composer detect data drift
|
|
32
|
+
between add-time and compose-time. Key ordering is sorted to make the
|
|
33
|
+
digest insensitive to dict construction order.
|
|
34
|
+
|
|
35
|
+
Payload must contain only JSON-native types or types with a stable
|
|
36
|
+
`str()` (e.g. `datetime`); arbitrary objects fall through `default=str`
|
|
37
|
+
and `<X object at 0x…>` reprs are per-process-unstable.
|
|
38
|
+
"""
|
|
39
|
+
canon = json.dumps(payload, sort_keys=True, separators=(",", ":"),
|
|
40
|
+
default=str).encode("utf-8")
|
|
41
|
+
return "sha256:" + hashlib.sha256(canon).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- Cell tagged union ---
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class TextCell:
|
|
48
|
+
text: str
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class MoneyCell:
|
|
52
|
+
usd: float
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class PercentCell:
|
|
56
|
+
pct: float
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class DateCell:
|
|
60
|
+
when: datetime
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class DeltaCell:
|
|
64
|
+
value: float
|
|
65
|
+
unit: str # "%" | "$"
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ProjectCell:
|
|
69
|
+
"""Anonymization chokepoint — scrubber rewrites the `label` field."""
|
|
70
|
+
label: str
|
|
71
|
+
|
|
72
|
+
Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Table primitives ---
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ColumnSpec:
|
|
79
|
+
key: str
|
|
80
|
+
label: str
|
|
81
|
+
align: str = "left" # "left" | "right" | "center"
|
|
82
|
+
emphasis: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Row:
|
|
87
|
+
cells: Mapping[str, "Cell"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Totalled:
|
|
92
|
+
label: str
|
|
93
|
+
value: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class PeriodSpec:
|
|
98
|
+
start: datetime
|
|
99
|
+
end: datetime
|
|
100
|
+
display_tz: str
|
|
101
|
+
label: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- Chart primitives ---
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ChartPoint:
|
|
108
|
+
x_label: str
|
|
109
|
+
x_value: float
|
|
110
|
+
y_value: float
|
|
111
|
+
project_label: str | None = None
|
|
112
|
+
series_key: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class LineChart:
|
|
117
|
+
points: tuple[ChartPoint, ...]
|
|
118
|
+
y_label: str
|
|
119
|
+
# Each reference line is (value, label, severity) where severity is "warn"|"alarm".
|
|
120
|
+
# Renderer unpacks the 3-tuple; bare-float form (Implementor 1's tightening) was
|
|
121
|
+
# incorrect — restored to the consumer-driven shape per Implementor Bundle 3.
|
|
122
|
+
reference_lines: tuple[tuple[float, str, str], ...] = ()
|
|
123
|
+
multi_series: Mapping[str, tuple[ChartPoint, ...]] | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class BarChart:
|
|
128
|
+
points: tuple[ChartPoint, ...]
|
|
129
|
+
y_label: str
|
|
130
|
+
stacks: Mapping[str, tuple[ChartPoint, ...]] | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class HorizontalBarChart:
|
|
135
|
+
"""Horizontal bar chart with top-N cap.
|
|
136
|
+
|
|
137
|
+
Contract: each point's `y_value` is treated as a non-negative magnitude.
|
|
138
|
+
Negative `y_value` would produce visually-misleading negative-width
|
|
139
|
+
rendering (silently zero in most SVG renderers); kernel-internal
|
|
140
|
+
callers must pre-filter or coerce.
|
|
141
|
+
"""
|
|
142
|
+
points: tuple[ChartPoint, ...]
|
|
143
|
+
x_label: str
|
|
144
|
+
cap: int | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
ChartSpec = LineChart | BarChart | HorizontalBarChart
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- Top-level snapshot ---
|
|
151
|
+
#
|
|
152
|
+
# Contract: ShareSnapshot and all nested dataclasses are nominally frozen.
|
|
153
|
+
# `frozen=True` blocks attribute rebinding (snap.cmd = ...) but cannot prevent
|
|
154
|
+
# mutation of the inner dict held by Row.cells or the inner tuple/dict held by
|
|
155
|
+
# chart fields. The scrubber and renderers MUST treat snapshots as read-only;
|
|
156
|
+
# the parameterized Mapping/tuple annotations exist to make a typechecker
|
|
157
|
+
# reject mutation attempts (e.g., dict assignment, list.append). Phase 4's
|
|
158
|
+
# scrubber returns a NEW snapshot rather than rewriting in place — see spec
|
|
159
|
+
# §5.3 (anonymization chokepoint) and Codex finding M6.
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class ShareSnapshot:
|
|
163
|
+
cmd: str
|
|
164
|
+
title: str
|
|
165
|
+
subtitle: str | None
|
|
166
|
+
period: PeriodSpec
|
|
167
|
+
columns: tuple[ColumnSpec, ...]
|
|
168
|
+
rows: tuple[Row, ...]
|
|
169
|
+
chart: ChartSpec | None
|
|
170
|
+
totals: tuple[Totalled, ...]
|
|
171
|
+
notes: tuple[str, ...]
|
|
172
|
+
generated_at: datetime
|
|
173
|
+
version: str
|
|
174
|
+
template_id: str | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Compose: multi-section stitching (M3.1) ---
|
|
178
|
+
#
|
|
179
|
+
# `compose()` is the multi-section counterpart of `render()`: every basket
|
|
180
|
+
# item is rendered via `_render_fragment` (the same body-only path the
|
|
181
|
+
# single-panel `render()` uses) and the fragments are stitched under one
|
|
182
|
+
# composite chrome — single <html>/<svg> wrapper or one MD frontmatter
|
|
183
|
+
# block. See `compose()` for the format-specific stitching rules; the
|
|
184
|
+
# dataclasses below pin the request shape.
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class ComposedSection:
|
|
188
|
+
"""One section in a multi-section compose request.
|
|
189
|
+
|
|
190
|
+
`drift_detected` is metadata only — surfaced to the composer UI as the
|
|
191
|
+
"Outdated" badge (spec §7.7). It must NOT alter the rendered body;
|
|
192
|
+
the renderer ignores it. Compute it server-side by comparing the
|
|
193
|
+
section's `data_digest_at_add` against a fresh `_data_digest` over
|
|
194
|
+
the same panel_data slice.
|
|
195
|
+
"""
|
|
196
|
+
snap: ShareSnapshot
|
|
197
|
+
drift_detected: bool
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class ComposeOptions:
|
|
202
|
+
"""Composite knobs supplied by the composer modal (spec §8.5).
|
|
203
|
+
|
|
204
|
+
`theme`, `format`, `reveal_projects`, and `no_branding` are
|
|
205
|
+
single-source-of-truth: every section is re-rendered with these
|
|
206
|
+
values, regardless of what was captured per-section at add-time.
|
|
207
|
+
"""
|
|
208
|
+
title: str
|
|
209
|
+
theme: str # "light" | "dark"
|
|
210
|
+
format: str # "md" | "html" | "svg"
|
|
211
|
+
no_branding: bool
|
|
212
|
+
# kernel: informational only — actual scrub happens upstream in
|
|
213
|
+
# the API layer before sections reach `compose()`.
|
|
214
|
+
reveal_projects: bool
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- Escape helpers ---
|
|
218
|
+
|
|
219
|
+
_XML_ESCAPE_TABLE = {
|
|
220
|
+
"&": "&",
|
|
221
|
+
"<": "<",
|
|
222
|
+
">": ">",
|
|
223
|
+
'"': """,
|
|
224
|
+
"'": "'",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _xml_escape(s: str) -> str:
|
|
229
|
+
"""Escape `&`, `<`, `>`, `"`, `'`. For SVG <text> content and HTML body text."""
|
|
230
|
+
out = []
|
|
231
|
+
for ch in s:
|
|
232
|
+
out.append(_XML_ESCAPE_TABLE.get(ch, ch))
|
|
233
|
+
return "".join(out)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _attr_escape(s: str) -> str:
|
|
237
|
+
"""Escape XML chars + collapse newlines to space. For SVG/HTML attribute values."""
|
|
238
|
+
s = s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
|
239
|
+
return _xml_escape(s)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_MD_HTML_TABLE = {"&": "&", "<": "<", ">": ">"}
|
|
243
|
+
_MD_FMT_CHARS = ("\\", "|", "*", "_", "`", "[", "]")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _md_escape(s: str) -> str:
|
|
247
|
+
"""Escape markdown formatting chars + HTML chars.
|
|
248
|
+
|
|
249
|
+
Markdown surfaces (GitHub, Slack, most renderers) interpret raw HTML inline,
|
|
250
|
+
so a revealed project name like 'Project<script>' would inject without
|
|
251
|
+
HTML-char escaping. Backslash is in _MD_FMT_CHARS so a literal `\\` becomes
|
|
252
|
+
`\\\\` — single-pass dispatch, each char checked independently.
|
|
253
|
+
"""
|
|
254
|
+
out = []
|
|
255
|
+
for ch in s:
|
|
256
|
+
if ch in _MD_HTML_TABLE:
|
|
257
|
+
out.append(_MD_HTML_TABLE[ch])
|
|
258
|
+
elif ch in _MD_FMT_CHARS:
|
|
259
|
+
out.append("\\" + ch)
|
|
260
|
+
else:
|
|
261
|
+
out.append(ch)
|
|
262
|
+
return "".join(out)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- Palettes ---
|
|
266
|
+
|
|
267
|
+
PALETTE_LIGHT = {
|
|
268
|
+
"bg": "#ffffff",
|
|
269
|
+
"fg": "#1a1a1a",
|
|
270
|
+
"muted": "#6b7280",
|
|
271
|
+
"grid": "#e5e7eb",
|
|
272
|
+
"axis": "#9ca3af",
|
|
273
|
+
"series_primary": "#2563eb", # blue-600
|
|
274
|
+
"series_secondary": "#9333ea", # purple-600
|
|
275
|
+
# Cycled for stacked-bar segments by sorted-key index. Six entries cover
|
|
276
|
+
# typical model counts (4-6); overflow wraps. Palette ordering is part
|
|
277
|
+
# of the byte-stable contract — adding/reordering is a goldens churn.
|
|
278
|
+
"series_palette": (
|
|
279
|
+
"#2563eb", # blue-600
|
|
280
|
+
"#9333ea", # purple-600
|
|
281
|
+
"#059669", # emerald-600
|
|
282
|
+
"#d97706", # amber-600
|
|
283
|
+
"#dc2626", # red-600
|
|
284
|
+
"#0891b2", # cyan-600
|
|
285
|
+
),
|
|
286
|
+
"ref_warn": "#d97706", # amber-600
|
|
287
|
+
"ref_alarm": "#dc2626", # red-600
|
|
288
|
+
"table_header_bg": "#f3f4f6",
|
|
289
|
+
"table_row_alt": "#f9fafb",
|
|
290
|
+
"footer_link": "#2563eb",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
PALETTE_DARK = {
|
|
294
|
+
"bg": "#0b0f17",
|
|
295
|
+
"fg": "#e5e7eb",
|
|
296
|
+
"muted": "#9ca3af",
|
|
297
|
+
"grid": "#1f2937",
|
|
298
|
+
"axis": "#4b5563",
|
|
299
|
+
"series_primary": "#60a5fa", # blue-400
|
|
300
|
+
"series_secondary": "#c084fc", # purple-400
|
|
301
|
+
"series_palette": (
|
|
302
|
+
"#60a5fa", # blue-400
|
|
303
|
+
"#c084fc", # purple-400
|
|
304
|
+
"#34d399", # emerald-400
|
|
305
|
+
"#fbbf24", # amber-400
|
|
306
|
+
"#f87171", # red-400
|
|
307
|
+
"#22d3ee", # cyan-400
|
|
308
|
+
),
|
|
309
|
+
"ref_warn": "#fbbf24", # amber-400
|
|
310
|
+
"ref_alarm": "#f87171", # red-400
|
|
311
|
+
"table_header_bg": "#111827",
|
|
312
|
+
"table_row_alt": "#1f2937",
|
|
313
|
+
"footer_link": "#60a5fa",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --- SVG primitives ---
|
|
318
|
+
|
|
319
|
+
def _fmt_num(n: float) -> str:
|
|
320
|
+
"""Format float with one decimal place, no scientific notation, no -0.0.
|
|
321
|
+
|
|
322
|
+
Byte-stability invariant — every coordinate / value in SVG output
|
|
323
|
+
routes through this so goldens are stable. Rejects non-finite inputs
|
|
324
|
+
(NaN/inf) loudly so chart-layer divide-by-zero or bad-data bugs surface
|
|
325
|
+
at the value site rather than rendering silently as a blank chart.
|
|
326
|
+
"""
|
|
327
|
+
if not math.isfinite(n):
|
|
328
|
+
raise ValueError(f"_fmt_num requires finite input, got {n!r}")
|
|
329
|
+
out = f"{float(n):.1f}"
|
|
330
|
+
if out == "-0.0":
|
|
331
|
+
return "0.0"
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _serialize_attrs(attrs: Mapping[str, object]) -> str:
|
|
336
|
+
"""Serialize SVG/HTML attributes in lexical key order with escaped values.
|
|
337
|
+
|
|
338
|
+
Numbers go through _fmt_num; strings through _attr_escape. None values
|
|
339
|
+
skipped (lets primitives accept optional attributes uniformly).
|
|
340
|
+
"""
|
|
341
|
+
parts = []
|
|
342
|
+
for key in sorted(attrs):
|
|
343
|
+
value = attrs[key]
|
|
344
|
+
if value is None:
|
|
345
|
+
continue
|
|
346
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
347
|
+
rendered = _fmt_num(value)
|
|
348
|
+
else:
|
|
349
|
+
rendered = _attr_escape(str(value))
|
|
350
|
+
parts.append(f'{key}="{rendered}"')
|
|
351
|
+
return " ".join(parts)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def svg_rect(x: float, y: float, w: float, h: float, *,
|
|
355
|
+
fill: str, stroke: str | None = None) -> str:
|
|
356
|
+
return f'<rect {_serialize_attrs({"x": x, "y": y, "width": w, "height": h, "fill": fill, "stroke": stroke})}/>'
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def svg_text(x: float, y: float, text: str, *,
|
|
360
|
+
font_size: float, fill: str,
|
|
361
|
+
anchor: str = "start", weight: str = "normal") -> str:
|
|
362
|
+
attrs = {
|
|
363
|
+
"x": x,
|
|
364
|
+
"y": y,
|
|
365
|
+
"font-size": font_size,
|
|
366
|
+
"fill": fill,
|
|
367
|
+
"text-anchor": anchor,
|
|
368
|
+
}
|
|
369
|
+
if weight and weight != "normal":
|
|
370
|
+
attrs["font-weight"] = weight
|
|
371
|
+
return f'<text {_serialize_attrs(attrs)}>{_xml_escape(text)}</text>'
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def svg_line(x1: float, y1: float, x2: float, y2: float, *,
|
|
375
|
+
stroke: str, width: float = 1) -> str:
|
|
376
|
+
return f'<line {_serialize_attrs({"x1": x1, "y1": y1, "x2": x2, "y2": y2, "stroke": stroke, "stroke-width": width})}/>'
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def svg_polyline(points: list[tuple[float, float]], *, stroke: str,
|
|
380
|
+
width: float = 2, fill: str = "none") -> str:
|
|
381
|
+
pts_str = " ".join(f"{_fmt_num(x)},{_fmt_num(y)}" for x, y in points)
|
|
382
|
+
return f'<polyline {_serialize_attrs({"points": pts_str, "stroke": stroke, "stroke-width": width, "fill": fill})}/>'
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def svg_path(d: str, *, stroke: str | None = None,
|
|
386
|
+
fill: str | None = None) -> str:
|
|
387
|
+
"""SVG path element — `d` is the only opaque attribute in the kernel.
|
|
388
|
+
|
|
389
|
+
Byte-stability caveat: callers building `d` from coordinates MUST format
|
|
390
|
+
each numeric value through `_fmt_num` before stringification, e.g.,
|
|
391
|
+
`f"M{_fmt_num(x0)} {_fmt_num(y0)} L{_fmt_num(x1)} {_fmt_num(y1)}"`. The
|
|
392
|
+
`d` minilanguage is opaque to `_serialize_attrs`, so a stray `f"{x:.6f}"`
|
|
393
|
+
would diverge goldens silently.
|
|
394
|
+
"""
|
|
395
|
+
attrs: dict[str, object] = {"d": d}
|
|
396
|
+
if stroke is not None:
|
|
397
|
+
attrs["stroke"] = stroke
|
|
398
|
+
if fill is not None:
|
|
399
|
+
attrs["fill"] = fill
|
|
400
|
+
return f'<path {_serialize_attrs(attrs)}/>'
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def svg_group(children: list, *, transform: str | None = None) -> str:
|
|
404
|
+
attrs: dict = {}
|
|
405
|
+
if transform is not None:
|
|
406
|
+
attrs["transform"] = transform
|
|
407
|
+
open_tag = f'<g {_serialize_attrs(attrs)}>' if attrs else "<g>"
|
|
408
|
+
return open_tag + "".join(children) + "</g>"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# --- Chart layout helpers ---
|
|
412
|
+
|
|
413
|
+
_PADDING_LEFT = 50 # axis labels
|
|
414
|
+
_PADDING_BOTTOM = 30 # x-tick labels
|
|
415
|
+
_PADDING_TOP = 10
|
|
416
|
+
_PADDING_RIGHT = 10
|
|
417
|
+
|
|
418
|
+
_HBAR_LABEL_GUTTER = 120.0 # left gutter for project labels (anonymized fits; long revealed labels may overflow)
|
|
419
|
+
_HBAR_RIGHT_PAD = 10.0 # right-side breathing room for value labels
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _chart_inner_box(
|
|
423
|
+
x: float, y: float, width: float, height: float,
|
|
424
|
+
) -> tuple[float, float, float, float]:
|
|
425
|
+
"""Compute (ix, iy, iw, ih) — the inner plot area inside chart padding."""
|
|
426
|
+
ix = x + _PADDING_LEFT
|
|
427
|
+
iy = y + _PADDING_TOP
|
|
428
|
+
iw = width - _PADDING_LEFT - _PADDING_RIGHT
|
|
429
|
+
ih = height - _PADDING_TOP - _PADDING_BOTTOM
|
|
430
|
+
return ix, iy, iw, ih
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _scale_y(
|
|
434
|
+
values: list[float], ih: float,
|
|
435
|
+
) -> tuple[float, Callable[[float], float]]:
|
|
436
|
+
"""Return y_max and a scale function f(value) -> y-pixel (top-down)."""
|
|
437
|
+
if not values:
|
|
438
|
+
return 1.0, lambda v: 0.0
|
|
439
|
+
y_max = max(values)
|
|
440
|
+
y_min = min(0.0, min(values))
|
|
441
|
+
span = y_max - y_min if (y_max - y_min) > 1e-9 else 1.0
|
|
442
|
+
def f(v: float) -> float:
|
|
443
|
+
# Higher value → smaller y (SVG y axis is top-down).
|
|
444
|
+
norm = (v - y_min) / span
|
|
445
|
+
return ih - (norm * ih)
|
|
446
|
+
return y_max, f
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _render_chart_no_data(palette: Mapping[str, str], *,
|
|
450
|
+
x: float, y: float, width: float, height: float) -> str:
|
|
451
|
+
"""Render the canonical '(no data)' placeholder for an empty chart."""
|
|
452
|
+
return svg_group([
|
|
453
|
+
svg_text(x + width / 2, y + height / 2, "(no data)",
|
|
454
|
+
font_size=12, fill=palette["muted"], anchor="middle"),
|
|
455
|
+
])
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# --- Chart renderers ---
|
|
459
|
+
|
|
460
|
+
# Line chart.
|
|
461
|
+
def _render_line_chart_svg(chart: LineChart, *, palette: dict,
|
|
462
|
+
x: float, y: float, width: float, height: float) -> str:
|
|
463
|
+
ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
|
|
464
|
+
pts = chart.points
|
|
465
|
+
if not pts:
|
|
466
|
+
return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
|
|
467
|
+
|
|
468
|
+
# Y-domain spans primary + multi_series + reference_lines so projected
|
|
469
|
+
# values that exceed the actual-sample max don't clip past the inner box.
|
|
470
|
+
y_values = [p.y_value for p in pts]
|
|
471
|
+
if chart.multi_series:
|
|
472
|
+
for series_pts in chart.multi_series.values():
|
|
473
|
+
y_values.extend(p.y_value for p in series_pts)
|
|
474
|
+
y_values.extend(r[0] for r in chart.reference_lines)
|
|
475
|
+
_, scale_y = _scale_y(y_values, ih)
|
|
476
|
+
|
|
477
|
+
# X-domain spans primary + multi_series so a projected ray that extends
|
|
478
|
+
# past the latest actual sample (e.g. forecast `now` -> `week_end`) lands
|
|
479
|
+
# at its true x position rather than getting pinned to enumerate-index.
|
|
480
|
+
# When primary uses sequential `x_value=float(i)` (e.g. report trend),
|
|
481
|
+
# this collapses to the prior `iw / (n-1)` spacing.
|
|
482
|
+
x_values = [p.x_value for p in pts]
|
|
483
|
+
if chart.multi_series:
|
|
484
|
+
for series_pts in chart.multi_series.values():
|
|
485
|
+
x_values.extend(p.x_value for p in series_pts)
|
|
486
|
+
x_min = min(x_values)
|
|
487
|
+
x_max = max(x_values)
|
|
488
|
+
x_span = x_max - x_min
|
|
489
|
+
if x_span <= 1e-9:
|
|
490
|
+
# Degenerate: single point or zero-width domain — anchor at left edge.
|
|
491
|
+
def scale_x(_v: float) -> float:
|
|
492
|
+
return 0.0
|
|
493
|
+
else:
|
|
494
|
+
def scale_x(v: float) -> float:
|
|
495
|
+
return iw * (v - x_min) / x_span
|
|
496
|
+
|
|
497
|
+
# Axes.
|
|
498
|
+
elements = []
|
|
499
|
+
elements.append(svg_line(ix, iy + ih, ix + iw, iy + ih,
|
|
500
|
+
stroke=palette["axis"], width=1))
|
|
501
|
+
elements.append(svg_line(ix, iy, ix, iy + ih,
|
|
502
|
+
stroke=palette["axis"], width=1))
|
|
503
|
+
|
|
504
|
+
# Reference lines.
|
|
505
|
+
for (ref_value, ref_label, severity) in chart.reference_lines:
|
|
506
|
+
ref_color = palette["ref_warn"] if severity == "warn" else palette["ref_alarm"]
|
|
507
|
+
ry = iy + scale_y(ref_value)
|
|
508
|
+
elements.append(svg_line(ix, ry, ix + iw, ry, stroke=ref_color, width=1))
|
|
509
|
+
elements.append(svg_text(ix + iw - 4, ry - 3, ref_label,
|
|
510
|
+
font_size=10, fill=ref_color, anchor="end"))
|
|
511
|
+
|
|
512
|
+
# Series polyline (primary series).
|
|
513
|
+
poly_pts = [(ix + scale_x(p.x_value), iy + scale_y(p.y_value)) for p in pts]
|
|
514
|
+
elements.append(svg_polyline(poly_pts, stroke=palette["series_primary"], width=2))
|
|
515
|
+
|
|
516
|
+
# Optional multi-series (forecast actual + projected).
|
|
517
|
+
if chart.multi_series:
|
|
518
|
+
for series_key, series_pts in sorted(chart.multi_series.items()):
|
|
519
|
+
series_color = palette["series_secondary"]
|
|
520
|
+
spoly = [(ix + scale_x(p.x_value), iy + scale_y(p.y_value)) for p in series_pts]
|
|
521
|
+
# Dashed for "projected" — simple stroke-dasharray.
|
|
522
|
+
attrs = {
|
|
523
|
+
"points": " ".join(f"{_fmt_num(px)},{_fmt_num(py)}" for px, py in spoly),
|
|
524
|
+
"stroke": series_color,
|
|
525
|
+
"stroke-width": 2,
|
|
526
|
+
"stroke-dasharray": "4 3",
|
|
527
|
+
"fill": "none",
|
|
528
|
+
}
|
|
529
|
+
elements.append(f'<polyline {_serialize_attrs(attrs)}/>')
|
|
530
|
+
|
|
531
|
+
# X-tick labels (one per primary sample, positioned by x_value).
|
|
532
|
+
for p in pts:
|
|
533
|
+
tx = ix + scale_x(p.x_value)
|
|
534
|
+
elements.append(svg_text(tx, iy + ih + 14, p.x_label,
|
|
535
|
+
font_size=10, fill=palette["muted"], anchor="middle"))
|
|
536
|
+
|
|
537
|
+
# Y-axis label.
|
|
538
|
+
elements.append(svg_text(ix - 10, iy + ih / 2, chart.y_label,
|
|
539
|
+
font_size=10, fill=palette["muted"], anchor="end"))
|
|
540
|
+
|
|
541
|
+
return svg_group(elements)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# Bar chart (vertical).
|
|
545
|
+
def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
|
|
546
|
+
x: float, y: float, width: float, height: float) -> str:
|
|
547
|
+
ix, iy, iw, ih = _chart_inner_box(x, y, width, height)
|
|
548
|
+
pts = chart.points
|
|
549
|
+
if not pts:
|
|
550
|
+
return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
|
|
551
|
+
|
|
552
|
+
n = len(pts)
|
|
553
|
+
bar_gap = 4.0
|
|
554
|
+
total_gap = bar_gap * (n - 1) if n > 1 else 0.0
|
|
555
|
+
bar_w = max(2.0, (iw - total_gap) / n)
|
|
556
|
+
|
|
557
|
+
has_stacks = bool(chart.stacks)
|
|
558
|
+
# Sorted keys give deterministic stack ordering; matches the
|
|
559
|
+
# `sorted(all_model_keys)` ordering builders use for table columns,
|
|
560
|
+
# so legend swatch -> table column line up by position.
|
|
561
|
+
series_keys = sorted(chart.stacks.keys()) if has_stacks else []
|
|
562
|
+
|
|
563
|
+
if has_stacks:
|
|
564
|
+
per_bar_totals: list[float] = []
|
|
565
|
+
for i in range(n):
|
|
566
|
+
total = 0.0
|
|
567
|
+
for k in series_keys:
|
|
568
|
+
sp = chart.stacks[k]
|
|
569
|
+
if i < len(sp):
|
|
570
|
+
total += sp[i].y_value
|
|
571
|
+
per_bar_totals.append(total)
|
|
572
|
+
y_values = per_bar_totals
|
|
573
|
+
else:
|
|
574
|
+
y_values = [p.y_value for p in pts]
|
|
575
|
+
_, scale_y = _scale_y(y_values, ih)
|
|
576
|
+
|
|
577
|
+
elements = []
|
|
578
|
+
elements.append(svg_line(ix, iy + ih, ix + iw, iy + ih,
|
|
579
|
+
stroke=palette["axis"], width=1))
|
|
580
|
+
elements.append(svg_line(ix, iy, ix, iy + ih,
|
|
581
|
+
stroke=palette["axis"], width=1))
|
|
582
|
+
|
|
583
|
+
series_palette = palette["series_palette"]
|
|
584
|
+
|
|
585
|
+
for i, p in enumerate(pts):
|
|
586
|
+
bx = ix + i * (bar_w + bar_gap)
|
|
587
|
+
if has_stacks:
|
|
588
|
+
# Cumulative bottom-up segments. Skip zero/negative segments so
|
|
589
|
+
# they don't emit a degenerate rect (and don't shift the next
|
|
590
|
+
# segment's baseline incorrectly).
|
|
591
|
+
y_running = 0.0
|
|
592
|
+
for k_idx, k in enumerate(series_keys):
|
|
593
|
+
sp = chart.stacks[k]
|
|
594
|
+
seg_v = sp[i].y_value if i < len(sp) else 0.0
|
|
595
|
+
if seg_v <= 0:
|
|
596
|
+
continue
|
|
597
|
+
seg_top_y = iy + scale_y(y_running + seg_v)
|
|
598
|
+
seg_bot_y = iy + scale_y(y_running)
|
|
599
|
+
seg_h = seg_bot_y - seg_top_y
|
|
600
|
+
color = series_palette[k_idx % len(series_palette)]
|
|
601
|
+
elements.append(svg_rect(bx, seg_top_y, bar_w, seg_h, fill=color))
|
|
602
|
+
y_running += seg_v
|
|
603
|
+
else:
|
|
604
|
+
by = iy + scale_y(p.y_value)
|
|
605
|
+
bh = (iy + ih) - by
|
|
606
|
+
elements.append(svg_rect(bx, by, bar_w, bh, fill=palette["series_primary"]))
|
|
607
|
+
# X-tick label centered under bar.
|
|
608
|
+
tx = bx + bar_w / 2
|
|
609
|
+
elements.append(svg_text(tx, iy + ih + 14, p.x_label,
|
|
610
|
+
font_size=10, fill=palette["muted"], anchor="middle"))
|
|
611
|
+
|
|
612
|
+
# Legend (top-right of inner box, only when stacks are present).
|
|
613
|
+
# SVG is the only artifact where the table doesn't double as a key, so
|
|
614
|
+
# the legend matters most for `--format svg` output. Placed inside the
|
|
615
|
+
# inner box so total chart dimensions stay byte-stable.
|
|
616
|
+
if has_stacks:
|
|
617
|
+
legend_swatch_w = 8.0
|
|
618
|
+
legend_swatch_h = 8.0
|
|
619
|
+
legend_row_h = 12.0
|
|
620
|
+
legend_col_w = 160.0
|
|
621
|
+
legend_left = ix + iw - legend_col_w
|
|
622
|
+
for k_idx, k in enumerate(series_keys):
|
|
623
|
+
row_y = iy + 4 + k_idx * legend_row_h
|
|
624
|
+
color = series_palette[k_idx % len(series_palette)]
|
|
625
|
+
elements.append(svg_rect(
|
|
626
|
+
legend_left, row_y, legend_swatch_w, legend_swatch_h,
|
|
627
|
+
fill=color,
|
|
628
|
+
))
|
|
629
|
+
elements.append(svg_text(
|
|
630
|
+
legend_left + legend_swatch_w + 4, row_y + 8, k,
|
|
631
|
+
font_size=10, fill=palette["fg"], anchor="start",
|
|
632
|
+
))
|
|
633
|
+
|
|
634
|
+
elements.append(svg_text(ix - 10, iy + ih / 2, chart.y_label,
|
|
635
|
+
font_size=10, fill=palette["muted"], anchor="end"))
|
|
636
|
+
|
|
637
|
+
return svg_group(elements)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# Horizontal bar chart (top-N with cap).
|
|
641
|
+
def _render_hbar_chart_svg(chart: HorizontalBarChart, *, palette: dict,
|
|
642
|
+
x: float, y: float, width: float, height: float) -> str:
|
|
643
|
+
pts = chart.points
|
|
644
|
+
if chart.cap is not None:
|
|
645
|
+
pts = pts[:chart.cap]
|
|
646
|
+
if not pts:
|
|
647
|
+
return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
|
|
648
|
+
|
|
649
|
+
label_w = _HBAR_LABEL_GUTTER
|
|
650
|
+
ix = x + label_w
|
|
651
|
+
iy = y + 6
|
|
652
|
+
iw = width - label_w - _HBAR_RIGHT_PAD
|
|
653
|
+
ih = height - 12
|
|
654
|
+
|
|
655
|
+
n = len(pts)
|
|
656
|
+
row_h = ih / n
|
|
657
|
+
bar_h = max(8.0, row_h * 0.7)
|
|
658
|
+
bar_gap = (row_h - bar_h) / 2
|
|
659
|
+
|
|
660
|
+
x_max = max(p.y_value for p in pts)
|
|
661
|
+
if x_max <= 0:
|
|
662
|
+
x_max = 1.0
|
|
663
|
+
|
|
664
|
+
elements = []
|
|
665
|
+
for i, p in enumerate(pts):
|
|
666
|
+
ry = iy + i * row_h + bar_gap
|
|
667
|
+
bw = (p.y_value / x_max) * iw
|
|
668
|
+
elements.append(svg_rect(ix, ry, bw, bar_h, fill=palette["series_primary"]))
|
|
669
|
+
# Label gutter (right-aligned to ix - 4).
|
|
670
|
+
elements.append(svg_text(ix - 4, ry + bar_h / 2 + 3, p.x_label,
|
|
671
|
+
font_size=11, fill=palette["fg"], anchor="end"))
|
|
672
|
+
# Value label at end of bar.
|
|
673
|
+
elements.append(svg_text(ix + bw + 4, ry + bar_h / 2 + 3,
|
|
674
|
+
f"${p.y_value:,.2f}",
|
|
675
|
+
font_size=10, fill=palette["muted"], anchor="start"))
|
|
676
|
+
|
|
677
|
+
return svg_group(elements)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# --- SVG chrome helpers ---
|
|
681
|
+
|
|
682
|
+
def _format_generated_at_iso(dt: datetime) -> str:
|
|
683
|
+
"""ISO 8601, no microseconds. UTC datetimes use trailing 'Z' instead of '+00:00';
|
|
684
|
+
non-UTC datetimes keep their offset-suffix form (no Z substitution applies)."""
|
|
685
|
+
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _version_label(version: str) -> str:
|
|
689
|
+
"""v<X.Y.Z> when version is set; 'dev' otherwise (Section 6.9 fallback)."""
|
|
690
|
+
return f"v{version}" if version else "dev"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _render_svg_header(snap: ShareSnapshot, *, palette: dict,
|
|
694
|
+
x: float, y: float, width: float) -> str:
|
|
695
|
+
elements = []
|
|
696
|
+
elements.append(svg_text(x, y + 18, snap.title,
|
|
697
|
+
font_size=18, fill=palette["fg"], weight="bold"))
|
|
698
|
+
if snap.subtitle:
|
|
699
|
+
elements.append(svg_text(x, y + 36, snap.subtitle,
|
|
700
|
+
font_size=12, fill=palette["muted"]))
|
|
701
|
+
elements.append(svg_text(x + width, y + 18,
|
|
702
|
+
_format_generated_at_iso(snap.generated_at),
|
|
703
|
+
font_size=10, fill=palette["muted"], anchor="end"))
|
|
704
|
+
return svg_group(elements)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
|
|
708
|
+
x: float, y: float, width: float, branding: bool) -> str:
|
|
709
|
+
if not branding:
|
|
710
|
+
return ""
|
|
711
|
+
label = (
|
|
712
|
+
"Generated by cctally · github.com/omrikais/cctally · "
|
|
713
|
+
+ _version_label(snap.version)
|
|
714
|
+
)
|
|
715
|
+
return svg_group([
|
|
716
|
+
svg_text(x, y, label, font_size=10, fill=palette["footer_link"]),
|
|
717
|
+
])
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# --- Scrubber ---
|
|
721
|
+
#
|
|
722
|
+
# Anonymization chokepoint (spec Section 5.3 / 7 / 8.4). Operates on a
|
|
723
|
+
# ShareSnapshot before any renderer runs; returns a new snapshot with project
|
|
724
|
+
# labels rewritten everywhere they appear in the rendered output (ProjectCell
|
|
725
|
+
# in rows, ChartPoint.project_label / .x_label in chart points + multi-series
|
|
726
|
+
# + stacks). The Section 8.4 invariant — anonymized output contains zero
|
|
727
|
+
# original tokens across md/svg/html — is the canary; if any new project-
|
|
728
|
+
# label site is introduced in the data model later, both `_collect_project_
|
|
729
|
+
# costs` (gather) and `_apply_anon_mapping` (rewrite) must be extended.
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
733
|
+
"""Walk rows: for each row containing a ProjectCell, sum MoneyCell values
|
|
734
|
+
in the same row under the project label.
|
|
735
|
+
|
|
736
|
+
Charts also contribute via ChartPoint.project_label + y_value (when y_value
|
|
737
|
+
is in $). For consistency we union both sources; rows take precedence on
|
|
738
|
+
duplicates."""
|
|
739
|
+
costs: dict[str, float] = {}
|
|
740
|
+
for row in snap.rows:
|
|
741
|
+
proj_label: str | None = None
|
|
742
|
+
money = 0.0
|
|
743
|
+
for cell in row.cells.values():
|
|
744
|
+
if isinstance(cell, ProjectCell):
|
|
745
|
+
proj_label = cell.label
|
|
746
|
+
elif isinstance(cell, MoneyCell):
|
|
747
|
+
money += cell.usd
|
|
748
|
+
if proj_label is not None:
|
|
749
|
+
costs[proj_label] = costs.get(proj_label, 0.0) + money
|
|
750
|
+
|
|
751
|
+
if snap.chart is not None:
|
|
752
|
+
chart_pts: list[ChartPoint] = []
|
|
753
|
+
if isinstance(snap.chart, LineChart):
|
|
754
|
+
chart_pts = list(snap.chart.points)
|
|
755
|
+
if snap.chart.multi_series:
|
|
756
|
+
for series in snap.chart.multi_series.values():
|
|
757
|
+
chart_pts.extend(series)
|
|
758
|
+
elif isinstance(snap.chart, BarChart):
|
|
759
|
+
chart_pts = list(snap.chart.points)
|
|
760
|
+
if snap.chart.stacks:
|
|
761
|
+
for series in snap.chart.stacks.values():
|
|
762
|
+
chart_pts.extend(series)
|
|
763
|
+
elif isinstance(snap.chart, HorizontalBarChart):
|
|
764
|
+
chart_pts = list(snap.chart.points)
|
|
765
|
+
# Chart-only fallback: tiebreaker only — `y_value` is dollars for project
|
|
766
|
+
# bar charts but may be a ratio for trend charts. Affects sort order of
|
|
767
|
+
# project-N labels, not anonymization correctness.
|
|
768
|
+
for p in chart_pts:
|
|
769
|
+
if p.project_label and p.project_label not in costs:
|
|
770
|
+
costs[p.project_label] = p.y_value
|
|
771
|
+
|
|
772
|
+
return costs
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _build_anon_mapping(project_costs: dict[str, float]) -> dict[str, str]:
|
|
776
|
+
"""Sort labels by descending cost (lex tie-break); assign project-1, project-2, ...
|
|
777
|
+
|
|
778
|
+
"(unknown)" is never numbered — keeps its literal label.
|
|
779
|
+
"""
|
|
780
|
+
items = [
|
|
781
|
+
(label, cost)
|
|
782
|
+
for label, cost in project_costs.items()
|
|
783
|
+
if label != "(unknown)"
|
|
784
|
+
]
|
|
785
|
+
items.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
786
|
+
mapping: dict[str, str] = {
|
|
787
|
+
label: f"project-{i + 1}" for i, (label, _cost) in enumerate(items)
|
|
788
|
+
}
|
|
789
|
+
if "(unknown)" in project_costs:
|
|
790
|
+
mapping["(unknown)"] = "(unknown)"
|
|
791
|
+
return mapping
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _apply_anon_mapping(
|
|
795
|
+
snap: ShareSnapshot, mapping: dict[str, str],
|
|
796
|
+
) -> ShareSnapshot:
|
|
797
|
+
"""Return a new ShareSnapshot with project labels replaced everywhere.
|
|
798
|
+
|
|
799
|
+
Walks: (a) every Row.cells dict — replaces ProjectCell.label;
|
|
800
|
+
(b) ChartPoint.project_label AND .x_label (when x_label == project_label,
|
|
801
|
+
i.e. project-axis charts) on chart.points + multi_series + stacks.
|
|
802
|
+
"""
|
|
803
|
+
new_rows: list[Row] = []
|
|
804
|
+
for row in snap.rows:
|
|
805
|
+
new_cells: dict[str, Cell] = {}
|
|
806
|
+
for key, cell in row.cells.items():
|
|
807
|
+
if isinstance(cell, ProjectCell) and cell.label in mapping:
|
|
808
|
+
new_cells[key] = ProjectCell(mapping[cell.label])
|
|
809
|
+
else:
|
|
810
|
+
new_cells[key] = cell
|
|
811
|
+
new_rows.append(Row(cells=new_cells))
|
|
812
|
+
|
|
813
|
+
new_chart: ChartSpec | None = snap.chart
|
|
814
|
+
if snap.chart is not None:
|
|
815
|
+
def _rewrite_pt(p: ChartPoint) -> ChartPoint:
|
|
816
|
+
if p.project_label:
|
|
817
|
+
# Fail-safe: any label not in mapping (e.g., from drift between
|
|
818
|
+
# _collect and _apply, or a future code path that adds chart
|
|
819
|
+
# points after gather) is mapped to "(unknown)" rather than
|
|
820
|
+
# passed through. Privacy invariant: never leak a non-anonymized
|
|
821
|
+
# label, even if the gather pass missed it.
|
|
822
|
+
new_label = mapping.get(p.project_label, "(unknown)")
|
|
823
|
+
else:
|
|
824
|
+
new_label = None
|
|
825
|
+
# x_label rewrite stays guarded — only anonymize if x_label is the
|
|
826
|
+
# project axis AND the label is in mapping (preserves non-project
|
|
827
|
+
# x_label values like time labels).
|
|
828
|
+
if (p.project_label
|
|
829
|
+
and p.x_label == p.project_label
|
|
830
|
+
and p.x_label in mapping):
|
|
831
|
+
new_x = mapping[p.x_label]
|
|
832
|
+
else:
|
|
833
|
+
new_x = p.x_label
|
|
834
|
+
return ChartPoint(
|
|
835
|
+
x_label=new_x,
|
|
836
|
+
x_value=p.x_value,
|
|
837
|
+
y_value=p.y_value,
|
|
838
|
+
project_label=new_label,
|
|
839
|
+
series_key=p.series_key,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
if isinstance(snap.chart, LineChart):
|
|
843
|
+
new_chart = LineChart(
|
|
844
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
845
|
+
y_label=snap.chart.y_label,
|
|
846
|
+
reference_lines=snap.chart.reference_lines,
|
|
847
|
+
multi_series=(
|
|
848
|
+
{k: tuple(_rewrite_pt(p) for p in v)
|
|
849
|
+
for k, v in snap.chart.multi_series.items()}
|
|
850
|
+
if snap.chart.multi_series else None
|
|
851
|
+
),
|
|
852
|
+
)
|
|
853
|
+
elif isinstance(snap.chart, BarChart):
|
|
854
|
+
new_chart = BarChart(
|
|
855
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
856
|
+
y_label=snap.chart.y_label,
|
|
857
|
+
stacks=(
|
|
858
|
+
{k: tuple(_rewrite_pt(p) for p in v)
|
|
859
|
+
for k, v in snap.chart.stacks.items()}
|
|
860
|
+
if snap.chart.stacks else None
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
elif isinstance(snap.chart, HorizontalBarChart):
|
|
864
|
+
new_chart = HorizontalBarChart(
|
|
865
|
+
points=tuple(_rewrite_pt(p) for p in snap.chart.points),
|
|
866
|
+
x_label=snap.chart.x_label,
|
|
867
|
+
cap=snap.chart.cap,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# When ShareSnapshot grows a new field, add it to this constructor — the
|
|
871
|
+
# scrubber must thread every field through to preserve frozen semantics.
|
|
872
|
+
return ShareSnapshot(
|
|
873
|
+
cmd=snap.cmd,
|
|
874
|
+
title=snap.title,
|
|
875
|
+
subtitle=snap.subtitle,
|
|
876
|
+
period=snap.period,
|
|
877
|
+
columns=snap.columns,
|
|
878
|
+
rows=tuple(new_rows),
|
|
879
|
+
chart=new_chart,
|
|
880
|
+
totals=snap.totals,
|
|
881
|
+
notes=snap.notes,
|
|
882
|
+
generated_at=snap.generated_at,
|
|
883
|
+
version=snap.version,
|
|
884
|
+
template_id=snap.template_id,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _scrub(snap: ShareSnapshot, *, reveal_projects: bool) -> ShareSnapshot:
|
|
889
|
+
"""Anonymize project labels unless reveal_projects is True.
|
|
890
|
+
|
|
891
|
+
When reveal_projects is True, returns the SAME instance (identity preserved
|
|
892
|
+
so callers can rely on `out is snap`). When False, returns a NEW snapshot
|
|
893
|
+
with ProjectCell labels and ChartPoint project/x labels rewritten via
|
|
894
|
+
`_build_anon_mapping`. If no project labels are present in the snapshot,
|
|
895
|
+
also returns the original instance.
|
|
896
|
+
"""
|
|
897
|
+
if reveal_projects:
|
|
898
|
+
return snap
|
|
899
|
+
project_costs = _collect_project_costs(snap)
|
|
900
|
+
if not project_costs:
|
|
901
|
+
return snap
|
|
902
|
+
mapping = _build_anon_mapping(project_costs)
|
|
903
|
+
return _apply_anon_mapping(snap, mapping)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# --- Format renderers ---
|
|
907
|
+
|
|
908
|
+
def _render_md_fragment(snap: ShareSnapshot, *, branding: bool) -> str:
|
|
909
|
+
"""Render the MD section body.
|
|
910
|
+
|
|
911
|
+
M1.2 contract: returns the full current `_render_md` body. Frontmatter
|
|
912
|
+
(added by M2.2) is layered on at the wrap step via `_build_md_frontmatter`
|
|
913
|
+
+ `_wrap_document`. Fragment shape is body-only by definition; even
|
|
914
|
+
without frontmatter the wrap layer remains the single chrome chokepoint
|
|
915
|
+
so future surfaces (compose, history) extend it once.
|
|
916
|
+
"""
|
|
917
|
+
parts = [f"# {_md_escape(snap.title)}"]
|
|
918
|
+
if snap.subtitle:
|
|
919
|
+
parts.append(f"_{_md_escape(snap.subtitle)}_")
|
|
920
|
+
parts.append(f"_{_format_generated_at_iso(snap.generated_at)}_")
|
|
921
|
+
parts.append("") # blank line before table
|
|
922
|
+
parts.append(_render_md_table(snap))
|
|
923
|
+
|
|
924
|
+
if snap.totals:
|
|
925
|
+
parts.append("")
|
|
926
|
+
for t in snap.totals:
|
|
927
|
+
parts.append(f"- **{_md_escape(t.label)}:** {_md_escape(t.value)}")
|
|
928
|
+
|
|
929
|
+
if snap.notes:
|
|
930
|
+
parts.append("")
|
|
931
|
+
for n in snap.notes:
|
|
932
|
+
parts.append(f"> {_md_escape(n)}")
|
|
933
|
+
|
|
934
|
+
if branding:
|
|
935
|
+
parts.append("")
|
|
936
|
+
parts.append(
|
|
937
|
+
f"_Generated by [cctally](https://github.com/omrikais/cctally) · "
|
|
938
|
+
f"{_version_label(snap.version)} · "
|
|
939
|
+
f"{_format_generated_at_iso(snap.generated_at)}_"
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
return "\n".join(parts) + "\n"
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# --- SVG composition ---
|
|
946
|
+
|
|
947
|
+
_SVG_WIDTH = 600
|
|
948
|
+
_SVG_HEADER_H = 60
|
|
949
|
+
_SVG_CHART_H = 220
|
|
950
|
+
_SVG_FOOTER_H = 30
|
|
951
|
+
_SVG_PADDING = 20
|
|
952
|
+
# Composition-level offset from the footer band's top edge to the text baseline.
|
|
953
|
+
# (Inside _render_svg_header, the raw `y + 18` / `y + 36` literals are font-metric
|
|
954
|
+
# baseline offsets for the 18pt title and 12pt subtitle — they live at the chrome
|
|
955
|
+
# helper site, not at the composition site.)
|
|
956
|
+
_SVG_FOOTER_BASELINE = 18
|
|
957
|
+
# Vertical padding between stacked sections in `_stitch_svg`.
|
|
958
|
+
_SVG_SECTION_GAP = 20.0
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
962
|
+
branding: bool, include_chrome: bool = True) -> str:
|
|
963
|
+
"""Render snapshot to SVG.
|
|
964
|
+
|
|
965
|
+
include_chrome=True → standalone SVG with title/subtitle/timestamp/footer.
|
|
966
|
+
include_chrome=False → chart-only (HTML wrapper consumes this).
|
|
967
|
+
"""
|
|
968
|
+
if include_chrome:
|
|
969
|
+
height = _SVG_HEADER_H + _SVG_CHART_H + _SVG_FOOTER_H + (_SVG_PADDING * 2)
|
|
970
|
+
else:
|
|
971
|
+
height = _SVG_CHART_H + (_SVG_PADDING * 2)
|
|
972
|
+
|
|
973
|
+
pieces = []
|
|
974
|
+
|
|
975
|
+
if include_chrome:
|
|
976
|
+
pieces.append(_render_svg_header(
|
|
977
|
+
snap, palette=palette,
|
|
978
|
+
x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
|
|
979
|
+
))
|
|
980
|
+
|
|
981
|
+
# Chart.
|
|
982
|
+
chart_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0)
|
|
983
|
+
if snap.chart is not None:
|
|
984
|
+
if isinstance(snap.chart, LineChart):
|
|
985
|
+
pieces.append(_render_line_chart_svg(
|
|
986
|
+
snap.chart, palette=palette,
|
|
987
|
+
x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
|
|
988
|
+
))
|
|
989
|
+
elif isinstance(snap.chart, BarChart):
|
|
990
|
+
pieces.append(_render_bar_chart_svg(
|
|
991
|
+
snap.chart, palette=palette,
|
|
992
|
+
x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
|
|
993
|
+
))
|
|
994
|
+
elif isinstance(snap.chart, HorizontalBarChart):
|
|
995
|
+
pieces.append(_render_hbar_chart_svg(
|
|
996
|
+
snap.chart, palette=palette,
|
|
997
|
+
x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
|
|
998
|
+
))
|
|
999
|
+
|
|
1000
|
+
if include_chrome:
|
|
1001
|
+
pieces.append(_render_svg_footer(
|
|
1002
|
+
snap, palette=palette,
|
|
1003
|
+
x=_SVG_PADDING,
|
|
1004
|
+
y=_SVG_PADDING + _SVG_HEADER_H + _SVG_CHART_H + _SVG_FOOTER_BASELINE,
|
|
1005
|
+
width=_SVG_WIDTH,
|
|
1006
|
+
branding=branding,
|
|
1007
|
+
))
|
|
1008
|
+
|
|
1009
|
+
total_w = _SVG_WIDTH + (_SVG_PADDING * 2)
|
|
1010
|
+
bg_rect = svg_rect(0, 0, total_w, height, fill=palette["bg"])
|
|
1011
|
+
inner = bg_rect + "".join(pieces)
|
|
1012
|
+
return (
|
|
1013
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
1014
|
+
f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(height)}" '
|
|
1015
|
+
f'width="{_fmt_num(total_w)}" height="{_fmt_num(height)}">'
|
|
1016
|
+
f'{inner}'
|
|
1017
|
+
f'</svg>'
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# --- Cell renderers (used by HTML and markdown) ---
|
|
1022
|
+
|
|
1023
|
+
def _render_cell_text(cell: Cell) -> str:
|
|
1024
|
+
"""Plain-text rendering of a cell — pre-escape. Used as base for md/html."""
|
|
1025
|
+
if isinstance(cell, TextCell):
|
|
1026
|
+
return cell.text
|
|
1027
|
+
if isinstance(cell, MoneyCell):
|
|
1028
|
+
# Sign goes outside the currency symbol: "-$12.34", not "$-12.34".
|
|
1029
|
+
sign = "-" if cell.usd < 0 else ""
|
|
1030
|
+
return f"{sign}${abs(cell.usd):,.2f}"
|
|
1031
|
+
if isinstance(cell, PercentCell):
|
|
1032
|
+
return f"{cell.pct:.1f}%"
|
|
1033
|
+
if isinstance(cell, DateCell):
|
|
1034
|
+
return cell.when.strftime("%Y-%m-%d")
|
|
1035
|
+
if isinstance(cell, DeltaCell):
|
|
1036
|
+
# Zero is conventionally treated as non-negative for deltas (renders "+0.0%").
|
|
1037
|
+
if cell.value > 0:
|
|
1038
|
+
sign = "+"
|
|
1039
|
+
elif cell.value < 0:
|
|
1040
|
+
sign = "-"
|
|
1041
|
+
else:
|
|
1042
|
+
sign = "+"
|
|
1043
|
+
if cell.unit == "%":
|
|
1044
|
+
return f"{sign}{abs(cell.value):.1f}%"
|
|
1045
|
+
# Sign goes outside the currency symbol for $-deltas too: "-$1.50".
|
|
1046
|
+
return f"{sign}${abs(cell.value):,.2f}"
|
|
1047
|
+
if isinstance(cell, ProjectCell):
|
|
1048
|
+
return cell.label
|
|
1049
|
+
raise TypeError(f"unknown cell type: {type(cell).__name__}")
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _render_cell_html(cell: Cell) -> str:
|
|
1053
|
+
return _xml_escape(_render_cell_text(cell))
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _render_cell_md(cell: Cell) -> str:
|
|
1057
|
+
return _md_escape(_render_cell_text(cell))
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# --- HTML chrome and table ---
|
|
1061
|
+
|
|
1062
|
+
def _render_html_table(snap: ShareSnapshot, palette: dict) -> str:
|
|
1063
|
+
th_cells = "".join(
|
|
1064
|
+
f'<th style="text-align:{c.align};padding:6px 10px;background:{palette["table_header_bg"]};color:{palette["fg"]}">{_xml_escape(c.label)}</th>'
|
|
1065
|
+
for c in snap.columns
|
|
1066
|
+
)
|
|
1067
|
+
body_rows = []
|
|
1068
|
+
for i, row in enumerate(snap.rows):
|
|
1069
|
+
bg = palette["table_row_alt"] if i % 2 == 1 else palette["bg"]
|
|
1070
|
+
td_cells = "".join(
|
|
1071
|
+
f'<td style="text-align:{c.align};padding:6px 10px;background:{bg};color:{palette["fg"]}">{_render_cell_html(row.cells.get(c.key, TextCell("")))}</td>'
|
|
1072
|
+
for c in snap.columns
|
|
1073
|
+
)
|
|
1074
|
+
body_rows.append(f"<tr>{td_cells}</tr>")
|
|
1075
|
+
return (
|
|
1076
|
+
f'<table style="border-collapse:collapse;font-family:system-ui,-apple-system,sans-serif;font-size:13px;margin-top:12px">'
|
|
1077
|
+
f'<thead><tr>{th_cells}</tr></thead>'
|
|
1078
|
+
f'<tbody>{"".join(body_rows)}</tbody>'
|
|
1079
|
+
f'</table>'
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool) -> str:
|
|
1084
|
+
"""Render the HTML body fragment — header + chart + table + (branded footer).
|
|
1085
|
+
|
|
1086
|
+
Document chrome (<!DOCTYPE>/<html>/<head>/<body>) is layered on at the wrap
|
|
1087
|
+
step via `_wrap_document`, keeping body-only content composable for v2's
|
|
1088
|
+
multi-section stitcher.
|
|
1089
|
+
"""
|
|
1090
|
+
# `_share_apply_content_toggles` sets `snap.chart=None` for show_chart=False
|
|
1091
|
+
# and `snap.columns=()`/`snap.rows=()` for show_table=False. Gate the chart
|
|
1092
|
+
# wrapper div + the table chrome on those, so disabled sections drop entirely
|
|
1093
|
+
# rather than rendering empty chrome (an empty `<svg>` chart area or an
|
|
1094
|
+
# `<table>` with no `<th>`/`<td>`).
|
|
1095
|
+
chart_html = (
|
|
1096
|
+
f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False)}</div>'
|
|
1097
|
+
if snap.chart is not None else ""
|
|
1098
|
+
)
|
|
1099
|
+
title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
|
|
1100
|
+
subtitle_html = (
|
|
1101
|
+
f'<div style="font-size:13px;color:{palette["muted"]};margin-top:4px">{_xml_escape(snap.subtitle)}</div>'
|
|
1102
|
+
if snap.subtitle else ""
|
|
1103
|
+
)
|
|
1104
|
+
timestamp_html = (
|
|
1105
|
+
f'<div style="font-size:11px;color:{palette["muted"]};margin-top:4px">'
|
|
1106
|
+
f'{_format_generated_at_iso(snap.generated_at)}</div>'
|
|
1107
|
+
)
|
|
1108
|
+
table_html = _render_html_table(snap, palette) if snap.columns else ""
|
|
1109
|
+
if branding:
|
|
1110
|
+
# "Generated by cctally" stays as a single plain-text substring so HTML
|
|
1111
|
+
# consumers can grep for the branding marker uniformly with the SVG
|
|
1112
|
+
# footer; the project URL is the linkable element.
|
|
1113
|
+
footer_html = (
|
|
1114
|
+
f'<footer style="margin-top:16px;font-size:11px;color:{palette["muted"]}">'
|
|
1115
|
+
f'Generated by cctally · '
|
|
1116
|
+
f'<a href="https://github.com/omrikais/cctally" style="color:{palette["footer_link"]}">github.com/omrikais/cctally</a>'
|
|
1117
|
+
f' · {_version_label(snap.version)}'
|
|
1118
|
+
f'</footer>'
|
|
1119
|
+
)
|
|
1120
|
+
else:
|
|
1121
|
+
footer_html = ""
|
|
1122
|
+
return (
|
|
1123
|
+
f'<header>{title_html}{subtitle_html}{timestamp_html}</header>'
|
|
1124
|
+
f'{chart_html}'
|
|
1125
|
+
f'{table_html}'
|
|
1126
|
+
f'{footer_html}'
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# --- Markdown chrome ---
|
|
1131
|
+
|
|
1132
|
+
def _render_md_table(snap: ShareSnapshot) -> str:
|
|
1133
|
+
"""Markdown table per ColumnSpec + Row contract."""
|
|
1134
|
+
if not snap.columns:
|
|
1135
|
+
return ""
|
|
1136
|
+
head = "| " + " | ".join(_md_escape(c.label) for c in snap.columns) + " |"
|
|
1137
|
+
sep = "|" + "|".join(
|
|
1138
|
+
":---:" if c.align == "center" else (
|
|
1139
|
+
"---:" if c.align == "right" else ":---"
|
|
1140
|
+
)
|
|
1141
|
+
for c in snap.columns
|
|
1142
|
+
) + "|"
|
|
1143
|
+
lines = [head, sep]
|
|
1144
|
+
for row in snap.rows:
|
|
1145
|
+
cells_md = [
|
|
1146
|
+
_render_cell_md(row.cells.get(c.key, TextCell("")))
|
|
1147
|
+
for c in snap.columns
|
|
1148
|
+
]
|
|
1149
|
+
lines.append("| " + " | ".join(cells_md) + " |")
|
|
1150
|
+
return "\n".join(lines)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
# --- SVG fragment ---
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _strip_outer_svg_tag(full_svg: str) -> tuple[str, float, float]:
|
|
1157
|
+
"""Extract inner XML + width/height from a standalone `<svg w h>...</svg>`.
|
|
1158
|
+
|
|
1159
|
+
Contract drift between renderer and stripper would raise here. Used by the
|
|
1160
|
+
SVG fragment path so compose can position multiple sections vertically
|
|
1161
|
+
inside one outer `<svg viewBox>` without nested-document weirdness.
|
|
1162
|
+
"""
|
|
1163
|
+
m = re.match(
|
|
1164
|
+
r'<svg[^>]*\bwidth="(?P<w>[\d.]+)"[^>]*\bheight="(?P<h>[\d.]+)"[^>]*>'
|
|
1165
|
+
r'(?P<body>.*)</svg>\s*$',
|
|
1166
|
+
full_svg,
|
|
1167
|
+
flags=re.DOTALL,
|
|
1168
|
+
)
|
|
1169
|
+
if not m:
|
|
1170
|
+
raise ValueError("unexpected SVG shape (renderer contract drift)")
|
|
1171
|
+
return m.group("body"), float(m.group("w")), float(m.group("h"))
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _render_svg_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool) -> tuple[str, float, float]:
|
|
1175
|
+
"""Return (inner_xml, width, height) for a chart-and-chrome section.
|
|
1176
|
+
|
|
1177
|
+
Calls into the existing `_render_svg(include_chrome=True)` producer, then
|
|
1178
|
+
strips the outer `<svg ...>` so the wrap step can rewrap byte-identically
|
|
1179
|
+
today and compose can stitch sections under one viewBox later.
|
|
1180
|
+
"""
|
|
1181
|
+
full = _render_svg(snap, palette=palette, branding=branding, include_chrome=True)
|
|
1182
|
+
return _strip_outer_svg_tag(full)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
# --- Print stylesheet + MD frontmatter (placeholders for M2.x layering) ---
|
|
1186
|
+
|
|
1187
|
+
def _print_stylesheet() -> str:
|
|
1188
|
+
"""Print-only CSS injected into HTML <head> for PDF export polish (spec §11.2).
|
|
1189
|
+
|
|
1190
|
+
M1.2 stub returned this string but it was NOT wired into `_wrap_document`
|
|
1191
|
+
yet to keep v1 HTML goldens byte-stable through M1-M3. M4.2 wires it in
|
|
1192
|
+
so Print → PDF on a dark-theme export renders as black-on-white instead
|
|
1193
|
+
of a solid-black page, and forces page-break-inside avoidance on
|
|
1194
|
+
semantic blocks. v1 + v2 HTML goldens re-baseline once on first run
|
|
1195
|
+
after this change and are byte-stable thereafter; MD + SVG goldens are
|
|
1196
|
+
unaffected (the stylesheet only lives in the HTML document head).
|
|
1197
|
+
"""
|
|
1198
|
+
return (
|
|
1199
|
+
'<style>@media print {'
|
|
1200
|
+
' body { color-scheme: light; background: #fff !important; color: #000 !important; }'
|
|
1201
|
+
' header, footer, section { page-break-inside: avoid; }'
|
|
1202
|
+
'}</style>'
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _build_md_frontmatter(snap: ShareSnapshot) -> str:
|
|
1207
|
+
"""YAML frontmatter prepended to MD exports (spec §11.5).
|
|
1208
|
+
|
|
1209
|
+
Byte-stable: key order is fixed (title -> generated_at -> period ->
|
|
1210
|
+
panel -> optional template_id -> anonymized -> cctally_version);
|
|
1211
|
+
single-line values; no eolian formatting. `_wrap_document` strips this when
|
|
1212
|
+
`branding=False` so `--no-branding` behaves consistently with the
|
|
1213
|
+
HTML/SVG footer-link stripping.
|
|
1214
|
+
|
|
1215
|
+
`template_id` is present for dashboard share-v2 snapshots and omitted
|
|
1216
|
+
for legacy CLI snapshots that have no template recipe.
|
|
1217
|
+
|
|
1218
|
+
`anonymized` reflects whether `_scrub` has rewritten this snapshot --
|
|
1219
|
+
detected via `_snapshot_is_anonymized` (label-prefix heuristic; see
|
|
1220
|
+
that function for the contract).
|
|
1221
|
+
"""
|
|
1222
|
+
period = snap.period
|
|
1223
|
+
period_iso = (
|
|
1224
|
+
f"{period.start.isoformat()}.."
|
|
1225
|
+
f"{period.end.isoformat()}"
|
|
1226
|
+
)
|
|
1227
|
+
anonymized = "true" if _snapshot_is_anonymized(snap) else "false"
|
|
1228
|
+
lines = [
|
|
1229
|
+
"---",
|
|
1230
|
+
f"title: {_yaml_scalar(snap.title)}",
|
|
1231
|
+
f"generated_at: {snap.generated_at.isoformat()}",
|
|
1232
|
+
f"period: {period_iso}",
|
|
1233
|
+
f"panel: {snap.cmd}",
|
|
1234
|
+
]
|
|
1235
|
+
if snap.template_id:
|
|
1236
|
+
lines.append(f"template_id: {_yaml_scalar(snap.template_id)}")
|
|
1237
|
+
lines.extend([
|
|
1238
|
+
f"anonymized: {anonymized}",
|
|
1239
|
+
f"cctally_version: {snap.version}",
|
|
1240
|
+
"---",
|
|
1241
|
+
"",
|
|
1242
|
+
])
|
|
1243
|
+
return "\n".join(lines)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _yaml_scalar(s: str) -> str:
|
|
1247
|
+
"""Quote a YAML scalar value when it would otherwise be ambiguous.
|
|
1248
|
+
|
|
1249
|
+
YAML 1.2 reserves leading `:`, `#`, `&`, `*`, `!`, `|`, `>`, `'`,
|
|
1250
|
+
`"`, `%`, `@`, `` ` `` and embedded `:` in plain scalars. We quote
|
|
1251
|
+
aggressively (when the value contains any of these or leading/trailing
|
|
1252
|
+
whitespace) to keep frontmatter parsers happy. Single quotes use
|
|
1253
|
+
YAML's `''` escape for the rare title containing a quote.
|
|
1254
|
+
"""
|
|
1255
|
+
if not s:
|
|
1256
|
+
return '""'
|
|
1257
|
+
if any(c in s for c in ":#&*!|>'\"%@`") or s.strip() != s:
|
|
1258
|
+
return "'" + s.replace("'", "''") + "'"
|
|
1259
|
+
return s
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
|
|
1263
|
+
"""Return True if every ProjectCell label is either an anon token or a sentinel.
|
|
1264
|
+
|
|
1265
|
+
`_scrub` rewrites labels to `project-<N>` (1-indexed, cost-descending).
|
|
1266
|
+
A snapshot with no `ProjectCell` rows returns False (nothing was
|
|
1267
|
+
anonymized because there was nothing to anonymize). `(unknown)` is the
|
|
1268
|
+
project-share sentinel for missing project_path (see
|
|
1269
|
+
`cmd_project`'s `_proj_label_for`) — it is never a revealed real name,
|
|
1270
|
+
so it is counted as also-anonymized. Mixed snapshots (some scrubbed,
|
|
1271
|
+
some revealed) are reported False to keep the frontmatter semantic
|
|
1272
|
+
("are projects revealed in this MD?").
|
|
1273
|
+
"""
|
|
1274
|
+
cells = [
|
|
1275
|
+
cell
|
|
1276
|
+
for row in snap.rows
|
|
1277
|
+
for cell in row.cells.values()
|
|
1278
|
+
if isinstance(cell, ProjectCell)
|
|
1279
|
+
]
|
|
1280
|
+
if not cells:
|
|
1281
|
+
return False
|
|
1282
|
+
return all(
|
|
1283
|
+
re.fullmatch(r"project-\d+", c.label) or c.label == "(unknown)"
|
|
1284
|
+
for c in cells
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# --- Fragment + wrap ---
|
|
1289
|
+
|
|
1290
|
+
def _render_fragment(snap: ShareSnapshot, *, format: str,
|
|
1291
|
+
palette: Mapping[str, str], branding: bool) -> "str | tuple[str, float, float]":
|
|
1292
|
+
"""Body-only render — no document chrome.
|
|
1293
|
+
|
|
1294
|
+
Returns:
|
|
1295
|
+
- format="html": str — the body fragment (header + chart + table + footer).
|
|
1296
|
+
- format="md": str — the markdown body (frontmatter not prepended).
|
|
1297
|
+
- format="svg": tuple[str, float, float] — (inner_xml, width, height).
|
|
1298
|
+
|
|
1299
|
+
Callers compose this into either:
|
|
1300
|
+
- render(): wraps in full document chrome via `_wrap_document`.
|
|
1301
|
+
- compose(): stitches multiple fragments under one wrapper (M3.x).
|
|
1302
|
+
"""
|
|
1303
|
+
if format == "html":
|
|
1304
|
+
return _render_html_fragment(snap, palette=palette, branding=branding)
|
|
1305
|
+
if format == "svg":
|
|
1306
|
+
return _render_svg_fragment(snap, palette=palette, branding=branding)
|
|
1307
|
+
if format == "md":
|
|
1308
|
+
return _render_md_fragment(snap, branding=branding)
|
|
1309
|
+
raise ValueError(f"unknown format: {format!r}")
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _wrap_document(fragment, *, format: str, palette: Mapping[str, str] | None,
|
|
1313
|
+
snap: ShareSnapshot, branding: bool = True) -> str:
|
|
1314
|
+
"""Wrap a fragment in document chrome.
|
|
1315
|
+
|
|
1316
|
+
Byte-stability invariant: for v1 single-section snapshots, the wrapped
|
|
1317
|
+
HTML/SVG output must equal the pre-refactor `_render_<fmt>` output
|
|
1318
|
+
character-for-character. The v1 share goldens (`bin/cctally-share-test`)
|
|
1319
|
+
are the gate.
|
|
1320
|
+
|
|
1321
|
+
MD: prepends `_build_md_frontmatter(snap)` when `branding=True` (spec
|
|
1322
|
+
§11.5). Suppressed when `branding=False` -- same surface as the
|
|
1323
|
+
HTML/SVG footer-link strip done inside the per-format renderers --
|
|
1324
|
+
so `--no-branding` behaves consistently across all three formats.
|
|
1325
|
+
"""
|
|
1326
|
+
if format == "html":
|
|
1327
|
+
return (
|
|
1328
|
+
f'<!DOCTYPE html>'
|
|
1329
|
+
f'<html lang="en"><head><meta charset="utf-8">'
|
|
1330
|
+
f'<title>{_xml_escape(snap.title)}</title>'
|
|
1331
|
+
f'{_print_stylesheet()}'
|
|
1332
|
+
f'</head>'
|
|
1333
|
+
f'<body style="background:{palette["bg"]};font-family:system-ui,-apple-system,sans-serif;padding:20px;max-width:680px;margin:auto">'
|
|
1334
|
+
f'{fragment}'
|
|
1335
|
+
f'</body></html>'
|
|
1336
|
+
)
|
|
1337
|
+
if format == "svg":
|
|
1338
|
+
inner, w, h = fragment
|
|
1339
|
+
# Mirror `_render_svg`'s exact outer-tag shape (xmlns, viewBox+w+h via
|
|
1340
|
+
# `_fmt_num`) so single-section wraps are byte-identical to the v1
|
|
1341
|
+
# producer. The 0 0 origin matches `_render_svg`'s `viewBox` literal.
|
|
1342
|
+
return (
|
|
1343
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
1344
|
+
f'viewBox="0 0 {_fmt_num(w)} {_fmt_num(h)}" '
|
|
1345
|
+
f'width="{_fmt_num(w)}" height="{_fmt_num(h)}">'
|
|
1346
|
+
f'{inner}'
|
|
1347
|
+
f'</svg>'
|
|
1348
|
+
)
|
|
1349
|
+
if format == "md":
|
|
1350
|
+
front = _build_md_frontmatter(snap) if branding else ""
|
|
1351
|
+
# Frontmatter already ends with "---\n" (trailing "" in the join
|
|
1352
|
+
# adds the separator newline); concat directly so the byte shape
|
|
1353
|
+
# is `---\n...---\n<fragment>`. When branding=False, front is
|
|
1354
|
+
# "" and the fragment passes through untouched.
|
|
1355
|
+
return (front + fragment) if front else fragment
|
|
1356
|
+
raise ValueError(f"unknown format: {format!r}")
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# --- Compose: stitch many fragments under one chrome (M3.1) ---
|
|
1360
|
+
|
|
1361
|
+
def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> str:
|
|
1362
|
+
"""Stitch multiple section fragments into a single document.
|
|
1363
|
+
|
|
1364
|
+
Pure function. Each section's body comes from `_render_fragment(...)` —
|
|
1365
|
+
the same body-only renderer used by single-panel share. `compose`
|
|
1366
|
+
wraps them all in composite chrome (one title, one footer, one outer
|
|
1367
|
+
wrapper) per format-specific stitching rules in spec §4.3.
|
|
1368
|
+
|
|
1369
|
+
`opts.reveal_projects` does NOT scrub here — scrubbing must have
|
|
1370
|
+
happened upstream (in the API layer) before the snapshot reaches
|
|
1371
|
+
this function. The kernel is anon-agnostic at compose time; the
|
|
1372
|
+
composer endpoint is the chokepoint.
|
|
1373
|
+
"""
|
|
1374
|
+
if not sections:
|
|
1375
|
+
raise ValueError("compose requires at least one section")
|
|
1376
|
+
fmt = opts.format
|
|
1377
|
+
if fmt == "html":
|
|
1378
|
+
return _stitch_html(sections, opts=opts)
|
|
1379
|
+
if fmt == "md":
|
|
1380
|
+
return _stitch_md(sections, opts=opts)
|
|
1381
|
+
if fmt == "svg":
|
|
1382
|
+
return _stitch_svg(sections, opts=opts)
|
|
1383
|
+
raise ValueError(f"unknown format: {fmt!r}")
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _stitch_html(sections: tuple[ComposedSection, ...], *,
|
|
1387
|
+
opts: ComposeOptions) -> str:
|
|
1388
|
+
"""HTML compose: single ``<html><body>`` wrapper, sections as ``<section>`` blocks."""
|
|
1389
|
+
palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
|
|
1390
|
+
body_open = (
|
|
1391
|
+
f'<body style="background:{palette["bg"]};'
|
|
1392
|
+
f'font-family:system-ui,-apple-system,sans-serif;'
|
|
1393
|
+
f'padding:20px;max-width:680px;margin:auto">'
|
|
1394
|
+
)
|
|
1395
|
+
header = f'<header><h1>{_xml_escape(opts.title)}</h1></header>'
|
|
1396
|
+
blocks = []
|
|
1397
|
+
for sec in sections:
|
|
1398
|
+
# branding here is for the *fragment* — composite footer is one
|
|
1399
|
+
# level up, so per-section branding is unconditional False to
|
|
1400
|
+
# keep the chrome single.
|
|
1401
|
+
frag = _render_fragment(sec.snap, format="html",
|
|
1402
|
+
palette=palette, branding=False)
|
|
1403
|
+
blocks.append(f'<section class="share-section">{frag}</section>')
|
|
1404
|
+
footer = (
|
|
1405
|
+
f'<footer style="font-size:11px;color:{palette["muted"]};margin-top:24px">'
|
|
1406
|
+
f'cctally · composed</footer>' if not opts.no_branding else ""
|
|
1407
|
+
)
|
|
1408
|
+
return (
|
|
1409
|
+
f'<!DOCTYPE html>'
|
|
1410
|
+
f'<html lang="en"><head><meta charset="utf-8">'
|
|
1411
|
+
f'<title>{_xml_escape(opts.title)}</title>'
|
|
1412
|
+
f'{_print_stylesheet()}'
|
|
1413
|
+
f'</head>{body_open}'
|
|
1414
|
+
f'{header}{"".join(blocks)}{footer}'
|
|
1415
|
+
f'</body></html>'
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _stitch_md(sections: tuple[ComposedSection, ...], *,
|
|
1420
|
+
opts: ComposeOptions) -> str:
|
|
1421
|
+
"""MD compose: one composite frontmatter + ``## `` headers + bodies."""
|
|
1422
|
+
parts: list[str] = []
|
|
1423
|
+
if not opts.no_branding:
|
|
1424
|
+
# Composite frontmatter: same key set as the single-section
|
|
1425
|
+
# `_build_md_frontmatter` but `panel` becomes `composed` and
|
|
1426
|
+
# `template_id` is omitted because one composed document can contain
|
|
1427
|
+
# multiple section templates.
|
|
1428
|
+
# `generated_at` and `cctally_version` are taken from the first
|
|
1429
|
+
# section since the composite document has no independent
|
|
1430
|
+
# provenance — every section was rendered in the same request.
|
|
1431
|
+
first_snap = sections[0].snap
|
|
1432
|
+
# `period` for the composite document = earliest start ..
|
|
1433
|
+
# latest end across all sections (per spec §11.5 implied
|
|
1434
|
+
# convention; reference test uses identical periods so the
|
|
1435
|
+
# union collapses).
|
|
1436
|
+
earliest = min(sec.snap.period.start for sec in sections)
|
|
1437
|
+
latest = max(sec.snap.period.end for sec in sections)
|
|
1438
|
+
anon_field = (
|
|
1439
|
+
"true"
|
|
1440
|
+
if all(_snapshot_is_anonymized(s.snap) for s in sections)
|
|
1441
|
+
else "false"
|
|
1442
|
+
)
|
|
1443
|
+
parts.append(
|
|
1444
|
+
"---\n"
|
|
1445
|
+
f"title: {_yaml_scalar(opts.title)}\n"
|
|
1446
|
+
f"generated_at: {first_snap.generated_at.isoformat()}\n"
|
|
1447
|
+
f"period: {earliest.isoformat()}..{latest.isoformat()}\n"
|
|
1448
|
+
f"panel: composed\n"
|
|
1449
|
+
f"anonymized: {anon_field}\n"
|
|
1450
|
+
f"cctally_version: {first_snap.version}\n"
|
|
1451
|
+
"---\n\n"
|
|
1452
|
+
)
|
|
1453
|
+
# Title as H1 (when frontmatter is present, this duplicates the
|
|
1454
|
+
# title key visually — accept the duplication; markdown readers
|
|
1455
|
+
# vary in how they render frontmatter and the H1 is the universal
|
|
1456
|
+
# fallback). Title and per-section heading go through `_md_escape`
|
|
1457
|
+
# to match the single-section path (`_render_md_body` at line 915);
|
|
1458
|
+
# otherwise inline HTML or MD specials in a user-entered title
|
|
1459
|
+
# would survive into the export unescaped.
|
|
1460
|
+
parts.append(f"# {_md_escape(opts.title)}\n\n")
|
|
1461
|
+
last_idx = len(sections) - 1
|
|
1462
|
+
for idx, sec in enumerate(sections):
|
|
1463
|
+
frag = _render_fragment(sec.snap, format="md", palette=PALETTE_LIGHT,
|
|
1464
|
+
branding=False)
|
|
1465
|
+
parts.append(f"## {_md_escape(sec.snap.title)}\n\n")
|
|
1466
|
+
parts.append(frag.rstrip("\n"))
|
|
1467
|
+
parts.append("\n\n" if idx < last_idx else "\n")
|
|
1468
|
+
return "".join(parts)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _stitch_svg(sections: tuple[ComposedSection, ...], *,
|
|
1472
|
+
opts: ComposeOptions) -> str:
|
|
1473
|
+
"""SVG compose: single outer ``<svg>``, sections positioned vertically.
|
|
1474
|
+
|
|
1475
|
+
`opts.no_branding` is intentionally unused: the SVG composite has no
|
|
1476
|
+
chrome footer band, so there is nothing to strip. HTML stitcher uses
|
|
1477
|
+
it to gate the `<footer>cctally · composed</footer>` line.
|
|
1478
|
+
"""
|
|
1479
|
+
palette = PALETTE_LIGHT if opts.theme == "light" else PALETTE_DARK
|
|
1480
|
+
inners: list[tuple[str, float, float]] = []
|
|
1481
|
+
for sec in sections:
|
|
1482
|
+
inner, w, h = _render_fragment(sec.snap, format="svg",
|
|
1483
|
+
palette=palette, branding=False)
|
|
1484
|
+
inners.append((inner, w, h))
|
|
1485
|
+
total_w = max(w for _, w, _ in inners)
|
|
1486
|
+
total_h = sum(h for _, _, h in inners) + _SVG_SECTION_GAP * (len(inners) - 1)
|
|
1487
|
+
body_blocks: list[str] = []
|
|
1488
|
+
y = 0.0
|
|
1489
|
+
for inner, _w, h in inners:
|
|
1490
|
+
body_blocks.append(
|
|
1491
|
+
f'<g transform="translate(0,{_fmt_num(y)})">{inner}</g>'
|
|
1492
|
+
)
|
|
1493
|
+
y += h + _SVG_SECTION_GAP
|
|
1494
|
+
return (
|
|
1495
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
1496
|
+
f'viewBox="0 0 {_fmt_num(total_w)} {_fmt_num(total_h)}" '
|
|
1497
|
+
f'width="{_fmt_num(total_w)}" height="{_fmt_num(total_h)}">'
|
|
1498
|
+
f'{"".join(body_blocks)}'
|
|
1499
|
+
f'</svg>'
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
# --- Public dispatch ---
|
|
1504
|
+
|
|
1505
|
+
def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool) -> str:
|
|
1506
|
+
"""Render a snapshot to the requested format.
|
|
1507
|
+
|
|
1508
|
+
Pure function: no I/O, no DB, no filesystem, no locks. Caller is
|
|
1509
|
+
responsible for emitting the result (stdout/file/clipboard/open).
|
|
1510
|
+
|
|
1511
|
+
Thin delegator over `_render_fragment` + `_wrap_document`: separates
|
|
1512
|
+
body-only rendering from document chrome so compose can stitch multiple
|
|
1513
|
+
sections under a single wrapper (M3.x).
|
|
1514
|
+
"""
|
|
1515
|
+
if format == "md":
|
|
1516
|
+
frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
|
|
1517
|
+
return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,
|
|
1518
|
+
branding=branding)
|
|
1519
|
+
|
|
1520
|
+
if theme == "light":
|
|
1521
|
+
palette = PALETTE_LIGHT
|
|
1522
|
+
elif theme == "dark":
|
|
1523
|
+
palette = PALETTE_DARK
|
|
1524
|
+
else:
|
|
1525
|
+
raise ValueError(f"unknown theme: {theme!r}")
|
|
1526
|
+
|
|
1527
|
+
if format not in ("svg", "html"):
|
|
1528
|
+
raise ValueError(f"unknown format: {format!r}")
|
|
1529
|
+
frag = _render_fragment(snap, format=format, palette=palette, branding=branding)
|
|
1530
|
+
return _wrap_document(frag, format=format, palette=palette, snap=snap,
|
|
1531
|
+
branding=branding)
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
# --- Backward-compat shims (Layer-A unit tests target these private helpers) ---
|
|
1535
|
+
#
|
|
1536
|
+
# The `_render_md` / `_render_html` names predate the fragment+wrap split.
|
|
1537
|
+
# v1 share goldens (`bin/cctally-share-test`) go through `render()` — these
|
|
1538
|
+
# shims exist solely to keep the Layer-A unit suite in `tests/test_lib_share.py`
|
|
1539
|
+
# pointed at byte-identical output without rewriting every call site. New code
|
|
1540
|
+
# should use `_render_fragment` + `_wrap_document` directly.
|
|
1541
|
+
|
|
1542
|
+
def _render_md(snap: ShareSnapshot, *, branding: bool) -> str:
|
|
1543
|
+
frag = _render_fragment(snap, format="md", palette=PALETTE_LIGHT, branding=branding)
|
|
1544
|
+
return _wrap_document(frag, format="md", palette=PALETTE_LIGHT, snap=snap,
|
|
1545
|
+
branding=branding)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _render_html(snap: ShareSnapshot, *, palette: dict, branding: bool) -> str:
|
|
1549
|
+
frag = _render_fragment(snap, format="html", palette=palette, branding=branding)
|
|
1550
|
+
return _wrap_document(frag, format="html", palette=palette, snap=snap,
|
|
1551
|
+
branding=branding)
|