diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index eb1f74e..688787e 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.5.11",
+ "version": "2.5.13",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 5f5c608..ce3684f 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.5.11",
+ "version": "2.5.13",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/scripts/test-verify-treemap.py b/scripts/test-verify-treemap.py
index 32b8a7d..c523045 100644
--- a/scripts/test-verify-treemap.py
+++ b/scripts/test-verify-treemap.py
@@ -12,6 +12,7 @@ Exit: 0 all pass, 1 a case failed.
from __future__ import annotations
+import runpy
import subprocess
import sys
import tempfile
@@ -20,6 +21,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHECKER = ROOT / "scripts/verify-treemap.py"
GOOD = ROOT / "skills/diagram-design/assets/example-treemap.html"
+SHIPPED = sorted(GOOD.parent.glob("example-treemap*.html"))
+ESTIMATED_ADVANCE = runpy.run_path(str(CHECKER), run_name="verify_treemap_test")[
+ "estimated_advance"
+]
+KOREAN_LABEL = "남아메리카 인구 구성 비율 요약"
+JAPANESE_LABEL = "南アメリカ人口構成比率の概要"
def run(path: Path) -> tuple[int, str]:
@@ -43,15 +50,35 @@ def main() -> int:
source = GOOD.read_text(encoding="utf-8")
failures: list[str] = []
+ # Pin the estimator independently from CLI diagnostics. The Korean fixture
+ # has 13 wide glyphs and four narrow spaces; the Japanese fixture has 14
+ # wide glyphs. This catches a width-contract regression without coupling
+ # the overflow fixture to its human-readable pixel message.
+ for label, expected, language in (
+ (KOREAN_LABEL, 15.4, "Korean"),
+ (JAPANESE_LABEL, 14.0, "Japanese"),
+ ):
+ actual = ESTIMATED_ADVANCE(label, False)
+ if abs(actual - expected) > 1e-9:
+ failures.append(
+ f"{language} estimator contract moved: expected {expected:g}em, got {actual:g}em"
+ )
+ else:
+ print(f"OK: {language} wide-glyph estimator contract is {expected:g}em")
+
with tempfile.TemporaryDirectory() as raw:
directory = Path(raw)
- # 1. The shipped example must pass untouched.
- code, output = run(write(directory, "clean.html", source))
- if code != 0:
- failures.append(f"clean example was rejected: {output.strip()}")
- else:
- print("OK: the shipped treemap passes")
+ # 1. Every shipped Latin example must pass untouched, proving the
+ # Unicode estimator did not move the calibrated Latin baseline.
+ shipped_clean = True
+ for path in SHIPPED:
+ code, output = run(path)
+ if code != 0:
+ shipped_clean = False
+ failures.append(f"clean example {path.name} was rejected: {output.strip()}")
+ if shipped_clean:
+ print(f"OK: all {len(SHIPPED)} shipped Latin treemaps pass")
# 2. Shrink a labelled cell below the share it prints. This is the
# defect that shipped in review — a cell drawn smaller than it claims
@@ -104,7 +131,51 @@ def main() -> int:
else:
print("OK: a label wider than its cell is rejected")
- # 4b. Overflow off the LEFT edge, via an end-anchored label. Checking
+ # 4a. Reproduce #115: most glyphs in this Korean translation are
+ # full-width. The old 0.60em character-count estimate called it
+ # 122.4px wide and accepted it in 132px of available space, while
+ # Chromium measured 146.2px. A conservative 1em wide-glyph advance
+ # must fail closed without changing the Latin estimate.
+ korean = source.replace(
+ 'South America',
+ '{KOREAN_LABEL}',
+ )
+ if korean == source:
+ failures.append("could not build the Korean-overflow fixture (anchor moved)")
+ else:
+ code, output = run(write(directory, "korean-overflow.html", korean))
+ if code == 0:
+ failures.append("full-width Korean label overflowing its cell was accepted")
+ elif "label" not in output or "overflows" not in output:
+ failures.append(f"Korean fixture lacked a label-overflow finding: {output.strip()}")
+ else:
+ print("OK: a Korean label in the old-estimator boundary gap is rejected")
+
+ # 4b. Japanese Han and kana are also Unicode-wide. Cover the documented
+ # CJK contract beyond Hangul with another label that fits under the
+ # old 0.60em estimate but overflows under the conservative contract.
+ japanese = source.replace(
+ 'South America',
+ '{JAPANESE_LABEL}',
+ )
+ if japanese == source:
+ failures.append("could not build the Japanese-overflow fixture (anchor moved)")
+ else:
+ code, output = run(write(directory, "japanese-overflow.html", japanese))
+ if code == 0:
+ failures.append("full-width Japanese label overflowing its cell was accepted")
+ elif "label" not in output or "overflows" not in output:
+ failures.append(f"Japanese fixture lacked a label-overflow finding: {output.strip()}")
+ else:
+ print("OK: a Japanese full-width label is rejected when it overflows")
+
+ # 4c. Overflow off the LEFT edge, via an end-anchored label. Checking
# only the right and bottom edges would pass this silently, and the
# text would read as belonging to the cell next door.
left_over = source.replace(
@@ -122,7 +193,7 @@ def main() -> int:
else:
print("OK: a label overflowing the left edge is rejected")
- # 4c. Overflow off the TOP edge — the label's ascent clears the cell.
+ # 4d. Overflow off the TOP edge — the label's ascent clears the cell.
top_over = source.replace(
' int:
else:
print("OK: a label overflowing the top edge is rejected")
- # 4d. A sliver can be narrower than the fixed 10px information-mark
+ # 4e. A sliver can be narrower than the fixed 10px information-mark
# disc. The marker must never cross into the neighbouring cell.
marker_over = source.replace(
' int:
else:
print("OK: an information mark overflowing its cell is rejected")
+ # 4f. Combining marks do not advance independently. Eighteen decomposed
+ # accented glyphs fit by the unchanged Latin baseline (129.6px in
+ # 132px), but counting every acute accent as a character would
+ # incorrectly double the estimate and reject the label.
+ decomposed = ("e\N{COMBINING ACUTE ACCENT}" * 18)
+ combining = source.replace(">South America", f">{decomposed}")
+ if combining == source:
+ failures.append("could not build the combining-mark fixture (anchor moved)")
+ else:
+ code, output = run(write(directory, "combining-marks.html", combining))
+ if code != 0:
+ failures.append(f"non-advancing combining marks were overcounted: {output.strip()}")
+ else:
+ print("OK: combining marks do not create phantom label overflow")
+
# 5. The shipped example leaves its smallest cell deliberately
# unlabelled. That must not switch the area check off for the cells
# that ARE labelled — the failure mode where a checker reports clean
diff --git a/scripts/verify-treemap.py b/scripts/verify-treemap.py
index 948b098..4085009 100644
--- a/scripts/verify-treemap.py
+++ b/scripts/verify-treemap.py
@@ -22,9 +22,10 @@ later-painted nodes) reads a cell against the number printed inside it.
Text extent is estimated from font metrics rather than measured in a browser,
deliberately: every other gate here is pure Python and runs in CI with no
-browser. The advances below are calibrated against Chromium renderings of the
-shipped Geist / Geist Mono faces and rounded UP, so the estimate reports
-overflow slightly before real overflow, never after.
+browser. The Latin advances below are calibrated against Chromium renderings of
+the shipped Geist / Geist Mono faces and rounded UP. Unicode wide/full-width
+characters use a conservative 1em advance, so both estimates report overflow
+slightly before real overflow, never after.
Usage:
python3 scripts/verify-treemap.py --all
@@ -39,6 +40,7 @@ import argparse
import html
import re
import sys
+import unicodedata
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -66,6 +68,7 @@ VALUE_RE = re.compile(r"(?P\d[\d,]*(?:\.\d+)?)\s*(?P[BMK])\b")
MONO_ADVANCE = 0.62
SANS_ADVANCE = 0.60
+WIDE_ADVANCE = 1.00
ASCENT = 0.74
UNITS = {None: 1.0, "K": 1e3, "M": 1e6, "B": 1e9}
@@ -116,6 +119,23 @@ def plain(body: str) -> str:
return html.unescape(TAG_RE.sub("", body)).strip()
+def estimated_advance(text: str, mono: bool) -> float:
+ """Conservative text advance in em, preserving the calibrated Latin baseline."""
+ narrow_advance = MONO_ADVANCE if mono else SANS_ADVANCE
+ advance = 0.0
+ for char in text:
+ # Nonspacing and enclosing marks modify the preceding glyph; counting
+ # them as another glyph rejects labels that do fit. Spacing combining
+ # marks (Mc) retain the narrow estimate because they can advance.
+ if unicodedata.category(char) in {"Mn", "Me"}:
+ continue
+ if unicodedata.east_asian_width(char) in {"W", "F"}:
+ advance += WIDE_ADVANCE
+ else:
+ advance += narrow_advance
+ return advance
+
+
def parse_cells(source: str) -> list[Box]:
"""Deduped cell rects. Each cell is painted twice: paper mask, then body."""
seen: list[Box] = []
@@ -149,11 +169,11 @@ def label_box(attrs: dict[str, str], body: str) -> Box | None:
size = float(attrs.get("font-size", "12"))
except (KeyError, ValueError):
return None
- chars = len(plain(body))
- if not chars:
+ text = plain(body)
+ if not text:
return None
mono = "mono" in attrs.get("font-family", "").lower()
- width = chars * size * (MONO_ADVANCE if mono else SANS_ADVANCE)
+ width = size * estimated_advance(text, mono)
anchor = attrs.get("text-anchor", "start")
transform = attrs.get("transform", "")
diff --git a/skills/diagram-design/references/output-spec.md b/skills/diagram-design/references/output-spec.md
index 42ccf2b..9112191 100644
--- a/skills/diagram-design/references/output-spec.md
+++ b/skills/diagram-design/references/output-spec.md
@@ -145,7 +145,7 @@ Geist has no CJK coverage. When labels contain Japanese, Chinese, or Korean text
인증 서비스
```
-The Hiragino/Yu Gothic stack carries no Hangul glyphs, so Korean labels need the Korean stack — don't reuse the Japanese one. For mono sublabels use `'Geist Mono', 'Noto Sans Mono CJK JP', monospace` (Japanese) or `'Geist Mono', 'Noto Sans Mono CJK KR', monospace` (Korean). CJK glyphs render ~10% wider than Latin at the same size — budget box width accordingly, and prefer 12px names over 8px sublabels for CJK, which goes muddy below 10px.
+The Hiragino/Yu Gothic stack carries no Hangul glyphs, so Korean labels need the Korean stack — don't reuse the Japanese one. For mono sublabels use `'Geist Mono', 'Noto Sans Mono CJK JP', monospace` (Japanese) or `'Geist Mono', 'Noto Sans Mono CJK KR', monospace` (Korean). Budget **1em per full-width CJK glyph**, not a small percentage over the average Latin glyph; `verify-treemap.py` uses that conservative contract for Unicode wide/full-width characters and treats combining marks as non-advancing. Prefer 12px names over 8px sublabels for CJK, which goes muddy below 10px. Actual width still varies by fallback font, so run the relevant geometry verifier after translating labels.
---