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. ---