dataface 0.3.1.dev22__py3-none-any.whl → 0.3.1.dev78__py3-none-any.whl

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.
Files changed (81) hide show
  1. d3_format/format.py +265 -265
  2. d3_format/spec.py +32 -34
  3. dataface/agent_api/board_artifact.py +160 -0
  4. dataface/agent_api/describe.py +2 -2
  5. dataface/agent_api/project_session.py +23 -0
  6. dataface/ai/llm.py +43 -3
  7. dataface/ai/skills/analyst-runbook/SKILL.md +7 -0
  8. dataface/cli/commands/board_artifact.py +85 -0
  9. dataface/cli/filesystem_project.py +2 -1
  10. dataface/cli/main.py +111 -0
  11. dataface/core/compile/board_artifact.py +202 -0
  12. dataface/core/compile/channel.py +4 -4
  13. dataface/core/compile/compiler.py +26 -4
  14. dataface/core/compile/enrich.py +7 -7
  15. dataface/core/compile/format.py +9 -2
  16. dataface/core/compile/merge.py +5 -3
  17. dataface/core/compile/models/face/resolved.py +0 -1
  18. dataface/core/compile/models/query/normalized.py +62 -50
  19. dataface/core/compile/models/style/theme/marks.py +8 -8
  20. dataface/core/compile/models/variable/authored.py +14 -2
  21. dataface/core/compile/models/vega_lite/config.py +4 -2
  22. dataface/core/compile/palette.py +5 -5
  23. dataface/core/compile/resolve/_resolve.py +3 -5
  24. dataface/core/compile/schema_renderers/board_resolved.py +100 -0
  25. dataface/core/compile/single_series_allocation.py +6 -18
  26. dataface/core/compile/source_map.py +126 -1
  27. dataface/core/compile/sql_guard.py +32 -2
  28. dataface/core/compile/style_cascade.py +39 -10
  29. dataface/core/compile/typography.py +4 -5
  30. dataface/core/compile/validate_formats.py +171 -0
  31. dataface/core/compile/vega_lite/mapping.py +2 -3
  32. dataface/core/dashboard.py +19 -5
  33. dataface/core/defaults/themes/_base.yaml +17 -6
  34. dataface/core/diagnostics/codes_compile.py +24 -1
  35. dataface/core/diagnostics/codes_render.py +59 -0
  36. dataface/core/diagnostics/execution.py +40 -2
  37. dataface/core/diagnostics/hints.py +9 -0
  38. dataface/core/execute/adapters/base.py +13 -1
  39. dataface/core/execute/adapters/duckdb_adapter.py +11 -0
  40. dataface/core/execute/chart_data_provider.py +75 -0
  41. dataface/core/execute/executor.py +12 -4
  42. dataface/core/execute/recording.py +99 -0
  43. dataface/core/execute/replay_provider.py +108 -0
  44. dataface/core/project.py +4 -0
  45. dataface/core/render/board_replay.py +136 -0
  46. dataface/core/render/chart/__init__.py +3 -2
  47. dataface/core/render/chart/emitters/_channels.py +2 -2
  48. dataface/core/render/chart/emitters/heatmap.py +1 -2
  49. dataface/core/render/chart/emitters/scatter.py +5 -9
  50. dataface/core/render/chart/features/baseline.py +2 -2
  51. dataface/core/render/chart/features/click_interactivity.py +3 -8
  52. dataface/core/render/chart/features/endpoint_labels.py +1 -1
  53. dataface/core/render/chart/features/value_labels.py +1 -3
  54. dataface/core/render/chart/labels.py +2 -2
  55. dataface/core/render/chart/presentation.py +6 -9
  56. dataface/core/render/chart/rendering.py +9 -9
  57. dataface/core/render/chart/table.py +3 -3
  58. dataface/core/render/chart/table_support.py +1 -1
  59. dataface/core/render/chart/translate.py +3 -7
  60. dataface/core/render/chart/type_inference.py +3 -19
  61. dataface/core/render/chart/vl_conditions.py +2 -2
  62. dataface/core/render/controls.py +9 -9
  63. dataface/core/render/converters/chart.py +2 -1
  64. dataface/core/render/face_resolve.py +4 -4
  65. dataface/core/render/face_to_dict.py +7 -1
  66. dataface/core/render/faces.py +10 -10
  67. dataface/core/render/format_utils.py +8 -22
  68. dataface/core/render/layout_sizing.py +4 -3
  69. dataface/core/render/layouts.py +9 -9
  70. dataface/core/render/renderer.py +5 -5
  71. dataface/core/render/sizing.py +69 -22
  72. dataface/core/render/svg_utils.py +2 -0
  73. dataface/core/render/variables_strip.py +3 -3
  74. dataface/core/text/format_d3.py +39 -0
  75. dataface/data/schemas/board-resolved/board-resolved.schema.json +22760 -0
  76. {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/METADATA +3 -1
  77. {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/RECORD +81 -71
  78. mdsvg/images.py +121 -1
  79. {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/WHEEL +0 -0
  80. {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/entry_points.txt +0 -0
  81. {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/licenses/LICENSE +0 -0
d3_format/format.py CHANGED
@@ -7,13 +7,18 @@ Key deviations from Python's standard number formatting:
7
7
  - SI type uses significant-digit precision (not decimal-place precision).
8
8
  - `d` type rounds to nearest integer using round-half-up.
9
9
  - `n` type is locale-aware `g` with grouping.
10
+
11
+ ``_apply_spec`` follows the shape of d3's own ``format(value)`` closure --
12
+ prefix/suffix assembly, the integer/fraction split, grouping, then padding --
13
+ because the order of those steps is observable: zero-fill is grouped along with
14
+ the digits, and a currency symbol displaces the percent sign.
10
15
  """
11
16
 
12
17
  from __future__ import annotations
13
18
 
14
19
  import math
15
20
  from collections.abc import Callable
16
- from decimal import ROUND_HALF_UP, Decimal
21
+ from decimal import ROUND_HALF_UP, Decimal, localcontext
17
22
  from typing import overload
18
23
 
19
24
  from d3_format.spec import FormatSpec, parse
@@ -45,6 +50,32 @@ _SI_PREFIXES = [
45
50
  ]
46
51
  _SI_PREFIX_OFFSET = 8 # index of the '' (10^0) entry
47
52
 
53
+ # Type letters that select a formatter. Every other letter the grammar admits
54
+ # falls back to ".12~g" (see _resolve_type); "n" is rewritten before this is
55
+ # consulted, so it is deliberately absent.
56
+ _KNOWN_TYPES = frozenset("%bcdefgoprsxX")
57
+
58
+ # Types whose precision counts significant digits (clamped to [1, 21]) rather
59
+ # than digits after the decimal point (clamped to [0, 20]).
60
+ _SIGNIFICANT_PRECISION_TYPES = frozenset("gprs")
61
+
62
+ # Types whose body may carry a fraction or exponent that must be held out of
63
+ # thousands-grouping and zero-padding (d3's `maybeSuffix`).
64
+ _SPLIT_SUFFIX_TYPES = frozenset("defgprs%")
65
+
66
+ # Types the "#" alternate form marks with a radix prefix.
67
+ _ALTERNATE_FORM_TYPES = frozenset("boxX")
68
+
69
+ # Radix types → (base, uppercase output).
70
+ _RADIX_TYPES: dict[str, tuple[int, bool]] = {
71
+ "b": (2, False),
72
+ "o": (8, False),
73
+ "x": (16, False),
74
+ "X": (16, True),
75
+ }
76
+
77
+ _RADIX_CONVERTERS: dict[int, Callable[[int], str]] = {2: bin, 8: oct, 16: hex}
78
+
48
79
 
49
80
  def _round_half_up(x: float, decimals: int) -> float:
50
81
  """Round x to `decimals` decimal places using round-half-up (like JS Math.round).
@@ -60,7 +91,44 @@ def _round_half_up(x: float, decimals: int) -> float:
60
91
  # Use Decimal(x) — the exact IEEE-754 binary value — so rounding matches d3/JS.
61
92
  # Decimal(repr(x)) would round "2.55" up to "2.6", but d3 rounds it down to "2.5"
62
93
  # because the binary value is 2.5499..., not 2.55.
63
- return float(Decimal(x).quantize(quant, rounding=ROUND_HALF_UP))
94
+ d = Decimal(x)
95
+ with localcontext() as ctx:
96
+ # quantize raises InvalidOperation when the result needs more digits
97
+ # than the context allows, which the default 28 does not for a large
98
+ # magnitude (1e30 as an integer) or a deep precision (.20f).
99
+ ctx.prec = max(ctx.prec, d.adjusted() + decimals + 2)
100
+ return float(d.quantize(quant, rounding=ROUND_HALF_UP))
101
+
102
+
103
+ def _js_number_to_string(v: float) -> str:
104
+ """Replicate JavaScript's ``String(number)``.
105
+
106
+ Python's ``repr`` yields the same shortest-round-trip digits but lays them
107
+ out with different thresholds (``1e-06`` where JS writes ``0.000001``,
108
+ ``255.0`` where JS writes ``255``), so the digits are re-rendered per
109
+ ECMA-262 Number::toString: exponential only outside 10^-6 .. 10^21.
110
+ """
111
+ if math.isnan(v):
112
+ return "NaN"
113
+ if math.isinf(v):
114
+ return "Infinity" if v > 0 else "-Infinity"
115
+ if v == 0:
116
+ return "0" # JS String(-0) is "0"
117
+ sign = "-" if v < 0 else ""
118
+ parts = Decimal(repr(abs(v))).normalize().as_tuple()
119
+ digits = "".join(str(d) for d in parts.digits)
120
+ k = len(digits)
121
+ # ECMA writes the value as 0.<digits> x 10^n.
122
+ n = int(parts.exponent) + k
123
+ if k <= n <= 21:
124
+ return sign + digits + "0" * (n - k)
125
+ if 0 < n <= 21:
126
+ return sign + digits[:n] + "." + digits[n:]
127
+ if -6 < n <= 0:
128
+ return sign + "0." + "0" * -n + digits
129
+ mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
130
+ exp = n - 1
131
+ return f"{sign}{mantissa}e{'+' if exp >= 0 else '-'}{abs(exp)}"
64
132
 
65
133
 
66
134
  def _fmt_fixed(abs_val: float, decimals: int) -> str:
@@ -69,35 +137,33 @@ def _fmt_fixed(abs_val: float, decimals: int) -> str:
69
137
  return f"{rounded:.{decimals}f}"
70
138
 
71
139
 
72
- def _format_group(digits: str, separator: str) -> str:
73
- """Apply thousands grouping (groups of 3) to a digit string."""
74
- n = len(digits)
75
- first = n % 3 or 3
76
- parts = [digits[:first]]
77
- i = first
78
- while i < n:
79
- parts.append(digits[i : i + 3])
80
- i += 3
81
- return separator.join(parts)
140
+ def _to_radix(abs_val: float, base: int, upper: bool) -> str:
141
+ """Replicate JS ``Math.round(x).toString(base)`` for base 2, 8 or 16."""
142
+ digits = _RADIX_CONVERTERS[base](int(_round_half_up(abs_val, 0)))[2:]
143
+ return digits.upper() if upper else digits
82
144
 
83
145
 
84
- def _apply_grouping(body: str, separator: str, decimal_pt: str) -> str:
85
- """Apply thousands grouping to the integer part of a formatted number.
146
+ def _group(value: str, width: float) -> str:
147
+ """Replicate d3's formatGroup for grouping [3] with a ',' separator.
86
148
 
87
- Handles plain integers, decimals, and exponential notation.
149
+ ``width`` caps the grouped length; d3 passes infinity except when zero-fill
150
+ padding is grouped along with the digits, where it bounds the total.
88
151
  """
89
- if "e" in body:
90
- mantissa, exp_part = body.split("e", 1)
91
- if decimal_pt in mantissa:
92
- int_part, frac = mantissa.split(decimal_pt, 1)
93
- return (
94
- _format_group(int_part, separator) + decimal_pt + frac + "e" + exp_part
95
- )
96
- return _format_group(mantissa, separator) + "e" + exp_part
97
- if decimal_pt in body:
98
- int_part, frac = body.split(decimal_pt, 1)
99
- return _format_group(int_part, separator) + decimal_pt + frac
100
- return _format_group(body, separator)
152
+ i = len(value)
153
+ parts: list[str] = []
154
+ g = 3
155
+ length = 0
156
+ while i > 0 and g > 0:
157
+ if length + g + 1 > width:
158
+ g = max(1, int(width - length))
159
+ i -= g
160
+ parts.append(value[max(0, i) : i + g])
161
+ length += g + 1
162
+ if length > width:
163
+ break
164
+ g = 3
165
+ parts.reverse()
166
+ return ",".join(parts)
101
167
 
102
168
 
103
169
  def _apply_trim(body: str) -> str:
@@ -233,7 +299,7 @@ def _js_to_precision(abs_val: float, precision: int) -> str:
233
299
 
234
300
 
235
301
  def _format_rounded(abs_val: float, precision: int) -> str:
236
- """Replicate d3's formatRounded(x, p) for 'r' type.
302
+ """Replicate d3's formatRounded(x, p), used by the 'r' and 'p' types.
237
303
 
238
304
  Always uses fixed notation (no e), rounds to p significant digits.
239
305
  """
@@ -252,273 +318,205 @@ def _format_rounded(abs_val: float, precision: int) -> str:
252
318
  return coefficient + "0" * (i - n)
253
319
 
254
320
 
255
- def _format_general(abs_val: float, precision: int) -> str:
256
- """Format abs_val using d3's 'g' rule (x.toPrecision(p))."""
257
- return _js_to_precision(abs_val, precision)
321
+ def _format_exponential(abs_val: float, precision: int) -> str:
322
+ """Replicate JS Number.prototype.toExponential(p) for the 'e' type."""
323
+ if abs_val == 0.0:
324
+ mantissa = ("0." + "0" * precision) if precision > 0 else "0"
325
+ return mantissa + "e+0"
326
+ coefficient, exponent = _format_decimal_parts(abs_val, precision + 1) # type: ignore[misc]
327
+ # Pad to precision+1 digits in case trailing zeros were stripped
328
+ coefficient = coefficient.ljust(precision + 1, "0")
329
+ if precision > 0:
330
+ mantissa = coefficient[0] + "." + coefficient[1 : precision + 1]
331
+ else:
332
+ mantissa = coefficient[0]
333
+ return f"{mantissa}e{'+' if exponent >= 0 else '-'}{abs(exponent)}"
258
334
 
259
335
 
260
- def _body_is_zero(body: str) -> bool:
261
- """True if `body` contains no nonzero digit (e.g. '0', '0.0', '0.00e+0')."""
262
- return not any(c.isdigit() and c != "0" for c in body)
336
+ def _format_decimal(abs_val: float) -> str:
337
+ """Replicate d3's formatDecimal, used by the 'd' type."""
338
+ if abs_val >= 1e21:
339
+ # JS formatDecimal uses x.toLocaleString("en") for |x| >= 1e21.
340
+ # str(x) gives the shortest round-trip repr; Decimal(str()) expands
341
+ # exponential form to the "clean" integer string matching JS.
342
+ s = str(abs_val)
343
+ return str(int(Decimal(s))) if ("e" in s or "E" in s) else str(int(abs_val))
344
+ return str(int(_round_half_up(abs_val, 0)))
263
345
 
264
346
 
265
- def _format_number(spec: FormatSpec, v: float) -> tuple[str, str, str]:
266
- """Format a finite, non-NaN float (v may be negative).
347
+ def _format_type(spec: FormatSpec, abs_val: float, precision: int) -> tuple[str, str]:
348
+ """Replicate d3's ``formatTypes[type](abs_val, precision)``.
267
349
 
268
- Returns (sign_char, body, suffix) where:
269
- - sign_char: "−" | "+" | " " | ""
270
- - body: formatted digit string
271
- - suffix: SI prefix char, "%" or ""
350
+ Returns (body, si_prefix); si_prefix mirrors d3's prefixExponent side
351
+ channel and is empty for every type but 's'.
272
352
  """
273
- is_neg = math.copysign(1.0, v) < 0
274
- abs_val = abs(v)
275
-
276
- # d3: negative values get minus; but -0 loses its minus unless sign is "+".
277
- # (equivalent to d3's "if valueNegative && +formattedValue === 0 && sign !== '+'" logic)
278
- # We handle this after formatting for non-zero; for zero it's always suppressed unless sign="+".
279
- is_neg_zero = is_neg and abs_val == 0.0
280
- if is_neg_zero and spec.sign != "+":
281
- is_neg = False
282
-
283
- if is_neg:
284
- sign_char = _MINUS
285
- elif spec.sign == "+":
286
- sign_char = "+"
287
- elif spec.sign == " ":
288
- sign_char = " "
289
- else:
290
- sign_char = ""
353
+ if math.isinf(abs_val):
354
+ # d3 has no infinity branch — each formatter stringifies it. Only 'd'
355
+ # (via toLocaleString) and 'X' (via toUpperCase) diverge from "Infinity".
356
+ if spec.type == "d":
357
+ return _INF_SYMBOL, ""
358
+ return ("INFINITY" if spec.type == "X" else "Infinity"), ""
291
359
 
292
360
  t = spec.type
293
- precision = spec.precision
294
-
295
361
  if t == "f":
296
- prec = precision if precision is not None else 6
297
- # JS Number.prototype.toFixed() falls back to toString() for |x| >= 10^21.
298
- body = str(abs_val) if abs_val >= 1e21 else _fmt_fixed(abs_val, prec)
299
- suffix = ""
300
-
301
- elif t == "%":
302
- prec = precision if precision is not None else 6
303
- scaled = abs_val * 100.0
304
- # toFixed fallback: |x * 100| >= 10^21 → toString(x * 100)
305
- body = str(scaled) if scaled >= 1e21 else _fmt_fixed(scaled, prec)
306
- suffix = "%"
307
-
308
- elif t == "e":
309
- prec = precision if precision is not None else 6
310
- if abs_val == 0.0:
311
- body = ("0." + "0" * prec if prec > 0 else "0") + "e+0"
312
- else:
313
- d = _format_decimal_parts(abs_val, prec + 1)
314
- coefficient, exponent = d # type: ignore[misc]
315
- # Pad to prec+1 digits in case trailing zeros were stripped
316
- coefficient = coefficient.ljust(prec + 1, "0")
317
- if prec > 0:
318
- mantissa = coefficient[0] + "." + coefficient[1 : prec + 1]
319
- else:
320
- mantissa = coefficient[0]
321
- exp_sign = "+" if exponent >= 0 else "-"
322
- body = f"{mantissa}e{exp_sign}{abs(exponent)}"
323
- suffix = ""
324
-
325
- elif t in ("g", ""):
326
- # d3's formatDefault (empty type) uses toPrecision(12) as the default precision.
327
- # 'g' type uses the spec precision (default 6).
328
- prec = precision if precision is not None else (12 if t == "" else 6)
329
- body = _format_general(abs_val, prec)
330
- suffix = ""
331
-
332
- elif t == "r":
333
- prec = precision if precision is not None else 6
334
- body = _format_rounded(abs_val, prec)
335
- suffix = ""
336
-
337
- elif t == "s":
338
- prec = precision if precision is not None else 6
339
- body, si_suffix = _format_si(abs_val, prec)
340
- suffix = si_suffix
341
-
342
- elif t == "d":
343
362
  if abs_val >= 1e21:
344
- # JS formatDecimal uses x.toLocaleString("en") for |x| >= 1e21.
345
- # str(x) gives the shortest round-trip repr; Decimal(str()) expands
346
- # exponential form to the "clean" integer string matching JS.
347
- s = str(abs_val)
348
- body = str(int(Decimal(s))) if ("e" in s or "E" in s) else str(int(abs_val))
349
- else:
350
- body = str(int(_round_half_up(abs_val, 0)))
351
- suffix = ""
352
-
353
- else: # t == "n"
354
- # n type: alias for ",g" — locale-aware g (grouping handled later).
355
- prec = precision if precision is not None else 6
356
- body = _js_to_precision(abs_val, prec)
357
- suffix = ""
358
-
359
- # d3: "if (valueNegative && +formattedValue === 0 && sign !== '+') valueNegative = false"
360
- # Drop the minus sign when the formatted body rounds to zero.
361
- if is_neg and spec.sign != "+" and _body_is_zero(body):
362
- is_neg = False
363
- sign_char = " " if spec.sign == " " else ""
364
-
365
- return sign_char, body, suffix
366
-
367
-
368
- def _assemble(
369
- spec: FormatSpec,
370
- sign_char: str,
371
- body: str,
372
- suffix: str,
373
- ) -> str:
374
- """Assemble sign + symbol + body + suffix, apply width/fill/align."""
375
- currency = "$" if spec.symbol == "$" else ""
376
-
377
- # Parens sign mode.
378
- if spec.sign == "(" and sign_char == _MINUS:
379
- paren_open = "("
380
- paren_close = ")"
381
- sign_char = ""
382
- else:
383
- paren_open = ""
384
- paren_close = ""
363
+ return (
364
+ _js_number_to_string(abs_val),
365
+ "",
366
+ ) # JS toFixed falls back to toString
367
+ return _fmt_fixed(abs_val, precision), ""
368
+ if t == "%":
369
+ scaled = abs_val * 100.0
370
+ if scaled >= 1e21:
371
+ return _js_number_to_string(scaled), ""
372
+ return _fmt_fixed(scaled, precision), ""
373
+ if t == "p":
374
+ return _format_rounded(abs_val * 100.0, precision), ""
375
+ if t == "e":
376
+ return _format_exponential(abs_val, precision), ""
377
+ if t == "g":
378
+ return _js_to_precision(abs_val, precision), ""
379
+ if t == "r":
380
+ return _format_rounded(abs_val, precision), ""
381
+ if t == "s":
382
+ return _format_si(abs_val, precision)
383
+ if t == "d":
384
+ return _format_decimal(abs_val), ""
385
+ base, upper = _RADIX_TYPES[t]
386
+ return _to_radix(abs_val, base, upper), ""
385
387
 
386
- # Assemble the non-padded string.
387
- if paren_open:
388
- full = paren_open + currency + body + suffix + paren_close
389
- else:
390
- full = sign_char + currency + body + suffix
391
388
 
392
- if spec.width is None:
393
- return full
389
+ def _body_is_zero(body: str) -> bool:
390
+ """True when JS's ``+body`` coercion would be 0 (d3's rounds-to-zero test).
394
391
 
395
- w = spec.width
396
- current = len(full)
397
- if current >= w:
398
- return full
399
- needed = w - current
392
+ A body JS coerces to NaN — a radix body like "ff", or "Infinity", or "∞" —
393
+ is not zero, so an unparseable body keeps its sign.
394
+ """
395
+ try:
396
+ return float(body) == 0.0
397
+ except ValueError:
398
+ return False
400
399
 
401
- if spec.zero:
402
- # Zero-pad: sign+currency, then zeros, then body+suffix.
403
- # parens+zero is rejected at parse time, so paren_open is never set here.
404
- prefix = sign_char + currency
405
- rest = body + suffix
406
- return prefix + "0" * needed + rest
407
400
 
408
- if spec.align == "=":
409
- prefix_str = (paren_open + currency) if paren_open else (sign_char + currency)
410
- rest = body + suffix + paren_close
411
- return prefix_str + spec.fill * needed + rest
401
+ def _prefix(spec: FormatSpec) -> str:
402
+ """The leading unit: a currency symbol, or the alternate-form radix marker."""
403
+ if spec.symbol == "$":
404
+ return "$"
405
+ if spec.symbol == "#" and spec.type in _ALTERNATE_FORM_TYPES:
406
+ return "0" + spec.type.lower()
407
+ return ""
412
408
 
413
- if spec.align == "<":
414
- return full + spec.fill * needed
415
- elif spec.align == ">":
416
- return spec.fill * needed + full
417
- else: # "^"
418
- left = needed // 2
419
- right = needed - left
420
- return spec.fill * left + full + spec.fill * right
421
-
422
-
423
- def _assemble_special(
424
- spec: FormatSpec,
425
- text: str,
426
- is_neg: bool,
427
- ) -> str:
428
- """Assemble NaN or Infinity string with sign/currency/width.
429
-
430
- NaN: sign chars from spec apply (+ shows +, space shows space).
431
- Infinity: actual sign from is_neg.
432
- """
433
- if is_neg:
434
- sign_char = _MINUS
435
- elif spec.sign == "+":
436
- sign_char = "+"
437
- elif spec.sign == " ":
438
- sign_char = " "
439
- else:
440
- sign_char = ""
441
409
 
442
- currency = "$" if spec.symbol == "$" else ""
410
+ def _value_suffix(spec: FormatSpec) -> str:
411
+ """The trailing unit. A currency symbol displaces the percent sign."""
412
+ if spec.symbol == "$":
413
+ return ""
414
+ return "%" if spec.type in ("%", "p") else ""
443
415
 
444
- # Parens mode for negative infinity.
445
- if spec.sign == "(" and is_neg:
446
- full = "(" + currency + text + ")"
447
- sign_char = ""
448
- else:
449
- full = sign_char + currency + text
450
416
 
451
- if spec.width is None:
452
- return full
417
+ def _pad(spec: FormatSpec, value_prefix: str, value: str, value_suffix: str) -> str:
418
+ """Group the digits, then pad to width and align — d3's closing steps.
453
419
 
454
- w = spec.width
455
- current = len(full)
456
- if current >= w:
457
- return full
458
- needed = w - current
420
+ Grouping appears twice for a reason: without zero-fill d3 groups the digits
421
+ alone, with zero-fill it groups the padding *and* the digits together so the
422
+ separators land inside the padded field.
423
+ """
424
+ if spec.comma and not spec.zero:
425
+ value = _group(value, math.inf)
426
+
427
+ length = len(value_prefix) + len(value) + len(value_suffix)
428
+ if spec.width is not None and spec.width > length:
429
+ padding = spec.fill * (spec.width - length)
430
+ else:
431
+ padding = ""
459
432
 
460
- # For special values, zero-pad uses "0" as fill char.
461
- if spec.zero:
462
- prefix = sign_char + currency
463
- return prefix + "0" * needed + text
433
+ if spec.comma and spec.zero:
434
+ width = (spec.width - len(value_suffix)) if padding and spec.width else math.inf
435
+ value = _group(padding + value, width)
436
+ padding = ""
464
437
 
465
438
  if spec.align == "<":
466
- return full + spec.fill * needed
467
- elif spec.align in (">", "="):
468
- return spec.fill * needed + full
469
- else: # "^"
470
- left = needed // 2
471
- right = needed - left
472
- return spec.fill * left + full + spec.fill * right
439
+ return value_prefix + value + value_suffix + padding
440
+ if spec.align == "=":
441
+ return value_prefix + padding + value + value_suffix
442
+ if spec.align == "^":
443
+ half = len(padding) // 2
444
+ return padding[:half] + value_prefix + value + value_suffix + padding[half:]
445
+ return padding + value_prefix + value + value_suffix
473
446
 
474
447
 
475
- def _apply_spec(spec: FormatSpec, v: float | int | Decimal) -> str:
476
- """Apply a parsed FormatSpec to a numeric value."""
477
- v = float(v) # coerce int, Decimal, etc.; preserves -0 sign via IEEE 754
448
+ def _resolve_type(spec: FormatSpec) -> None:
449
+ """Apply d3's type aliases in place, before any formatting decision.
478
450
 
479
- # d and ,d type: use ∞ symbol for infinite values.
480
- use_inf_symbol = spec.type in ("d",)
451
+ ``n`` is ``,g``. The empty type and any letter that selects no formatter are
452
+ alike ``.12~g`` — d3 does not reject an unrecognised type letter, it falls
453
+ back to a general-purpose format, so neither do we.
454
+ """
455
+ if spec.type == "n":
456
+ spec.comma = True
457
+ spec.type = "g"
458
+ elif spec.type not in _KNOWN_TYPES:
459
+ if spec.precision is None:
460
+ spec.precision = 12
461
+ spec.trim = True
462
+ spec.type = "g"
463
+
464
+
465
+ def _resolve_precision(spec: FormatSpec) -> int:
466
+ """d3's precision default and per-type clamp. Call after ``_resolve_type``."""
467
+ if spec.precision is None:
468
+ return 6
469
+ if spec.type in _SIGNIFICANT_PRECISION_TYPES:
470
+ return max(1, min(21, spec.precision))
471
+ return max(0, min(20, spec.precision))
472
+
473
+
474
+ def _apply_spec(spec: FormatSpec, precision: int, v: float | int | Decimal) -> str:
475
+ """Apply a resolved FormatSpec to a numeric value, mirroring d3's format()."""
476
+ value_prefix = _prefix(spec)
477
+ value_suffix = _value_suffix(spec)
478
+ x = float(v) # coerce int, Decimal, etc.; preserves -0 sign via IEEE 754
479
+
480
+ if spec.type == "c":
481
+ # Character data: the value is stringified verbatim and carries its own
482
+ # ASCII hyphen, so the sign, trim and split steps are all skipped.
483
+ return _pad(spec, value_prefix, "", _js_number_to_string(x) + value_suffix)
484
+
485
+ if math.isnan(x):
486
+ # d3 short-circuits NaN ahead of the type function, which is why 'X'
487
+ # does not uppercase it the way it uppercases "INFINITY".
488
+ is_negative = False
489
+ body, si_prefix = "NaN", ""
490
+ else:
491
+ is_negative = x < 0 or math.copysign(1.0, x) < 0
492
+ body, si_prefix = _format_type(spec, abs(x), precision)
481
493
 
482
- # The % type appends "%" even to NaN/Infinity.
483
- type_suffix = "%" if spec.type == "%" else ""
494
+ if spec.trim:
495
+ body = _apply_trim(body)
484
496
 
485
- # NaN.
486
- if math.isnan(v):
487
- return _assemble_special(spec, "NaN" + type_suffix, is_neg=False)
497
+ # A negative value that formats to zero loses its sign unless the spec asks
498
+ # for an explicit one.
499
+ if is_negative and spec.sign != "+" and _body_is_zero(body):
500
+ is_negative = False
488
501
 
489
- # Infinity.
490
- if math.isinf(v):
491
- is_neg = v < 0
492
- text = (_INF_SYMBOL if use_inf_symbol else "Infinity") + type_suffix
493
- return _assemble_special(spec, text, is_neg=is_neg)
502
+ if is_negative:
503
+ value_prefix = ("(" if spec.sign == "(" else _MINUS) + value_prefix
504
+ elif spec.sign not in ("-", "("):
505
+ value_prefix = spec.sign + value_prefix
494
506
 
495
- # Normal number.
496
- sign_char, body, suffix = _format_number(spec, v)
507
+ value_suffix = si_prefix + value_suffix
508
+ if is_negative and spec.sign == "(":
509
+ value_suffix += ")"
497
510
 
498
- # Trim trailing zeros: explicit ~ flag, or unconditionally for empty type.
499
- # d3's formatDefault (empty type) always strips trailing zeros regardless of ~.
500
- if spec.trim or spec.type == "":
501
- body = _apply_trim(body)
511
+ if spec.type in _SPLIT_SUFFIX_TYPES:
512
+ # Hold the fraction/exponent out of grouping and zero-padding.
513
+ for i, char in enumerate(body):
514
+ if not "0" <= char <= "9":
515
+ value_suffix = body[i:] + value_suffix
516
+ body = body[:i]
517
+ break
502
518
 
503
- # Comma grouping: for 'n' type always group; for all others respect spec.comma.
504
- # For 's' type, comma is applied to the mantissa (matters for very large SI bodies).
505
- use_comma = spec.comma or spec.type == "n"
506
- if use_comma:
507
- if spec.zero and spec.width is not None:
508
- # d3 applies comma grouping to the zero-padded digits.
509
- # Pre-pad body to the largest digit count that fits the target width.
510
- sign_len = len(sign_char) + (1 if spec.symbol == "$" else 0)
511
- available = spec.width - sign_len - len(suffix)
512
- d = len(body)
513
- while True:
514
- nd = d + 1
515
- if nd + (nd - 1) // 3 > available:
516
- break
517
- d = nd
518
- body = body.zfill(d)
519
- body = _apply_grouping(body, ",", ".")
520
-
521
- return _assemble(spec, sign_char, body, suffix)
519
+ return _pad(spec, value_prefix, body, value_suffix)
522
520
 
523
521
 
524
522
  @overload
@@ -548,12 +546,14 @@ def format( # noqa: A001
548
546
  A ``Callable[[float], str]`` if value is None, else the formatted string.
549
547
 
550
548
  Raises:
551
- D3FormatError: If spec_str is invalid or uses unsupported features.
549
+ D3FormatError: If spec_str does not match the d3-format grammar.
552
550
  """
553
551
  spec = parse(spec_str)
552
+ _resolve_type(spec)
553
+ precision = _resolve_precision(spec)
554
554
 
555
555
  def _fmt(v: float | int | Decimal) -> str:
556
- return _apply_spec(spec, v)
556
+ return _apply_spec(spec, precision, v)
557
557
 
558
558
  if value is None:
559
559
  return _fmt