kami · 紙 紙 · かみ means paper in Japanese. Good content deserves good paper. Use Kami’s templates and layout rules to create documents and landing pages with serif fonts, warm backgrounds, and ink-blue accents. Part of - Kaku writes code, Waza drills habits, Kami delivers documents . Commands and paths below are relative to this skill's directory. Keep the document in the user's working directory and call the scripts from there by their full path ( ); relative file arguments resolve from your working directory first. Update check (non-blocking). At the start of a task, run . It keeps a local da…

, re.M)\n\n def repl(m: re.Match) -> str:\n quoted = '\"' + value.replace('\"', \"'\") + '\"'\n pad = max(1, 2 + len(m.group(2)) - len(quoted)) # keep the comment column\n return f\"{m.group(1)}{quoted}{' ' * pad}{m.group(3) or ''}\".rstrip()\n\n return pattern.sub(repl, text, count=1)\n\n\ndef build_profile(result: dict, url: str) -> str:\n template = EXAMPLE_PROFILE.read_text(encoding=\"utf-8\")\n head, _, _ = template.partition(\"Examples to replace with your own:\")\n head = head.replace(\n \"# Then edit the values below.\",\n \"# Then edit the values below.\\n#\\n\"\n f\"# Draft generated by scripts/brand_extract.py from {url}.\\n\"\n \"# Review every value before copying it into place.\",\n 1,\n )\n notes: list[str] = []\n if result[\"name\"]:\n head = _set_field(head, \"company\", result[\"name\"])\n if result[\"host\"]:\n head = _set_field(head, \"website\", result[\"host\"])\n\n if result[\"colors\"]:\n source = result[\"colors\"][0][0]\n accent, steps = ensure_contrast(source)\n head = _set_field(head, \"brand_color\", accent.upper())\n ratio = contrast_ratio(accent, PARCHMENT)\n if steps:\n notes.append(\n f\"Brand color: the site accent {source.upper()} reads at \"\n f\"{contrast_ratio(source, PARCHMENT):.2f}:1 on Kami parchment {PARCHMENT}, below \"\n f\"{MIN_CONTRAST}:1, so it was darkened {steps} HSL lightness step(s) to \"\n f\"{accent.upper()} ({ratio:.2f}:1).\"\n )\n else:\n notes.append(\n f\"Brand color: {accent.upper()} taken from the site as is, \"\n f\"{ratio:.2f}:1 on Kami parchment {PARCHMENT}.\"\n )\n if result[\"colors\"][0][1] \u003c 1:\n notes.append(\n \"Low confidence: no saturated color was found in the stylesheets read, \"\n \"so this pick comes from inline styles in the page body.\"\n )\n others = \", \".join(c.upper() for c, _ in result[\"colors\"][1:5])\n if others:\n notes.append(\n f\"Kami uses one accent only. Other site colors, not applied: {others}.\"\n )\n else:\n notes.append(\"Brand color: no saturated color found on the page; Kami ink blue stays.\")\n\n if result[\"fonts\"]:\n names = \", \".join(f for f, _ in result[\"fonts\"][:4])\n notes.append(\n f\"Site fonts: {names}. A habit note only; Kami keeps its own serif stacks.\"\n )\n if result[\"logos\"]:\n notes.append(\n \"Logo candidates (download one and set `logo:` to its local path): \"\n + \"; \".join(result[\"logos\"][:4]) + \".\"\n )\n\n body = \"Draft notes from the site scan:\\n\\n\" + \"\\n\".join(f\"- {n}\" for n in notes) + \"\\n\"\n return head + body\n\n\ndef main(argv: list[str] | None = None) -> int:\n parser = argparse.ArgumentParser(description=\"Draft a Kami brand profile from a website.\")\n parser.add_argument(\"url\", help=\"page to scan, e.g. https://example.com\")\n parser.add_argument(\"-o\", \"--output\", type=Path, help=\"write the draft here instead of stdout\")\n args = parser.parse_args(argv)\n\n url = args.url if \"://\" in args.url else f\"https://{args.url}\"\n try:\n result, read, skipped = fetch_site(url)\n except BrandFetchError as exc:\n print(f\"ERROR: {exc}\", file=sys.stderr)\n return 2\n profile = build_profile(result, url)\n if args.output:\n args.output.write_text(profile, encoding=\"utf-8\")\n print(\n f\"OK: wrote draft brand profile to {args.output} \"\n f\"({read} stylesheet(s) read, {skipped} skipped)\"\n )\n else:\n sys.stdout.write(profile)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":17887,"content_sha256":"e21fc73c613d81f603716c1188f194a25d91c882b535cc246751ca512f9a17cb"},{"filename":"scripts/build.py","content":"#!/usr/bin/env python3\n\"\"\"kami build & check\n\nThin CLI shell. Implementation lives in:\n - render.py (render_pdf, build_slides, PDF metadata)\n - lint.py (scan_file, check_all, check_cross_template_consistency)\n - tokens.py (sync_check)\n - site_facts.py (check_site_facts)\n - verify.py (verify_all, show_fonts, font checks)\n - checks.py (check_placeholders, check_markdown_residue, check_orphans, check_density, check_resume_balance, check_rhythm)\n - content.py (check_content)\n - html_visibility.py (shared HTML/CSS evidence for content and residue checks)\n - visual.py (check_visual)\n - deliver.py (deliver: every document gate in order, one verdict)\n - chart.py (render_chart, check_charts: data spec to SVG and drift check)\n\nUsage:\n python3 scripts/build.py --deliver filled.html [content.json] # finish a document: all gates, one verdict\n python3 scripts/build.py # build all examples (HTML + diagrams + PPTX)\n python3 scripts/build.py resume # build one template, print pages + fonts\n python3 scripts/build.py landing-page # check one browser-only static template\n python3 scripts/build.py --check # lint + token/theme + doc-snippet + public-site fact checks\n python3 scripts/build.py --check -v # verbose (show each scanned file)\n python3 scripts/build.py --sync # check CSS token drift across templates\n python3 scripts/build.py --verify # build all + page count + font checks\n python3 scripts/build.py --verify resume-en # single target full verification\n python3 scripts/build.py --check-placeholders path/to/doc.html\n python3 scripts/build.py --check-markdown path/to/doc.pdf\n python3 scripts/build.py --check-orphans # scan example PDFs for orphan text\n python3 scripts/build.py --check-orphans path/to/doc.pdf\n python3 scripts/build.py --check-density # warn on pages with >25% trailing whitespace\n python3 scripts/build.py --check-density path/to/doc.pdf\n python3 scripts/build.py --check-resume-balance path/to/resume.pdf\n python3 scripts/build.py --check-rhythm # warn on monotonous slide sequences\n python3 scripts/build.py --check-rhythm slides slides-en\n python3 scripts/build.py --check-content content.json # content IR schema validation\n python3 scripts/build.py --check-content content.json filled.html # + coverage into the document\n python3 scripts/build.py --check-visual path/to/doc.pdf # page PNGs + perceptual checklist\n python3 scripts/build.py --check-fonts path/to/doc.pdf # which family actually drew the CJK text\n python3 scripts/build.py --check-style path/to/filled.html # template rules against a produced document\n python3 scripts/build.py --check-docs # lint the CSS snippets the reference docs teach from\n python3 scripts/build.py --doctor # installed render/check/font capabilities\n python3 scripts/build.py --chart spec.json -o chart.svg # data chart from a JSON spec (bar/line/hbar/donut/candlestick/waterfall)\n python3 scripts/build.py --check-charts path/to/doc.html # every generated chart still matches its embedded data\n\"\"\"\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\nfrom chart import check_charts\nfrom chart import main as chart_main\nfrom checks import (\n check_density,\n check_markdown_residue,\n check_orphans,\n check_placeholders,\n check_resume_balance,\n check_rhythm,\n)\nfrom content import check_content\nfrom deliver import deliver\nfrom lint import (\n check_all,\n check_cross_template_consistency,\n check_docs,\n check_off_palette,\n check_style,\n scan_file,\n)\nfrom math_render import MathRenderError\nfrom optional_deps import MissingDepError, run_doctor\nfrom render import build_slides, render_pdf\nfrom shared import (\n DIAGRAMS,\n EXAMPLES,\n TEMPLATES,\n build_targets,\n diagram_targets,\n pptx_targets,\n screen_targets,\n)\nfrom site_facts import check_site_facts\nfrom tokens import sync_check\nfrom verify import check_fonts, show_fonts, verify_all\nfrom visual import check_visual\n\n# name -> (source, max_pages). max_pages=0 means no hard check.\n# All four dicts derive from the shared registries (single source of truth).\nHTML_TARGETS: dict[str, tuple[str, int]] = build_targets()\nSCREEN_TARGETS: dict[str, str] = screen_targets()\nPPTX_TARGETS: dict[str, str] = pptx_targets()\nDIAGRAM_TARGETS: dict[str, str] = diagram_targets()\n\n\n# ------------------------- build -------------------------\n\ndef build_html(name: str, source: str, max_pages: int,\n src_dir: Path = TEMPLATES) -> bool:\n src = src_dir / source\n if not src.exists():\n print(f\"ERROR: {name}: source not found ({src})\")\n return False\n\n try:\n n = render_pdf(src, EXAMPLES / f\"{name}.pdf\")\n except (MissingDepError, MathRenderError) as exc:\n print(f\"ERROR: {exc}\")\n return False\n except Exception as exc: # WeasyPrint / pypdf / OS errors: report, keep the sweep going\n print(f\"ERROR: {name}: render failed: {exc}\")\n return False\n\n if max_pages and n > max_pages:\n print(f\"ERROR: {name}: {n} pages (limit {max_pages})\")\n return False\n print(f\"OK: {name}: {n} pages\")\n return True\n\n\ndef build_screen_template(name: str, source: str) -> bool:\n src = TEMPLATES / source\n if not src.exists():\n print(f\"ERROR: {name}: source not found ({src})\")\n return False\n\n findings = scan_file(src)\n if findings:\n print(f\"ERROR: {name}: {len(findings)} template violation(s)\")\n return False\n\n print(f\"OK: {name}: static HTML template\")\n return True\n\n\ndef build_all() -> int:\n failures = 0\n for name, (source, max_pages) in HTML_TARGETS.items():\n if not build_html(name, source, max_pages):\n failures += 1\n for name, source in SCREEN_TARGETS.items():\n if not build_screen_template(name, source):\n failures += 1\n for name, source in DIAGRAM_TARGETS.items():\n if not build_html(name, source, 0, src_dir=DIAGRAMS):\n failures += 1\n for name in PPTX_TARGETS:\n if not build_slides(name):\n failures += 1\n return failures\n\n\ndef build_single(name: str) -> int:\n if name in HTML_TARGETS:\n source, max_pages = HTML_TARGETS[name]\n ok = build_html(name, source, max_pages)\n if ok:\n show_fonts(EXAMPLES / f\"{name}.pdf\")\n return 0 if ok else 1\n if name in SCREEN_TARGETS:\n ok = build_screen_template(name, SCREEN_TARGETS[name])\n return 0 if ok else 1\n if name in DIAGRAM_TARGETS:\n source = DIAGRAM_TARGETS[name]\n ok = build_html(name, source, 0, src_dir=DIAGRAMS)\n return 0 if ok else 1\n if name in PPTX_TARGETS:\n return 0 if build_slides(name) else 1\n known = list(HTML_TARGETS) + list(SCREEN_TARGETS) + list(DIAGRAM_TARGETS) + list(PPTX_TARGETS)\n print(f\"ERROR: unknown target: {name}. Known: {', '.join(known)}\")\n return 2\n\n\n# ------------------------- entry -------------------------\n\ndef _unexpected_arg(args: list[str], allowed: set[str] | None = None) -> str | None:\n for arg in args:\n if allowed is not None:\n if arg not in allowed:\n return arg\n elif arg.startswith(\"-\"):\n return arg\n return None\n\n\ndef _error_unexpected(arg: str) -> int:\n print(f\"ERROR: unexpected argument: {arg}\")\n return 2\n\n\ndef main(argv: list[str]) -> int:\n args = argv[1:]\n if not args:\n return build_all()\n if args[0] in (\"-h\", \"--help\"):\n print(__doc__)\n return 0\n if args[0] == \"--check\":\n unexpected = _unexpected_arg(args[1:], {\"-v\", \"--verbose\"})\n if unexpected:\n return _error_unexpected(unexpected)\n verbose = \"-v\" in args[1:] or \"--verbose\" in args[1:]\n css_result = check_all(verbose)\n sync_result = sync_check(verbose)\n cross_result = check_cross_template_consistency(verbose)\n palette_result = check_off_palette(verbose)\n docs_result = check_docs([])\n site_result = check_site_facts(verbose)\n chart_files = [str(p) for p in sorted(DIAGRAMS.glob(\"*.html\")) if \"data-kami-chart\" in p.read_text(encoding=\"utf-8\")]\n chart_result = check_charts(chart_files)\n return max(css_result, sync_result, cross_result, palette_result, docs_result, site_result,\n chart_result)\n if args[0] == \"--sync\":\n unexpected = _unexpected_arg(args[1:], {\"-v\", \"--verbose\"})\n if unexpected:\n return _error_unexpected(unexpected)\n verbose = \"-v\" in args[1:] or \"--verbose\" in args[1:]\n return sync_check(verbose)\n if args[0] == \"--verify\":\n if len(args) > 2:\n return _error_unexpected(args[2])\n if len(args) == 2 and args[1].startswith(\"-\"):\n return _error_unexpected(args[1])\n target = args[1] if len(args) > 1 else None\n return verify_all(target)\n if args[0] == \"--doctor\":\n if len(args) > 1:\n return _error_unexpected(args[1])\n return run_doctor()\n if args[0] == \"--chart\":\n return chart_main(args[1:])\n # Path-taking check subcommands share one guard + dispatch table.\n path_checks = {\n \"--check-orphans\": check_orphans,\n \"--check-density\": check_density,\n \"--check-resume-balance\": check_resume_balance,\n \"--check-placeholders\": check_placeholders,\n \"--check-markdown\": check_markdown_residue,\n \"--check-content\": check_content,\n \"--check-visual\": check_visual,\n \"--check-fonts\": check_fonts,\n \"--check-style\": check_style,\n \"--check-docs\": check_docs,\n \"--check-charts\": check_charts,\n \"--deliver\": deliver,\n }\n handler = path_checks.get(args[0])\n if handler is not None:\n unexpected = _unexpected_arg(args[1:])\n if unexpected:\n return _error_unexpected(unexpected)\n return handler(args[1:])\n if args[0] == \"--check-rhythm\":\n unexpected = _unexpected_arg(args[1:])\n if unexpected:\n return _error_unexpected(unexpected)\n slide_targets = [a for a in args[1:] if not a.startswith(\"-\")]\n return check_rhythm(slide_targets)\n if args[0].startswith(\"-\"):\n print(f\"ERROR: unknown option: {args[0]}\")\n return 2\n if len(args) > 1:\n return _error_unexpected(args[1])\n return build_single(args[0])\n\n\nif __name__ == \"__main__\":\n sys.exit(main(sys.argv))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10684,"content_sha256":"197757bfbb678a2bf609dee9af33dc9202d6ab12f3d1146398267ce14a56301e"},{"filename":"scripts/chart.py","content":"\"\"\"Deterministic data charts: a JSON spec in, a Kami-palette SVG out.\n\nHand-placing bar heights and arc endpoints is where silent wrong numbers come\nfrom, so the geometry is computed here and nowhere else. Every chart carries\nits own spec in `data-kami-spec`; `--check-charts` regenerates each chart from\nthat spec and fails when the drawn SVG no longer matches it, which catches a\nhand-edited label as surely as a stale bar.\n\nSpec (one chart per file):\n\n {\"type\": \"bar\", \"categories\": [\"2022\", \"2023\"],\n \"series\": [{\"name\": \"Revenue\", \"values\": [44, 60]}]}\n {\"type\": \"line\", same shape as bar}\n {\"type\": \"hbar\", \"items\": [{\"label\": \"A\", \"value\": 31}, ...], \"focus\": 0}\n {\"type\": \"donut\", \"items\": [{\"label\": \"A\", \"value\": 32}, ...],\n \"center_label\": \"Share\"}\n {\"type\": \"candlestick\",\n \"points\": [{\"label\": \"D1\", \"open\": 120, \"high\": 128,\n \"low\": 116, \"close\": 126}, ...]}\n {\"type\": \"waterfall\",\n \"items\": [{\"label\": \"Start\", \"value\": 120, \"total\": true},\n {\"label\": \"A\", \"value\": 30},\n {\"label\": \"End\", \"total\": true}]}\n\nOptional on every type: \"unit\" (axis caption), \"prefix\" / \"suffix\" (value\nlabels), \"decimals\", \"source\" (footnote), \"lang\" (\"cn\", \"en\", \"ko\"; inferred\nfrom the labels when absent), \"title\" (accessible name only, never drawn;\nthe document's figure caption carries the takeaway).\n\nUsage:\n python3 scripts/build.py --chart spec.json [-o chart.svg]\n python3 scripts/build.py --check-charts filled.html\n\"\"\"\nfrom __future__ import annotations\n\nimport html\nimport json\nimport math\nimport re\nimport sys\nimport unicodedata\nfrom pathlib import Path\n\nPARCHMENT = \"#f5f4ed\"\nNEAR_BLACK = \"#141413\"\nCHARCOAL = \"#4d4c48\"\nOLIVE = \"#504e49\"\nSTONE = \"#6b6a64\"\nLIGHT_STONE = \"#b8b7b0\"\nMIST = \"#d4d3cd\"\nBRAND = \"#1B365D\"\nBRAND_TINT = \"#EEF2F7\"\nGRID = \"#e8e7e1\"\n\nSERIES = (BRAND, OLIVE, STONE, LIGHT_STONE, MIST, BRAND_TINT)\n\nFONT_CN = (\"'TsangerJinKai02', 'Source Han Serif SC', 'Source Han Serif CN', \"\n \"'Noto Serif CJK SC', 'Noto Serif SC', 'Songti SC', 'STSong', 'SimSun', \"\n \"Charter, Georgia, serif\")\nFONT_KO = (\"'Source Han Serif K', 'Source Han Serif KR', 'Noto Serif KR', \"\n \"'Nanum Myeongjo', 'AppleMyungjo', Charter, Georgia, serif\")\nFONT_EN = \"Charter, Georgia, Palatino, 'Times New Roman', serif\"\n\nWORDS = {\n \"en\": {\"up\": \"Up (close above open)\", \"down\": \"Down (close below open)\",\n \"increase\": \"Increase\", \"decrease\": \"Decrease\", \"total\": \"Total\"},\n \"cn\": {\"up\": \"上涨\", \"down\": \"下跌\", \"increase\": \"增加\", \"decrease\": \"减少\", \"total\": \"合计\"},\n \"ko\": {\"up\": \"상승\", \"down\": \"하락\", \"increase\": \"증가\", \"decrease\": \"감소\", \"total\": \"합계\"},\n}\n\nLIMITS = {\n \"bar\": (8, 3), \"line\": (12, 3), \"hbar\": (12, 1),\n \"donut\": (6, 1), \"candlestick\": (30, 1), \"waterfall\": (8, 1),\n}\n\n# One layout for every chart, sized for a document column: a 680-unit-wide\n# chart set across a ~170mm column lands near 0.7 scale, so 12-unit labels\n# print at about 8.5pt, just under body text.\nTOP, BASE = 24, 224 # plot area y range\nPLOT_H = BASE - TOP\nLABEL_Y = BASE + 22 # category labels under the axis\nLEGEND_Y = BASE + 44 # hairline above the legend row\nFS_TICK, FS_LABEL, FS_VALUE, FS_LEGEND, FS_NOTE = 11, 12, 12, 12, 11\n\n\ndef _width(spec: dict, base: int) -> int:\n \"\"\"Canvas width. \"size\": \"compact\" narrows the canvas, not the type, for a\n chart shown small (a half-column slide, two charts side by side): drawn at\n the same physical width, its labels come out about 1.5 times larger.\"\"\"\n size = spec.get(\"size\", \"full\")\n if size not in (\"full\", \"compact\"):\n raise ChartError('size must be \"full\" or \"compact\"')\n return round(base * 0.65) if size == \"compact\" else base\n\n\ndef _height(legend: bool, spec: dict) -> int:\n \"\"\"Chart height: plot, labels, optional legend row, optional source line.\"\"\"\n height = LEGEND_Y + 32 if legend else LABEL_Y + 16\n src = spec.get(\"source\")\n return height + (22 if isinstance(src, str) and src.strip() else 0)\n\n\nclass ChartError(ValueError):\n pass\n\n\n# ------------------------------------------------------------------ helpers\n\ndef _num(value, where: str) -> float:\n if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):\n raise ChartError(f\"{where}: expected a number, got {value!r}\")\n return float(value)\n\n\ndef _c(v: float) -> str:\n \"\"\"Coordinate text: one decimal, trailing zero dropped.\"\"\"\n s = f\"{round(v, 1):.1f}\"\n s = s[:-2] if s.endswith(\".0\") else s\n return \"0\" if s == \"-0\" else s\n\n\ndef _esc(text: str) -> str:\n return html.escape(str(text), quote=True)\n\n\ndef _wide(ch: str) -> bool:\n return unicodedata.east_asian_width(ch) in (\"W\", \"F\")\n\n\ndef text_width(text: str, size: float) -> float:\n return sum(size if _wide(ch) else size * 0.56 for ch in str(text))\n\n\ndef _lang(spec: dict, labels: list[str]) -> str:\n lang = spec.get(\"lang\")\n if lang in WORDS:\n return lang\n if isinstance(lang, str) and lang.lower().startswith(\"zh\"):\n return \"cn\"\n joined = \"\".join(labels)\n if re.search(r\"[가-힯ᄀ-ᇿ]\", joined):\n return \"ko\"\n if any(_wide(ch) for ch in joined):\n return \"cn\"\n return \"en\"\n\n\ndef _font(lang: str) -> str:\n return {\"cn\": FONT_CN, \"ko\": FONT_KO}.get(lang, FONT_EN)\n\n\ndef _decimals(spec: dict, values: list[float]) -> int:\n if isinstance(spec.get(\"decimals\"), int):\n return max(0, min(4, spec[\"decimals\"]))\n best = 0\n for v in values:\n text = repr(float(v))\n if \"e\" in text:\n continue\n frac = text.split(\".\")[1].rstrip(\"0\")\n best = max(best, len(frac))\n return min(best, 2)\n\n\ndef fmt(value: float, decimals: int, prefix: str = \"\", suffix: str = \"\", sign: bool = False) -> str:\n body = f\"{abs(value):,.{decimals}f}\"\n neg = value \u003c 0 and float(body.replace(\",\", \"\")) != 0\n lead = \"-\" if neg else (\"+\" if sign and value > 0 else \"\")\n return f\"{lead}{prefix}{body}{suffix}\"\n\n\ndef nice_scale(lo: float, hi: float, *, include_zero: bool) -> tuple[float, float, float]:\n \"\"\"Round [lo, hi] out to a 4-8 tick axis with a 1/2/2.5/5 step.\"\"\"\n if include_zero:\n lo, hi = min(lo, 0.0), max(hi, 0.0)\n if hi == lo:\n hi = lo + (abs(lo) or 1.0)\n raw = (hi - lo) / 6\n mag = 10 ** math.floor(math.log10(raw))\n step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw - 1e-12)\n lo_n = math.floor(lo / step + 1e-9) * step\n hi_n = math.ceil(hi / step - 1e-9) * step\n return lo_n, hi_n, step\n\n\nclass _Svg:\n def __init__(self, spec: dict, kind: str, width: int, height: int, font: str) -> None:\n self.kind, self.width, self.height, self.font = kind, width, height, font\n self.spec = spec\n self.parts: list[str] = []\n\n def add(self, line: str) -> None:\n self.parts.append(line)\n\n def text(self, x: float, y: float, value: str, *, size: float = FS_LABEL, fill: str = NEAR_BLACK,\n anchor: str = \"start\", weight: str | None = None, extra: str = \"\") -> None:\n attrs = f'x=\"{_c(x)}\" y=\"{_c(y)}\" fill=\"{fill}\" font-size=\"{size:g}\" font-family=\"{self.font}\"'\n if anchor != \"start\":\n attrs += f' text-anchor=\"{anchor}\"'\n if weight:\n attrs += f' font-weight=\"{weight}\"'\n if extra:\n attrs += f\" {extra}\"\n self.add(f\"\u003ctext {attrs}>{_esc(value)}\u003c/text>\")\n\n def render(self) -> str:\n canonical = json.dumps(self.spec, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\"))\n title = self.spec.get(\"title\")\n label = f' aria-label=\"{_esc(title)}\"' if isinstance(title, str) and title else \"\"\n head = (f'\u003csvg viewBox=\"0 0 {self.width} {self.height}\" xmlns=\"http://www.w3.org/2000/svg\" '\n f'role=\"img\"{label} data-kami-chart=\"{self.kind}\" data-kami-spec=\"{_esc(canonical)}\">')\n body = [f'\u003crect width=\"100%\" height=\"100%\" fill=\"{PARCHMENT}\"/>', *self.parts]\n return head + \"\\n\" + \"\\n\".join(f\" {p}\" for p in body) + \"\\n\u003c/svg>\\n\"\n\n\ndef _step_decimals(step: float) -> int:\n \"\"\"Tick labels carry the precision of the step, not of the data (0-20 by 5, not 0.0-20.0).\"\"\"\n for dec in range(5):\n if abs(step * 10 ** dec - round(step * 10 ** dec)) \u003c 1e-9:\n return dec\n return 4\n\n\ndef _plot_left(lo: float, hi: float, step: float, unit) -> float:\n \"\"\"Left edge of the plot: tick labels start at the chart's own left edge, so\n the chart lines up with the text column it sits in.\"\"\"\n dec = _step_decimals(step)\n ticks = [fmt(lo + i * step, dec) for i in range(round((hi - lo) / step) + 1)]\n return (22 if unit else 0) + max(text_width(t, FS_TICK) for t in ticks) + 10\n\n\ndef _axis(svg: _Svg, x0: float, x1: float, lo: float, hi: float, step: float,\n ymap, unit: str | None) -> None:\n decimals = _step_decimals(step)\n ticks = round((hi - lo) / step)\n for i in range(ticks + 1):\n v = lo + i * step\n y = ymap(v)\n svg.add(f'\u003cline x1=\"{_c(x0)}\" y1=\"{_c(y)}\" x2=\"{_c(x1)}\" y2=\"{_c(y)}\" stroke=\"{GRID}\" stroke-width=\"0.8\"/>')\n svg.text(x0 - 8, y + 4, fmt(v, decimals), size=FS_TICK, fill=STONE, anchor=\"end\")\n if unit:\n mid = (TOP + BASE) / 2\n svg.text(8, mid, unit, size=FS_NOTE, fill=STONE, anchor=\"middle\",\n extra=f'transform=\"rotate(-90 8 {_c(mid)})\"')\n\n\ndef _legend(svg: _Svg, x0: float, x1: float, y: float, entries: list[tuple[str, str]]) -> None:\n \"\"\"entries: (square swatch markup with {x} {y} slots, label).\"\"\"\n svg.add(f'\u003cline x1=\"{_c(x0)}\" y1=\"{_c(y)}\" x2=\"{_c(x1)}\" y2=\"{_c(y)}\" stroke=\"{GRID}\" stroke-width=\"0.8\"/>')\n x = x0\n for swatch, label in entries:\n svg.add(swatch.format(x=_c(x), y=_c(y + 13)))\n tx = x + 18\n svg.text(tx, y + 24, label, size=FS_LEGEND, fill=OLIVE)\n x = tx + text_width(label, FS_LEGEND) + 32\n\n\ndef _source(svg: _Svg, spec: dict, x: float) -> None:\n \"\"\"The source line sits on the last row _height reserved for it.\"\"\"\n src = spec.get(\"source\")\n if isinstance(src, str) and src.strip():\n svg.text(x, svg.height - 6, src.strip(), size=FS_NOTE, fill=STONE)\n\n\ndef _square(color: str) -> str:\n stroke = f' stroke=\"{GRID}\" stroke-width=\"0.8\"' if color == BRAND_TINT else \"\"\n return '\u003crect x=\"{x}\" y=\"{y}\" width=\"12\" height=\"12\" fill=\"' + color + '\"' + stroke + ' rx=\"2\"/>'\n\n\ndef _common(spec: dict) -> tuple[str, str]:\n prefix = spec.get(\"prefix\", \"\")\n suffix = spec.get(\"suffix\", \"\")\n if not isinstance(prefix, str) or not isinstance(suffix, str):\n raise ChartError(\"prefix and suffix must be strings\")\n return prefix, suffix\n\n\ndef _limit(kind: str, count: int, series: int = 1) -> None:\n max_items, max_series = LIMITS[kind]\n if count == 0:\n raise ChartError(f\"{kind}: no data\")\n if count > max_items:\n hint = \" (use hbar for 7+ shares)\" if kind == \"donut\" else \"\"\n raise ChartError(f\"{kind}: {count} items, at most {max_items}{hint}\")\n if series > max_series:\n raise ChartError(f\"{kind}: {series} series, at most {max_series}\")\n\n\ndef _label(item, where: str) -> str:\n if not isinstance(item, str) or not item.strip():\n raise ChartError(f\"{where}: label must be a non-empty string\")\n return item\n\n\n# ------------------------------------------------------------- bar / line\n\ndef _series_spec(spec: dict, kind: str) -> tuple[list[str], list[tuple[str, list[float]]]]:\n cats = spec.get(\"categories\")\n series = spec.get(\"series\")\n if not isinstance(cats, list) or not isinstance(series, list) or not series:\n raise ChartError(f\"{kind}: needs 'categories' and a non-empty 'series' list\")\n cats = [_label(c, f\"categories[{i}]\") for i, c in enumerate(cats)]\n out = []\n for si, s in enumerate(series):\n if not isinstance(s, dict) or not isinstance(s.get(\"values\"), list):\n raise ChartError(f\"series[{si}]: needs 'values'\")\n vals = [_num(v, f\"series[{si}].values[{i}]\") for i, v in enumerate(s[\"values\"])]\n if len(vals) != len(cats):\n raise ChartError(f\"series[{si}]: {len(vals)} values for {len(cats)} categories\")\n name = s.get(\"name\", \"\")\n out.append((name if isinstance(name, str) else str(name), vals))\n _limit(kind, len(cats), len(out))\n return cats, out\n\n\ndef _bar(spec: dict) -> _Svg:\n cats, series = _series_spec(spec, \"bar\")\n prefix, suffix = _common(spec)\n allv = [v for _, vals in series for v in vals]\n dec = _decimals(spec, allv)\n lo, hi, step = nice_scale(min(allv), max(allv), include_zero=True)\n legend = len(series) > 1\n lang = _lang(spec, cats + [n for n, _ in series])\n svg = _Svg(spec, \"bar\", _width(spec, 680), _height(legend, spec), _font(lang))\n x0, x1 = _plot_left(lo, hi, step, spec.get(\"unit\")), svg.width - 4\n\n def ymap(v: float) -> float:\n return BASE - (v - lo) / (hi - lo) * PLOT_H\n\n _axis(svg, x0, x1, lo, hi, step, ymap, spec.get(\"unit\"))\n zero = ymap(0)\n svg.add(f'\u003cline x1=\"{x0}\" y1=\"{_c(zero)}\" x2=\"{x1}\" y2=\"{_c(zero)}\" stroke=\"{NEAR_BLACK}\" stroke-width=\"0.8\"/>')\n n, s = len(cats), len(series)\n gw = (x1 - x0) / n\n # One series gets a bar about half its slot; grouped bars stay narrow so\n # the gap between groups still reads as the category break.\n bw = min(56.0, gw * 0.5) if s == 1 else min(32.0, (gw * 0.7 - 8 * (s - 1)) / s)\n total = s * bw + 8 * (s - 1)\n for i, cat in enumerate(cats):\n cx = x0 + gw * (i + 0.5)\n for si, (_, vals) in enumerate(series):\n v = vals[i]\n bx = cx - total / 2 + si * (bw + 8)\n top, bottom = (ymap(v), zero) if v >= 0 else (zero, ymap(v))\n svg.add(f'\u003crect x=\"{_c(bx)}\" y=\"{_c(top)}\" width=\"{_c(bw)}\" height=\"{_c(bottom - top)}\" '\n f'fill=\"{SERIES[si]}\" rx=\"2\"/>')\n ly = top - 4 if v >= 0 else bottom + 12\n svg.text(bx + bw / 2, ly, fmt(v, dec, prefix, suffix), size=FS_VALUE, anchor=\"middle\",\n weight=\"500\" if si == 0 else None)\n svg.text(cx, LABEL_Y, cat, size=FS_LABEL, anchor=\"middle\")\n if legend:\n _legend(svg, x0, x1, LEGEND_Y, [(_square(SERIES[i]), name) for i, (name, _) in enumerate(series)])\n _source(svg, spec, 0)\n return svg\n\n\n_LINE_STYLES = (\n (\"2\", \"\", True), (\"1.5\", ' stroke-dasharray=\"5 3\"', False), (\"1.5\", ' stroke-dasharray=\"2 3\"', False),\n)\n\n\ndef _line(spec: dict) -> _Svg:\n cats, series = _series_spec(spec, \"line\")\n prefix, suffix = _common(spec)\n allv = [v for _, vals in series for v in vals]\n dec = _decimals(spec, allv)\n lo, hi, step = nice_scale(min(allv), max(allv), include_zero=min(allv) >= 0 and spec.get(\"zero\", True))\n legend = len(series) > 1\n lang = _lang(spec, cats + [n for n, _ in series])\n svg = _Svg(spec, \"line\", _width(spec, 680), _height(legend, spec), _font(lang))\n end_w = max(text_width(fmt(vals[-1], dec, prefix, suffix), FS_VALUE) for _, vals in series)\n x0, x1 = _plot_left(lo, hi, step, spec.get(\"unit\")), svg.width - end_w - 12\n\n def ymap(v: float) -> float:\n return BASE - (v - lo) / (hi - lo) * PLOT_H\n\n n = len(cats)\n xs = [(x0 + x1) / 2] if n == 1 else [x0 + i * (x1 - x0) / (n - 1) for i in range(n)]\n _axis(svg, x0, x1, lo, hi, step, ymap, spec.get(\"unit\"))\n for x in xs[1:-1]:\n svg.add(f'\u003cline x1=\"{_c(x)}\" y1=\"{TOP}\" x2=\"{_c(x)}\" y2=\"{BASE}\" stroke=\"{GRID}\" '\n f'stroke-width=\"0.5\" stroke-dasharray=\"3 4\"/>')\n svg.add(f'\u003cline x1=\"{x0}\" y1=\"{BASE}\" x2=\"{x1}\" y2=\"{BASE}\" stroke=\"{NEAR_BLACK}\" stroke-width=\"0.8\"/>')\n ends = []\n for si, (_, vals) in enumerate(series):\n width, dash, filled = _LINE_STYLES[si]\n color = SERIES[si]\n pts = \" \".join(f\"{_c(x)},{_c(ymap(v))}\" for x, v in zip(xs, vals))\n svg.add(f'\u003cpolyline points=\"{pts}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{width}\"{dash} '\n f'stroke-linejoin=\"round\" stroke-linecap=\"round\"/>')\n for i, (x, v) in enumerate(zip(xs, vals)):\n y = ymap(v)\n if filled:\n r = \"5.5\" if i == n - 1 else \"4\"\n svg.add(f'\u003ccircle cx=\"{_c(x)}\" cy=\"{_c(y)}\" r=\"{r}\" fill=\"{color}\" stroke=\"{PARCHMENT}\" stroke-width=\"1.5\"/>')\n else:\n svg.add(f'\u003ccircle cx=\"{_c(x)}\" cy=\"{_c(y)}\" r=\"3.5\" fill=\"{PARCHMENT}\" stroke=\"{color}\" stroke-width=\"1.5\"/>')\n ends.append([ymap(vals[-1]) + 4, si, vals[-1]])\n ends.sort()\n for i in range(1, len(ends)): # keep end labels at least 12px apart\n ends[i][0] = max(ends[i][0], ends[i - 1][0] + 12)\n for y, si, v in ends:\n svg.text(xs[-1] + 8, y, fmt(v, dec, prefix, suffix), size=FS_VALUE,\n fill=SERIES[si], weight=\"500\" if si == 0 else None)\n for x, cat in zip(xs, cats):\n svg.text(x, LABEL_Y, cat, size=FS_LABEL, anchor=\"middle\")\n if legend:\n _legend_lines(svg, x0, x1, LEGEND_Y, [name for name, _ in series])\n _source(svg, spec, 0)\n return svg\n\n\ndef _legend_lines(svg: _Svg, x0: float, x1: float, y: float, names: list[str]) -> None:\n svg.add(f'\u003cline x1=\"{_c(x0)}\" y1=\"{_c(y)}\" x2=\"{_c(x1)}\" y2=\"{_c(y)}\" stroke=\"{GRID}\" stroke-width=\"0.8\"/>')\n x, cy = x0, y + 18\n for si, name in enumerate(names):\n width, dash, filled = _LINE_STYLES[si]\n color = SERIES[si]\n svg.add(f'\u003cline x1=\"{_c(x)}\" y1=\"{_c(cy)}\" x2=\"{_c(x + 24)}\" y2=\"{_c(cy)}\" stroke=\"{color}\" '\n f'stroke-width=\"{width}\"{dash} stroke-linecap=\"round\"/>')\n if filled:\n svg.add(f'\u003ccircle cx=\"{_c(x + 12)}\" cy=\"{_c(cy)}\" r=\"3.5\" fill=\"{color}\"/>')\n else:\n svg.add(f'\u003ccircle cx=\"{_c(x + 12)}\" cy=\"{_c(cy)}\" r=\"3\" fill=\"{PARCHMENT}\" stroke=\"{color}\" stroke-width=\"1.5\"/>')\n svg.text(x + 32, cy + 4, name, size=FS_LEGEND, fill=OLIVE)\n x = x + 32 + text_width(name, FS_LEGEND) + 32\n\n\n# ------------------------------------------------------------------- hbar\n\ndef _items(spec: dict, kind: str) -> list[tuple[str, float]]:\n items = spec.get(\"items\")\n if not isinstance(items, list):\n raise ChartError(f\"{kind}: needs an 'items' list\")\n out = []\n for i, it in enumerate(items):\n if not isinstance(it, dict):\n raise ChartError(f\"items[{i}]: expected an object\")\n out.append((_label(it.get(\"label\"), f\"items[{i}]\"), _num(it.get(\"value\"), f\"items[{i}].value\")))\n _limit(kind, len(out))\n return out\n\n\ndef _hbar(spec: dict) -> _Svg:\n items = _items(spec, \"hbar\")\n prefix, suffix = _common(spec)\n if any(v \u003c 0 for _, v in items):\n raise ChartError(\"hbar: values must be zero or positive\")\n dec = _decimals(spec, [v for _, v in items])\n focus = spec.get(\"focus\", 0)\n if focus is not None and (not isinstance(focus, int) or not 0 \u003c= focus \u003c len(items)):\n raise ChartError(\"hbar: focus must be an item index or null\")\n lang = _lang(spec, [l for l, _ in items])\n unit = spec.get(\"unit\") if isinstance(spec.get(\"unit\"), str) and spec.get(\"unit\") else None\n row, bar = 30, 18\n top = 34 if unit else 10\n has_source = isinstance(spec.get(\"source\"), str) and spec[\"source\"].strip()\n height = top + len(items) * row + 6 + (26 if has_source else 0)\n svg = _Svg(spec, \"hbar\", _width(spec, 680), height, _font(lang))\n label_w = min(200.0, max(text_width(l, FS_LABEL) for l, _ in items))\n x0 = label_w + 14\n vmax = max(v for _, v in items) or 1.0\n labels = [fmt(v, dec, prefix, suffix) for _, v in items]\n span = svg.width - 20 - x0 - max(text_width(t, FS_VALUE) for t in labels) - 10\n if unit:\n svg.text(x0, top - 14, unit, size=FS_NOTE, fill=STONE)\n for i, (label, v) in enumerate(items):\n y = top + i * row\n w = v / vmax * span\n color = BRAND if i == focus else LIGHT_STONE\n svg.text(x0 - 14, y + 14, label, size=FS_LABEL, anchor=\"end\")\n svg.add(f'\u003crect x=\"{_c(x0)}\" y=\"{_c(y + 1)}\" width=\"{_c(w)}\" height=\"{bar}\" fill=\"{color}\" rx=\"2\"/>')\n svg.text(x0 + w + 8, y + 14, labels[i], size=FS_VALUE, weight=\"500\" if i == focus else None)\n svg.add(f'\u003cline x1=\"{_c(x0)}\" y1=\"{top - 3}\" x2=\"{_c(x0)}\" y2=\"{_c(top + len(items) * row - 7)}\" '\n f'stroke=\"{NEAR_BLACK}\" stroke-width=\"0.8\"/>')\n _source(svg, spec, 0)\n return svg\n\n\n# ------------------------------------------------------------------ donut\n\ndef _shares(values: list[float], decimals: int) -> list[float]:\n \"\"\"Percentages that sum to exactly 100 at the shown precision (largest remainder).\"\"\"\n total = sum(values)\n scale = 10 ** decimals\n raw = [v / total * 100 * scale for v in values]\n floors = [math.floor(r) for r in raw]\n short = round(100 * scale) - sum(floors)\n order = sorted(range(len(raw)), key=lambda i: raw[i] - floors[i], reverse=True)\n for i in order[:short]:\n floors[i] += 1\n return [f / scale for f in floors]\n\n\ndef _arc_point(cx: float, cy: float, r: float, deg: float) -> tuple[float, float]:\n rad = math.radians(deg)\n return cx + r * math.cos(rad), cy + r * math.sin(rad)\n\n\ndef _donut(spec: dict) -> _Svg:\n \"\"\"Ring on the left, legend beside it at reading size, one composition.\"\"\"\n items = _items(spec, \"donut\")\n if any(v \u003c= 0 for _, v in items):\n raise ChartError(\"donut: every value must be positive\")\n dec = spec[\"decimals\"] if isinstance(spec.get(\"decimals\"), int) else 0\n shares = _shares([v for _, v in items], dec)\n lang = _lang(spec, [l for l, _ in items] + [str(spec.get(\"center_label\", \"\"))])\n has_source = isinstance(spec.get(\"source\"), str) and spec[\"source\"].strip()\n cx, cy, R, r = 114, 128, 112, 68\n pct = [fmt(s, dec, suffix=\"%\") for s in shares]\n pct_w = max(text_width(p, 14) for p in pct)\n lx = cx + R + 44\n need = lx + 22 + pct_w + 14 + max(text_width(l, FS_LABEL) for l, _ in items) + 8\n width = max(_width(spec, 680), round(need))\n svg = _Svg(spec, \"donut\", width, 260 + (26 if has_source else 0), _font(lang))\n total = sum(v for _, v in items)\n angle = -90.0\n for i, (_, v) in enumerate(items):\n span = v / total * 360\n color = SERIES[i]\n stroke = f' stroke=\"{GRID}\" stroke-width=\"0.8\"' if color == BRAND_TINT else \"\"\n if span >= 359.999:\n d = (f\"M {_c(cx)} {_c(cy - R)} A {R} {R} 0 1 1 {_c(cx)} {_c(cy + R)} \"\n f\"A {R} {R} 0 1 1 {_c(cx)} {_c(cy - R)} M {_c(cx)} {_c(cy - r)} \"\n f\"A {r} {r} 0 1 0 {_c(cx)} {_c(cy + r)} A {r} {r} 0 1 0 {_c(cx)} {_c(cy - r)} Z\")\n svg.add(f'\u003cpath d=\"{d}\" fill=\"{color}\" fill-rule=\"evenodd\"{stroke}/>')\n else:\n end = angle + span\n large = 1 if span > 180 else 0\n ox1, oy1 = _arc_point(cx, cy, R, angle)\n ox2, oy2 = _arc_point(cx, cy, R, end)\n ix2, iy2 = _arc_point(cx, cy, r, end)\n ix1, iy1 = _arc_point(cx, cy, r, angle)\n d = (f\"M {_c(ox1)} {_c(oy1)} A {R} {R} 0 {large} 1 {_c(ox2)} {_c(oy2)} \"\n f\"L {_c(ix2)} {_c(iy2)} A {r} {r} 0 {large} 0 {_c(ix1)} {_c(iy1)} Z\")\n svg.add(f'\u003cpath d=\"{d}\" fill=\"{color}\"{stroke}/>')\n angle += span\n svg.add(f'\u003ccircle cx=\"{cx}\" cy=\"{cy}\" r=\"{R}\" fill=\"none\" stroke=\"{PARCHMENT}\" stroke-width=\"1.5\"/>')\n svg.add(f'\u003ccircle cx=\"{cx}\" cy=\"{cy}\" r=\"{r}\" fill=\"none\" stroke=\"{PARCHMENT}\" stroke-width=\"1.5\"/>')\n center = spec.get(\"center_label\")\n has_center = isinstance(center, str) and center\n svg.text(cx, cy + (-2 if has_center else 10), pct[0], size=30, fill=BRAND, anchor=\"middle\", weight=\"500\")\n if has_center:\n svg.text(cx, cy + 20, center, size=FS_NOTE, fill=STONE, anchor=\"middle\")\n n = len(items)\n rowh = 32\n y0 = cy - n * rowh / 2\n for i, (label, _) in enumerate(items):\n y = y0 + i * rowh\n svg.add(_square(SERIES[i]).format(x=_c(lx), y=_c(y + 10)))\n svg.text(lx + 22, y + 21, pct[i], size=14, fill=BRAND if i == 0 else NEAR_BLACK,\n weight=\"500\" if i == 0 else None)\n svg.text(lx + 22 + pct_w + 14, y + 21, label, size=FS_LABEL, fill=OLIVE)\n _source(svg, spec, 0)\n return svg\n\n\n# ------------------------------------------------------------ candlestick\n\ndef _candlestick(spec: dict) -> _Svg:\n pts = spec.get(\"points\")\n if not isinstance(pts, list):\n raise ChartError(\"candlestick: needs a 'points' list\")\n rows = []\n for i, p in enumerate(pts):\n if not isinstance(p, dict):\n raise ChartError(f\"points[{i}]: expected an object\")\n o, h, l, c = (_num(p.get(k), f\"points[{i}].{k}\") for k in (\"open\", \"high\", \"low\", \"close\"))\n if h \u003c max(o, c) or l > min(o, c):\n raise ChartError(f\"points[{i}]: high {h:g} / low {l:g} do not contain open {o:g} and close {c:g}\")\n rows.append((_label(p.get(\"label\"), f\"points[{i}]\"), o, h, l, c))\n _limit(\"candlestick\", len(rows))\n prefix, suffix = _common(spec)\n dec = _decimals(spec, [v for row in rows for v in row[1:]])\n lo, hi, step = nice_scale(min(r[3] for r in rows), max(r[2] for r in rows), include_zero=False)\n lang = _lang(spec, [r[0] for r in rows])\n words = WORDS[lang]\n svg = _Svg(spec, \"candlestick\", _width(spec, 760), _height(True, spec), _font(lang))\n x0, x1 = _plot_left(lo, hi, step, spec.get(\"unit\")), svg.width - 4\n\n def ymap(v: float) -> float:\n return BASE - (v - lo) / (hi - lo) * PLOT_H\n\n _axis(svg, x0, x1, lo, hi, step, ymap, spec.get(\"unit\"))\n n = len(rows)\n stepx = (x1 - x0) / n\n bw = min(16.0, stepx * 0.5)\n every = max(1, math.ceil(n / 5))\n for i, (label, o, h, l, c) in enumerate(rows):\n cx = x0 + stepx * (i + 0.5)\n color = BRAND if c >= o else STONE\n svg.add(f'\u003cline x1=\"{_c(cx)}\" y1=\"{_c(ymap(h))}\" x2=\"{_c(cx)}\" y2=\"{_c(ymap(l))}\" '\n f'stroke=\"{color}\" stroke-width=\"1.2\"/>')\n top, bottom = ymap(max(o, c)), ymap(min(o, c))\n svg.add(f'\u003crect x=\"{_c(cx - bw / 2)}\" y=\"{_c(top)}\" width=\"{_c(bw)}\" '\n f'height=\"{_c(max(1.0, bottom - top))}\" fill=\"{color}\" rx=\"1\"/>')\n if i % every == 0 or (i == n - 1 and (n - 1) % every >= every / 2):\n svg.text(cx, LABEL_Y, label, size=FS_TICK, fill=STONE, anchor=\"middle\")\n _legend(svg, x0, x1, LEGEND_Y, [(_square(BRAND), words[\"up\"]), (_square(STONE), words[\"down\"])])\n _source(svg, spec, 0)\n return svg\n\n\n# -------------------------------------------------------------- waterfall\n\ndef _waterfall(spec: dict) -> _Svg:\n items = spec.get(\"items\")\n if not isinstance(items, list):\n raise ChartError(\"waterfall: needs an 'items' list\")\n _limit(\"waterfall\", len(items))\n prefix, suffix = _common(spec)\n rows = [] # (label, kind, value shown, level_before, level_after)\n level = 0.0\n for i, it in enumerate(items):\n if not isinstance(it, dict):\n raise ChartError(f\"items[{i}]: expected an object\")\n label = _label(it.get(\"label\"), f\"items[{i}]\")\n if it.get(\"total\"):\n if \"value\" in it:\n v = _num(it[\"value\"], f\"items[{i}].value\")\n if i > 0 and abs(v - level) > 1e-9 * max(1.0, abs(level)):\n raise ChartError(f\"items[{i}] '{label}': total {v:g} does not equal the running sum {level:g}\")\n level = v\n elif i == 0:\n raise ChartError(\"items[0]: an opening total needs a value\")\n rows.append((label, \"total\", level, 0.0, level))\n else:\n d = _num(it.get(\"value\"), f\"items[{i}].value\")\n rows.append((label, \"delta\", d, level, level + d))\n level += d\n dec = _decimals(spec, [r[2] for r in rows])\n levels = [x for r in rows for x in (r[3], r[4])]\n lo, hi, step = nice_scale(min(levels), max(levels), include_zero=True)\n lang = _lang(spec, [r[0] for r in rows])\n words = WORDS[lang]\n svg = _Svg(spec, \"waterfall\", _width(spec, 720), _height(True, spec), _font(lang))\n x0, x1 = _plot_left(lo, hi, step, spec.get(\"unit\")), svg.width - 4\n\n def ymap(v: float) -> float:\n return BASE - (v - lo) / (hi - lo) * PLOT_H\n\n _axis(svg, x0, x1, lo, hi, step, ymap, spec.get(\"unit\"))\n n = len(rows)\n stepx = (x1 - x0) / n\n bw = min(64.0, stepx * 0.75)\n prev_edge = None\n for i, (label, kind, v, before, after) in enumerate(rows):\n cx = x0 + stepx * (i + 0.5)\n bx = cx - bw / 2\n if prev_edge is not None:\n y = ymap(before if kind == \"delta\" else after)\n svg.add(f'\u003cline x1=\"{_c(prev_edge)}\" y1=\"{_c(y)}\" x2=\"{_c(bx)}\" y2=\"{_c(y)}\" '\n f'stroke=\"{LIGHT_STONE}\" stroke-width=\"0.8\" stroke-dasharray=\"3 2\"/>')\n a, b = (0.0, after) if kind == \"total\" else (before, after)\n top, bottom = ymap(max(a, b)), ymap(min(a, b))\n color = CHARCOAL if kind == \"total\" else (BRAND if v >= 0 else STONE)\n svg.add(f'\u003crect x=\"{_c(bx)}\" y=\"{_c(top)}\" width=\"{_c(bw)}\" height=\"{_c(max(1.0, bottom - top))}\" '\n f'fill=\"{color}\" rx=\"2\"/>')\n text = fmt(v, dec, prefix, suffix, sign=kind == \"delta\")\n ly = bottom + 14 if (kind == \"delta\" and v \u003c 0) else top - 8\n svg.text(cx, ly, text, size=FS_VALUE, anchor=\"middle\", weight=\"500\")\n svg.text(cx, LABEL_Y, label, size=FS_LABEL, anchor=\"middle\")\n prev_edge = bx + bw\n _legend(svg, x0, x1, LEGEND_Y, [(_square(BRAND), words[\"increase\"]), (_square(STONE), words[\"decrease\"]),\n (_square(CHARCOAL), words[\"total\"])])\n _source(svg, spec, 0)\n return svg\n\n\nBUILDERS = {\"bar\": _bar, \"line\": _line, \"hbar\": _hbar, \"donut\": _donut,\n \"candlestick\": _candlestick, \"waterfall\": _waterfall}\n\n\ndef render_chart(spec: dict) -> str:\n if not isinstance(spec, dict):\n raise ChartError(\"spec must be a JSON object\")\n kind = spec.get(\"type\")\n if kind not in BUILDERS:\n raise ChartError(f\"type must be one of {', '.join(BUILDERS)}; got {kind!r}\")\n return BUILDERS[kind](spec).render()\n\n\n# ------------------------------------------------------------------ gate\n\n_CHART_RE = re.compile(r\"\u003csvg\\b[^>]*\\bdata-kami-chart=\\\"[^\\\"]*\\\"[^>]*>.*?\u003c/svg>\", re.S | re.I)\n_SPEC_RE = re.compile(r'\\bdata-kami-spec=\"([^\"]*)\"')\n_KIND_RE = re.compile(r'\\bdata-kami-chart=\"([^\"]*)\"')\n_GAPS = re.compile(r\">\\s+\u003c\")\n\n\ndef _inner(svg: str) -> str:\n \"\"\"Drawn content only: embedding may restyle the root tag, never the marks.\"\"\"\n body = svg[svg.index(\">\") + 1: svg.rindex(\"\u003c/svg>\")]\n return _GAPS.sub(\">\u003c\", body).strip()\n\n\ndef find_charts(text: str) -> list[str]:\n return _CHART_RE.findall(text)\n\n\ndef compare_chart(svg: str) -> str | None:\n \"\"\"None when the drawn chart matches its embedded spec, else the reason.\"\"\"\n m = _SPEC_RE.search(svg[: svg.index(\">\") + 1])\n if not m:\n return \"no data-kami-spec on the chart root\"\n try:\n spec = json.loads(html.unescape(m.group(1)))\n expected = render_chart(spec)\n except (json.JSONDecodeError, ChartError) as exc:\n return f\"embedded spec is invalid: {exc}\"\n want, got = _inner(expected), _inner(svg)\n if want == got:\n return None\n want_parts, got_parts = want.split(\">\u003c\"), got.split(\">\u003c\")\n for i, (a, b) in enumerate(zip(want_parts, got_parts)):\n if a != b:\n at = next((k for k, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b)))\n lo = max(0, at - 24)\n return (f\"drawn mark {i} differs from the spec: expected ...{a[lo:at + 24]}..., \"\n f\"found ...{b[lo:at + 24]}...\")\n return f\"{abs(len(want_parts) - len(got_parts))} mark(s) added or removed after generation\"\n\n\ndef check_charts(argv: list[str]) -> int:\n files = [a for a in argv if not a.startswith(\"-\")]\n if not files:\n print(\"ERROR: usage: --check-charts file.html|file.svg ...\")\n return 2\n failures = 0\n for name in files:\n path = Path(name)\n if not path.is_file():\n print(f\"ERROR: {name}: file not found\")\n return 2\n charts = find_charts(path.read_text(encoding=\"utf-8\", errors=\"replace\"))\n if not charts:\n print(f\"OK: {name}: no generated charts\")\n continue\n for i, svg in enumerate(charts, 1):\n kind = _KIND_RE.search(svg).group(1)\n reason = compare_chart(svg)\n if reason:\n failures += 1\n print(f\"ERROR: {name}: chart {i} ({kind}): {reason}; \"\n \"fix the spec and regenerate with --chart, never edit the SVG by hand\")\n else:\n print(f\"OK: {name}: chart {i} ({kind}) matches its data\")\n return 1 if failures else 0\n\n\ndef main(argv: list[str]) -> int:\n args = list(argv)\n out = None\n if \"-o\" in args:\n i = args.index(\"-o\")\n if i + 1 >= len(args):\n print(\"ERROR: -o needs a path\")\n return 2\n out = args[i + 1]\n del args[i:i + 2]\n if len(args) != 1 or args[0].startswith(\"-\"):\n print(\"ERROR: usage: --chart spec.json [-o chart.svg]\")\n return 2\n try:\n spec = json.loads(Path(args[0]).read_text(encoding=\"utf-8\"))\n svg = render_chart(spec)\n except FileNotFoundError:\n print(f\"ERROR: {args[0]}: file not found\")\n return 2\n except json.JSONDecodeError as exc:\n print(f\"ERROR: {args[0]}: invalid JSON: {exc}\")\n return 2\n except ChartError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n if out:\n Path(out).write_text(svg, encoding=\"utf-8\")\n print(f\"OK: wrote {out} ({spec['type']})\")\n else:\n sys.stdout.write(svg)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main(sys.argv[1:]))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":33844,"content_sha256":"2ac32eeab66753ed44bf8d91f5168ff9960b7584ca4a48a5b011d8ab1aa3bd2b"},{"filename":"scripts/check-update.sh","content":"#!/usr/bin/env bash\n# Quiet daily update check for the installed kami skill.\n#\n# Writes a local daily cache marker, then resolves GitHub's latest published\n# release and compares its tag to the bundled VERSION. If a newer version exists,\n# prints one line so the agent can relay it. It uploads no user document or task\n# content; any failure is silent, so the check never blocks work.\nset -u\n\nSKILL=\"kami\"\nREPO=\"tw93/Kami\"\nDEFAULT_UPDATE_CMD=\"npx skills add tw93/kami -a claude-code codex cursor -g -y\"\n# KAMI_UPDATE_URL overrides the source with a plain version file (used by tests).\nLATEST_RELEASE_URL=\"https://github.com/${REPO}/releases/latest\"\n\nroot=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nlocal_ver=\"$(tr -d '[:space:]' \u003c \"${root}/VERSION\" 2>/dev/null)\"\n[ -n \"${local_ver}\" ] || exit 0\n\ncase \"${root}\" in\n */.claude/plugins/cache/kami/kami/*/skills/kami)\n UPDATE_CMD=\"claude plugin update kami\"\n ;;\n */plugins/cache/kami/kami/*/skills/kami)\n UPDATE_CMD=\"codex plugin marketplace upgrade kami && codex plugin add kami@kami\"\n ;;\n *)\n UPDATE_CMD=\"${DEFAULT_UPDATE_CMD}\"\n ;;\nesac\n\n# Throttle: at most one check per calendar day, regardless of outcome. One\n# dated marker file rewritten in place, so the cache dir does not accumulate\n# a new empty update-checked-YYYY-MM-DD file every day.\nday=\"$(date +%F 2>/dev/null)\" || exit 0\nif [ -n \"${XDG_CACHE_HOME:-}\" ]; then\n cache_root=\"${XDG_CACHE_HOME}\"\nelif [ -n \"${HOME:-}\" ]; then\n cache_root=\"${HOME}/.cache\"\nelse\n exit 0\nfi\ncache_dir=\"${cache_root}/${SKILL}\"\nmarker=\"${cache_dir}/update-checked\"\n[ \"$(cat \"${marker}\" 2>/dev/null)\" = \"${day}\" ] && exit 0\nmkdir -p \"${cache_dir}\" 2>/dev/null || exit 0\nprintf '%s' \"${day}\" > \"${marker}\" 2>/dev/null || exit 0 # write first so an offline run does not retry all day\nrm -f \"${cache_dir}\"/update-checked-2* 2>/dev/null # sweep legacy per-day markers\n\ncommand -v curl >/dev/null 2>&1 || exit 0\nif [ -n \"${KAMI_UPDATE_URL:-}\" ]; then\n remote_ver=\"$(curl -fsSL --max-time 3 \"${KAMI_UPDATE_URL}\" 2>/dev/null | tr -d '[:space:]')\"\nelse\n release_url=\"$(curl -fsSL --max-time 3 -o /dev/null -w '%{url_effective}' \\\n \"${LATEST_RELEASE_URL}\" 2>/dev/null)\"\n release_tag=\"${release_url##*/}\"\n remote_ver=\"${release_tag#V}\"\nfi\n[ -n \"${remote_ver}\" ] || exit 0\n[[ \"${remote_ver}\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]] || exit 0\n[ \"${remote_ver}\" = \"${local_ver}\" ] && exit 0\n\n# Only notify when the remote version sorts strictly higher. Numeric-field\n# sort instead of `sort -V`: on a sort without -V support the old pipeline\n# yielded an empty string and silently never notified again.\nhighest=\"$(printf '%s\\n%s\\n' \"${local_ver}\" \"${remote_ver}\" | sort -t. -k1,1n -k2,2n -k3,3n 2>/dev/null | tail -1)\"\n[ -n \"${highest}\" ] || exit 0\n[ \"${highest}\" = \"${remote_ver}\" ] || exit 0\n\necho \"Kami ${remote_ver} is available (you have ${local_ver}). Update: ${UPDATE_CMD}\"\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":2894,"content_sha256":"4b842deeed159645721a9c8d976705829208d83968e23a1ca86fb89596f8efc1"},{"filename":"scripts/checks.py","content":"\"\"\"PDF-side and content-shape checks for kami documents.\n\nSplits out from build.py:\n - check_placeholders: scan filled HTML for unreplaced `{{...}}` tokens.\n - check_orphans: scan rendered PDFs for short trailing lines (typographic orphans).\n - check_density: scan rendered PDFs for pages with too much trailing whitespace.\n - check_resume_balance: scan resume PDFs for exact two-page balance.\n - check_rhythm: scan slides Python source for monotonous deck sequences.\n\nDensity scanning uses a parchment-aware pixel sweep. The hot path is\nvectorized with NumPy when available and falls back to a pure-Python loop.\nThresholds and DPI live in `references/checks_thresholds.json`.\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nfrom html_visibility import visible_html_text\nfrom optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader\nfrom shared import (\n PARCHMENT_RGB,\n TEMPLATES,\n default_example_pdfs,\n load_checks_thresholds,\n pptx_targets,\n rel_to_root,\n resolve_input,\n)\n\nPLACEHOLDER = re.compile(r\"\\{\\{[^}]+\\}\\}\")\n# The author meta tag may keep its placeholder: render.py stamps the PDF /Author\n# from git config or KAMI_AUTHOR exactly when it is still unfilled (SKILL.md\n# metadata table), so leaving it is the documented path, not a miss.\nAUTHOR_META_PLACEHOLDER = re.compile(\n r\"\"\"\u003cmeta\\s+name=[\"']author[\"']\\s+content=[\"']\\{\\{[^}]+\\}\\}[\"']\\s*/?>\"\"\", re.I)\nMARKDOWN_THEMATIC_BREAK = re.compile(r\"^\\s*[-*_]{3,}\\s*$\")\nMARKDOWN_RESIDUE_MARKERS = (\n (\"markdown thematic break\", MARKDOWN_THEMATIC_BREAK),\n (\"unconverted bold marker\", re.compile(r\"\\*\\*\")),\n (\"unconverted inline-code marker\", re.compile(r\"`\")),\n # Em dash is banned in every deliverable (writing.md, anti-patterns #28).\n # It keeps leaking past the prose-level rule, so it is a machine gate here.\n (\"em dash (banned punctuation)\", re.compile(\"\\u2014\")),\n)\n\n# Parchment background RGB for pixel comparison (sourced from shared.PARCHMENT_RGB).\n_BG_R, _BG_G, _BG_B = PARCHMENT_RGB\n_BG_TOLERANCE = 10\n\n\ndef check_placeholders(paths: list[str]) -> int:\n if not paths:\n print(\"ERROR: provide at least one HTML file to scan\")\n return 2\n\n failures = 0\n for raw in paths:\n path = resolve_input(raw)\n if not path.exists():\n print(f\"ERROR: {raw}: file not found\")\n failures += 1\n continue\n text = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n hits = list(dict.fromkeys(PLACEHOLDER.findall(AUTHOR_META_PLACEHOLDER.sub(\"\", text))))\n rel = rel_to_root(path)\n if hits:\n print(f\"ERROR: {rel}: unfilled placeholder(s): {', '.join(hits)}\")\n failures += 1\n else:\n print(f\"OK: {rel}: no placeholders\")\n\n return 0 if failures == 0 else 1\n\n\n# ---------- markdown residue check ----------\n\ndef _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]:\n issues: list[str] = []\n for line_no, line in enumerate(text.splitlines(), start=1):\n for label, pattern in MARKDOWN_RESIDUE_MARKERS:\n if pattern.search(line):\n where = f\"p{page}\" if page is not None else f\"line {line_no}\"\n sample = \" \".join(line.strip().split())[:80]\n issues.append(f\"{where}: {label}: {sample!r}\")\n return issues\n\n\ndef _markdown_text_chunks(path: Path) -> tuple[list[tuple[int | None, str]], str | None]:\n suffix = path.suffix.lower()\n if suffix == \".pdf\":\n try:\n PdfReader = require_pypdf_reader()\n except MissingDepError as exc:\n return [], str(exc)\n try:\n reader = PdfReader(str(path))\n except Exception as exc:\n return [], f\"could not read PDF text: {exc}\"\n if not reader.pages:\n return [], \"PDF has no pages\"\n\n def page_text(page) -> str:\n parts: list[str] = []\n\n def visit_text(text, _cm, _tm, font_dict, _font_size) -> None:\n base_font = str((font_dict or {}).get(\"/BaseFont\", \"\")).casefold()\n if any(\n marker in base_font\n for marker in (\"mono\", \"courier\", \"consolas\", \"menlo\")\n ):\n return\n parts.append(text)\n\n page.extract_text(visitor_text=visit_text)\n return \"\".join(parts)\n\n return [\n (index, page_text(page))\n for index, page in enumerate(reader.pages, start=1)\n ], None\n\n raw = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n if suffix in {\".html\", \".htm\"}:\n return [(None, visible_html_text(raw, residue_mode=True))], None\n return [(None, raw)], None\n\n\ndef check_markdown_residue(paths: list[str]) -> int:\n \"\"\"Scan filled HTML/PDF outputs for visible raw Markdown markers.\n\n This catches common hand-conversion misses such as literal `---`, `**bold**`,\n and inline-code backticks leaking into the final PDF.\n \"\"\"\n if not paths:\n paths = default_example_pdfs()\n if not paths:\n print(\"ERROR: no files to scan\")\n return 2\n\n failures = 0\n scanned = 0\n for raw in paths:\n path = resolve_input(raw)\n rel = rel_to_root(path)\n if not path.exists():\n print(f\"ERROR: {raw}: file not found\")\n failures += 1\n continue\n\n chunks, error = _markdown_text_chunks(path)\n if error:\n print(f\"ERROR: {rel}: {error}\")\n failures += 1\n continue\n\n scanned += 1\n issues: list[str] = []\n for page, text in chunks:\n issues.extend(_markdown_residue_issues(text, page=page))\n if issues:\n failures += 1\n print(f\"ERROR: {rel}: markdown residue found\")\n for issue in issues:\n print(f\" {issue}\")\n else:\n print(f\"OK: {rel}: no markdown residue\")\n\n if scanned == 0:\n print(\"ERROR: no files scanned\")\n return 2\n return 0 if failures == 0 else 1\n\n\n# ---------- orphan check ----------\n\ndef _orphan_last_line(text: str, max_words: int, max_chars: int) -> str | None:\n \"\"\"Return a block's last line if it is an orphan, else None.\n\n A block orphans when it has 2+ lines and the trailing line is short by\n both word count (\u003c= max_words) and length (\u003c max_chars). Pure so the\n predicate is unit-testable without a rendered PDF.\n \"\"\"\n lines = text.strip().splitlines()\n if len(lines) \u003c 2:\n return None\n last = lines[-1].strip()\n if len(last.split()) \u003c= max_words and len(last) \u003c max_chars:\n return last\n return None\n\n\ndef check_orphans(paths: list[str]) -> int:\n \"\"\"Scan PDF for text blocks whose last line has \u003c= max_words and \u003c max_chars.\"\"\"\n try:\n fitz = require_pymupdf()\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n\n if not paths:\n paths = default_example_pdfs()\n if not paths:\n print(\"ERROR: no PDF files to scan\")\n return 2\n\n orphan_cfg = load_checks_thresholds()[\"orphan\"]\n max_words = int(orphan_cfg[\"max_words\"])\n max_chars = int(orphan_cfg[\"max_chars\"])\n\n total = 0\n missing = 0\n scanned = 0\n for raw in paths:\n path = resolve_input(raw)\n if not path.exists():\n print(f\"ERROR: {raw}: not found\")\n missing += 1\n continue\n try:\n doc = fitz.open(str(path))\n except Exception as exc:\n print(f\"ERROR: {raw}: could not read PDF: {exc}\")\n missing += 1\n continue\n if len(doc) == 0:\n print(f\"ERROR: {raw}: PDF has no pages\")\n doc.close()\n missing += 1\n continue\n scanned += 1\n rel = rel_to_root(path)\n for page_num in range(len(doc)):\n page = doc[page_num]\n blocks = page.get_text(\"blocks\")\n for bx0, by0, bx1, by1, text, block_no, block_type in blocks:\n if block_type != 0: # text blocks only\n continue\n last = _orphan_last_line(text, max_words, max_chars)\n if last is not None:\n total += 1\n print(f\" {rel} p{page_num + 1}: orphan: \\\"{last}\\\" ({len(last.split())} word(s), {len(last)} chars)\")\n doc.close()\n\n if scanned == 0:\n print(f\"ERROR: no PDFs scanned ({missing} missing)\")\n return 2\n\n if total == 0 and missing == 0:\n print(f\"OK: no orphans found across {scanned} PDF(s)\")\n return 0\n\n if total:\n print(f\"\\n{total} orphan(s) found across {scanned} PDF(s)\")\n if missing:\n print(f\"{missing} input(s) missing\")\n return 1\n\n\n# ---------- density check ----------\n\ndef _last_content_y(samples: bytes, w: int, h: int, stride: int, n: int) -> int:\n \"\"\"Return the highest y row index that contains non-parchment content.\n\n Uses numpy when available (vectorized scan, ~50-100x faster on multi-page\n PDFs); falls back to a pure Python loop otherwise. Both paths sample every\n fourth column for parity, so the result is identical.\n \"\"\"\n try:\n import numpy as np\n except ImportError:\n last_y = 0\n for y in range(h - 1, -1, -1):\n row_start = y * stride\n is_bg = True\n for x in range(0, w, 4):\n offset = row_start + x * n\n if (abs(samples[offset] - _BG_R) > _BG_TOLERANCE\n or abs(samples[offset + 1] - _BG_G) > _BG_TOLERANCE\n or abs(samples[offset + 2] - _BG_B) > _BG_TOLERANCE):\n is_bg = False\n break\n if not is_bg:\n last_y = y\n break\n return last_y\n\n arr = np.frombuffer(samples, dtype=np.uint8).reshape((h, stride))\n pixels = arr[:, : w * n].reshape((h, w, n))\n rgb = pixels[:, ::4, :3].astype(np.int16)\n bg = np.array([_BG_R, _BG_G, _BG_B], dtype=np.int16)\n row_is_bg = (np.abs(rgb - bg).max(axis=2) \u003c= _BG_TOLERANCE).all(axis=1)\n non_bg = np.where(~row_is_bg)[0]\n return int(non_bg[-1]) if non_bg.size else 0\n\n\ndef _density_bucket(empty: float, warn_pct: float, sparse_pct: float) -> str:\n \"\"\"Categorize a page by its trailing-whitespace fraction.\n\n Pure so `scan_density` and its tests share one decision. A test that\n reimplements these comparisons would stay green if the real operators\n drifted (`>` to `>=`, or warn/sparse swapped); calling this keeps the\n assertion anchored to production logic.\n \"\"\"\n if empty > sparse_pct:\n return \"SPARSE\"\n if empty > warn_pct:\n return \"WARN\"\n return \"OK\"\n\n\ndef scan_density(paths: list[str], scan_single_page: bool = False) -> tuple[int, int, int, int] | None:\n \"\"\"Scan PDFs and print SPARSE/WARN lines.\n\n Returns (sparse, warn, missing, scanned), or None if PyMuPDF is missing.\n Thresholds (warn_pct, sparse_pct, dpi) come from\n references/checks_thresholds.json. Public: verify.py runs the same scan\n as its advisory pass.\n\n The last page of a multi-page PDF is judged only against `last_page_pct`.\n\n Page 1 is skipped as a cover exemption, which left a one-page document with\n no scanned page at all: the single-page templates (one-pager, letter) were\n silently exempt from the only layout gate that reads a rendered page. When\n `scan_single_page` is set, a one-page PDF has that page scanned instead.\n It stays opt-in because the no-arg repo sweep reads example PDFs built from\n unfilled placeholder skeletons, which are sparse by construction and would\n turn the sweep permanently red without describing a real defect.\n \"\"\"\n try:\n fitz = require_pymupdf()\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return None\n\n density_cfg = load_checks_thresholds()[\"density\"]\n warn_pct = float(density_cfg[\"warn_pct\"])\n sparse_pct = float(density_cfg[\"sparse_pct\"])\n # The last page of a multi-page document may close short (writing.md «Page\n # density»: the last body page runs 40-60% full), so it is\n # only reported once it passes this looser ceiling.\n last_page_pct = float(density_cfg.get(\"last_page_pct\", 0.60))\n dpi = int(density_cfg[\"dpi\"])\n\n sparse = 0\n warn = 0\n missing = 0\n scanned = 0\n for raw in paths:\n path = resolve_input(raw)\n if not path.exists():\n print(f\"ERROR: {raw}: not found\")\n missing += 1\n continue\n try:\n doc = fitz.open(str(path))\n except Exception as exc:\n print(f\"ERROR: {raw}: could not read PDF: {exc}\")\n missing += 1\n continue\n if len(doc) == 0:\n print(f\"ERROR: {raw}: PDF has no pages\")\n doc.close()\n missing += 1\n continue\n scanned += 1\n rel = rel_to_root(path)\n single_page = len(doc) == 1\n for page_num in range(len(doc)):\n if page_num == 0 and not (single_page and scan_single_page):\n continue\n page = doc[page_num]\n pix = page.get_pixmap(dpi=dpi)\n w, h = pix.width, pix.height\n if h == 0:\n continue\n last_content_y = _last_content_y(pix.samples, w, h, pix.stride, pix.n)\n\n empty = (h - last_content_y) / h\n if not single_page and page_num == len(doc) - 1:\n bucket = \"SPARSE\" if empty > last_page_pct else \"OK\"\n else:\n bucket = _density_bucket(empty, warn_pct, sparse_pct)\n if bucket == \"SPARSE\":\n print(f\" SPARSE: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace\")\n sparse += 1\n elif bucket == \"WARN\":\n print(f\" WARN: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace\")\n warn += 1\n doc.close()\n return sparse, warn, missing, scanned\n\n\ndef check_density(paths: list[str]) -> int:\n \"\"\"Scan PDF pages for sparse content (large trailing whitespace from\n break-inside:avoid pushing content to the next page).\"\"\"\n explicit = bool(paths)\n if not paths:\n paths = default_example_pdfs()\n if not paths:\n print(\"ERROR: no PDF files to scan\")\n return 2\n\n result = scan_density(paths, scan_single_page=explicit)\n if result is None:\n return 2\n sparse, warn, missing, scanned = result\n\n if scanned == 0:\n print(f\"ERROR: no PDFs scanned ({missing} missing)\")\n return 2\n\n total = sparse + warn\n if total == 0 and missing == 0:\n print(f\"OK: no density issues across {scanned} PDF(s)\")\n return 0\n\n if total:\n print(f\"\\n{total} density warning(s) across {scanned} PDF(s)\")\n if missing:\n print(f\"{missing} input(s) missing\")\n return 1\n\n\n# ---------- resume balance check ----------\n\ndef _resume_balance_issues(\n fills: list[float],\n page_count: int,\n min_fill: float,\n max_fill: float,\n max_gap: float,\n) -> list[str]:\n \"\"\"Return human-readable resume balance failures for unit tests and CLI.\"\"\"\n issues: list[str] = []\n if page_count != 2:\n issues.append(f\"{page_count} pages (expected 2)\")\n\n for index, fill in enumerate(fills[:2], start=1):\n if fill \u003c min_fill:\n issues.append(f\"p{index} fill {fill:.0%} below {min_fill:.0%}\")\n elif fill > max_fill:\n issues.append(f\"p{index} fill {fill:.0%} above {max_fill:.0%}\")\n\n if len(fills) >= 2:\n gap = abs(fills[0] - fills[1])\n if gap > max_gap:\n issues.append(f\"page fill gap {gap:.0%} above {max_gap:.0%}\")\n\n return issues\n\n\ndef resume_thin_source(path: Path) -> str | None:\n \"\"\"Explain a balance shortfall no layout can close, else None.\n\n Two pages that each stay under the ceiling but together hold less than two\n floors' worth of ink are short of material, not badly laid out: moving a\n break or loosening type only trades one sparse page for another. The\n delivery gate reports that as a data gap instead of a layout error.\n \"\"\"\n cfg = load_checks_thresholds()[\"resume_balance\"]\n min_fill, max_fill = float(cfg[\"min_fill_pct\"]), float(cfg[\"max_fill_pct\"])\n result = _resume_page_fills(path, int(cfg[\"dpi\"]))\n if result is None:\n return None\n fills, page_count = result\n if page_count != 2 or len(fills) \u003c 2 or any(f > max_fill for f in fills[:2]):\n return None\n total = fills[0] + fills[1]\n if total >= 2 * min_fill:\n return None\n return (f\"{fills[0]:.0%} / {fills[1]:.0%}, {total:.0%} in total against the \"\n f\"{2 * min_fill:.0%} two balanced pages need: the source is too thin, so list the \"\n \"missing material as [DATA NEEDED] gaps (resume-writing.md «Thin source»)\")\n\n\ndef _resume_page_fills(path: Path, dpi: int) -> tuple[list[float], int] | None:\n try:\n fitz = require_pymupdf()\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return None\n\n try:\n doc = fitz.open(str(path))\n except Exception as exc:\n print(f\"ERROR: {path}: could not read PDF: {exc}\")\n return None\n fills: list[float] = []\n for page in doc:\n pix = page.get_pixmap(dpi=dpi)\n if pix.height == 0:\n fills.append(0.0)\n continue\n last_content_y = _last_content_y(pix.samples, pix.width, pix.height, pix.stride, pix.n)\n fills.append((last_content_y + 1) / pix.height)\n page_count = len(doc)\n doc.close()\n return fills, page_count\n\n\ndef check_resume_balance(paths: list[str]) -> int:\n \"\"\"Require resume PDFs to be exactly 2 pages with balanced content fill.\"\"\"\n if not paths:\n print(\"ERROR: provide at least one filled resume PDF to scan\")\n return 2\n\n # Resolve the dependency once up front so a missing PyMuPDF stays a\n # tooling error (exit 2) while a single unreadable PDF inside the loop\n # tallies as missing and lets the remaining files still get scanned.\n try:\n require_pymupdf()\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n\n cfg = load_checks_thresholds()[\"resume_balance\"]\n min_fill = float(cfg[\"min_fill_pct\"])\n max_fill = float(cfg[\"max_fill_pct\"])\n max_gap = float(cfg[\"max_gap_pct\"])\n dpi = int(cfg[\"dpi\"])\n\n failures = 0\n missing = 0\n scanned = 0\n for raw in paths:\n path = resolve_input(raw)\n if not path.exists():\n print(f\"ERROR: {raw}: not found\")\n missing += 1\n continue\n\n result = _resume_page_fills(path, dpi)\n if result is None:\n missing += 1\n continue\n\n fills, page_count = result\n scanned += 1\n rel = rel_to_root(path)\n fill_text = \" / \".join(f\"{fill:.0%}\" for fill in fills)\n issues = _resume_balance_issues(fills, page_count, min_fill, max_fill, max_gap)\n if issues:\n failures += 1\n print(f\"ERROR: {rel}: {fill_text} ({'; '.join(issues)})\")\n else:\n gap = abs(fills[0] - fills[1])\n print(f\"OK: {rel}: 2 pages, fill {fill_text}, gap {gap:.0%}\")\n\n if scanned == 0:\n print(f\"ERROR: no PDFs scanned ({missing} missing)\")\n return 2\n if missing:\n print(f\"{missing} input(s) missing\")\n return 0 if failures == 0 and missing == 0 else 1\n\n\n# ---------- rhythm check ----------\n\n# Layout functions that count as \"divider\" slides (break monotony).\n_DIVIDER_FUNCS = {\"chapter_slide\"}\n# Layout functions that count as \"density variation\" slides.\n_DENSITY_VARIATION_FUNCS = {\"quote_slide\", \"metrics_slide\"}\n# Layout function call pattern in slides.py source.\n_SLIDE_CALL = re.compile(r\"^\\s*(\\w+_slide)\\s*\\(\")\n\n\ndef _parse_slide_sequence(src: Path) -> list[str]:\n \"\"\"Return the ordered list of slide-function names called in main().\"\"\"\n text = src.read_text(encoding=\"utf-8\", errors=\"replace\")\n in_main = False\n sequence: list[str] = []\n for line in text.splitlines():\n if re.match(r\"^def main\\s*\\(\", line):\n in_main = True\n continue\n if in_main and re.match(r\"^def \\w\", line):\n break\n if in_main:\n m = _SLIDE_CALL.match(line)\n if m:\n sequence.append(m.group(1))\n return sequence\n\n\ndef _rhythm_issues(seq: list[str], max_content_run: int, divider_min_deck_size: int) -> list[str]:\n \"\"\"Return the rhythm warnings for one parsed slide sequence.\n\n Pure so the three monotony rules are unit-testable without rendering a\n deck, matching the `_resume_balance_issues` seam.\n \"\"\"\n issues: list[str] = []\n\n # Rule 1: no run of more than `max_content_run` consecutive content_slides.\n run = 0\n max_run = 0\n for fn in seq:\n if fn == \"content_slide\":\n run += 1\n max_run = max(max_run, run)\n else:\n run = 0\n if max_run > max_content_run:\n issues.append(f\"longest content_slide run is {max_run} (limit {max_content_run})\")\n\n # Rule 2: large decks need at least one chapter_slide divider.\n if len(seq) >= divider_min_deck_size and not any(fn in _DIVIDER_FUNCS for fn in seq):\n issues.append(f\"{len(seq)} slides with no chapter_slide divider\")\n\n # Rule 3: deck must contain at least one density-variation slide.\n if not any(fn in _DENSITY_VARIATION_FUNCS for fn in seq):\n issues.append(\"no quote_slide or metrics_slide for density variation\")\n\n return issues\n\n\ndef check_rhythm(targets: list[str]) -> int:\n \"\"\"Scan slide templates for monotony: too many consecutive content_slides,\n missing dividers, and missing density variation.\n\n Targets come from the shared PPTX registry; thresholds from\n references/checks_thresholds.json.\n \"\"\"\n slide_sources = pptx_targets()\n names = targets if targets else list(slide_sources)\n failures = 0\n rhythm_cfg = load_checks_thresholds()[\"rhythm\"]\n max_content_run = int(rhythm_cfg[\"max_content_run\"])\n divider_min_deck_size = int(rhythm_cfg[\"divider_min_deck_size\"])\n\n for name in names:\n source = slide_sources.get(name)\n if source is None:\n print(f\"ERROR: {name}: not a known slides target\")\n failures += 1\n continue\n src = TEMPLATES / source\n if not src.exists():\n print(f\"ERROR: {name}: source not found ({src})\")\n failures += 1\n continue\n\n seq = _parse_slide_sequence(src)\n if not seq:\n print(f\"ERROR: {name}: no slide calls found in main() (deck unparseable)\")\n failures += 1\n continue\n\n issues = _rhythm_issues(seq, max_content_run, divider_min_deck_size)\n\n if issues:\n # These fail the run (exit 1), so label them ERROR; WARN is\n # reserved for advisory output that does not gate the build.\n for issue in issues:\n print(f\"ERROR: {name}: {issue}\")\n failures += 1\n else:\n content_run = 0\n max_run = 0\n for fn in seq:\n content_run = content_run + 1 if fn == \"content_slide\" else 0\n max_run = max(max_run, content_run)\n print(f\"OK: {name}: rhythm ok ({len(seq)} slides, max run {max_run})\")\n\n return 0 if failures == 0 else 1\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23491,"content_sha256":"9b9afa4f81211beac2208283667f82848b895df5bb5552e88a4683fec8f8f55a"},{"filename":"scripts/content.py","content":"\"\"\"Content IR checks: schema validation and content-to-HTML coverage.\n\nThe content IR is a JSON file the agent writes before filling a template:\n\n {\"type\": \"resume\", \"lang\": \"cn\", \"brief\": {...}, \"content\": {...}}\n\n`type` selects a contract from `references/schemas/\u003ctype>.json` (a lean JSON\nSchema subset). Validation happens before layout, so structural defects\n(too few metric cards, missing impact rows, an over-long tagline) are caught\nas data problems instead of surfacing later as sparse or overflowing pages.\n\nThe coverage check runs after filling: every short atomic value from the\ncontent file must appear in the filled HTML's visible text, which catches\nsilently dropped facts. Long prose fields are exempt because filling is\neditorial, not verbatim.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport posixpath\nimport re\nfrom pathlib import Path\nfrom urllib.parse import unquote, urljoin, urlsplit\n\nfrom html_visibility import (\n _HtmlVisibilityParser,\n _css_hidden_filters,\n _document_custom_properties,\n visible_html_evidence,\n)\nfrom shared import (\n HTML_TEMPLATES,\n MARP_TEMPLATES,\n PPTX_TEMPLATES,\n SCHEMAS_DIR,\n SCREEN_TEMPLATES,\n content_schema_types,\n rel_to_root,\n resolve_input,\n)\n\n# Strings longer than this are treated as prose the agent may rephrase while\n# filling; only shorter atomic values (names, metrics, dates) must survive\n# verbatim into the rendered document.\nCOVERAGE_MAX_LEN = 80\nMAX_COVERAGE_VALUES = 5000\nMAX_COVERAGE_ISSUES = 200\n\n_CJK = re.compile(r\"[\\u3000-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff\\uac00-\\ud7af]\")\n_LANG_TAG = re.compile(r\"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*\")\n_ENVELOPE_FIELDS = {\"type\", \"lang\", \"brief\", \"content\"}\n\n_TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {\n \"object\": dict,\n \"array\": list,\n \"string\": str,\n \"number\": (int, float),\n \"integer\": int,\n \"boolean\": bool,\n}\n\nBRIEF_SCHEMA = {\n \"type\": \"object\",\n \"required\": [\"audience\", \"job\", \"template\", \"formats\", \"acceptance_checks\"],\n \"additionalProperties\": False,\n \"properties\": {\n \"audience\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n \"job\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n \"template\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 80},\n \"formats\": {\n \"type\": \"array\", \"minItems\": 1, \"maxItems\": 4,\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\"html\", \"md\", \"pdf\", \"pptx\", \"png\"],\n },\n },\n \"page_target\": {\"type\": \"integer\", \"minimum\": 1, \"maximum\": 200},\n \"length_target\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 120},\n \"narrative\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 800},\n \"required_facts\": {\n \"type\": \"array\", \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n },\n \"required_assets\": {\n \"type\": \"array\", \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 500},\n },\n \"acceptance_checks\": {\n \"type\": \"array\", \"minItems\": 1, \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n },\n \"target\": {\n \"type\": \"object\", \"additionalProperties\": False,\n \"properties\": {\n \"surface\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 120},\n \"page\": {\"type\": \"integer\", \"minimum\": 1, \"maximum\": 200},\n \"viewport\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 80},\n \"state\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 120},\n \"element\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 160},\n },\n },\n \"preserve\": {\n \"type\": \"array\", \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n },\n \"evidence\": {\n \"type\": \"array\", \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 500},\n },\n \"explicit_deviations\": {\n \"type\": \"array\", \"maxItems\": 100,\n \"items\": {\"type\": \"string\", \"minLength\": 1, \"maxLength\": 240},\n },\n },\n}\n\n\nclass _HtmlAttributeParser(_HtmlVisibilityParser):\n \"\"\"Collect resource-bearing HTML attributes for asset coverage checks.\"\"\"\n\n _RESOURCE_ATTRS = {\n \"audio\": {\"src\"},\n \"image\": {\"href\", \"xlink:href\"},\n \"img\": {\"src\", \"srcset\"},\n \"source\": {\"src\", \"srcset\"},\n \"use\": {\"href\", \"xlink:href\"},\n \"video\": {\"poster\", \"src\"},\n }\n _SKIP_TAGS = {\n \"clippath\", \"defs\", \"head\", \"mask\", \"metadata\", \"noembed\", \"noframes\",\n \"noscript\", \"pattern\", \"rp\", \"script\", \"style\", \"symbol\", \"template\",\n }\n\n def __init__(\n self,\n hidden_classes: set[str],\n hidden_ids: set[str],\n hidden_tags: set[str],\n hidden_attrs: set[tuple[str, str | None]],\n ambiguous_classes: set[str],\n ambiguous_ids: set[str],\n ambiguous_tags: set[str],\n ambiguous_attrs: set[tuple[str, str | None]],\n visibility_ambiguous: bool,\n *,\n custom_properties: dict[str, str] | None = None,\n ) -> None:\n super().__init__(\n hidden_classes,\n hidden_ids,\n hidden_tags,\n hidden_attrs,\n ambiguous_classes,\n ambiguous_ids,\n ambiguous_tags,\n ambiguous_attrs,\n visibility_ambiguous,\n skip_tags=self._SKIP_TAGS,\n fail_closed=True,\n custom_properties=custom_properties,\n )\n self.values: set[str] = set()\n self.base_href: str | None = None\n self.ambiguous_resources = False\n self._picture_depth = 0\n\n def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:\n tag = tag.lower()\n if tag == \"picture\":\n self._picture_depth += 1\n if tag == \"base\" and self.base_href is None:\n self.base_href = next(\n (value.strip() for name, value in attrs\n if name.lower() == \"href\" and value and value.strip()),\n None,\n )\n super().handle_starttag(tag, attrs)\n\n def handle_endtag(self, tag: str) -> None:\n super().handle_endtag(tag)\n if tag.lower() == \"picture\" and self._picture_depth:\n self._picture_depth -= 1\n\n def _handle_visible_starttag(\n self, tag: str, attrs: list[tuple[str, str | None]],\n ) -> None:\n attrs_map = {name.lower(): (value or \"\") for name, value in attrs}\n if tag in {\"picture\", \"source\"} or attrs_map.get(\"srcset\"):\n self.ambiguous_resources = True\n if self._picture_depth or tag == \"source\":\n return\n if tag == \"img\" and attrs_map.get(\"srcset\"):\n candidates = [\n part.strip().split()\n for part in attrs_map[\"srcset\"].split(\",\")\n if part.strip()\n ]\n if len(candidates) == 1 and len(candidates[0]) == 1:\n self.values.add(candidates[0][0])\n return\n allowed = self._RESOURCE_ATTRS.get(tag, set())\n for name, value in attrs:\n if name.lower() not in allowed or not value:\n continue\n self.values.add(value.strip())\n\n def _handle_ambiguous_starttag(\n self,\n tag: str,\n attrs: list[tuple[str, str | None]],\n ) -> None:\n attrs_map = {name.lower(): (value or \"\") for name, value in attrs}\n allowed = self._RESOURCE_ATTRS.get(tag, set())\n if any(name in attrs_map and attrs_map[name] for name in allowed):\n self._visibility_ambiguous = True\n if tag in {\"picture\", \"source\"} or attrs_map.get(\"srcset\"):\n self.ambiguous_resources = True\n\n\ndef load_schema(doc_type: str) -> dict:\n path = SCHEMAS_DIR / f\"{doc_type}.json\"\n if not path.exists():\n known = \", \".join(content_schema_types()) or \"none\"\n raise FileNotFoundError(f\"no schema for type {doc_type!r} (known: {known})\")\n return json.loads(path.read_text(encoding=\"utf-8\"))\n\n\ndef _type_ok(value, expected: str) -> bool:\n py = _TYPE_CHECKS.get(expected)\n if py is None:\n return True\n if isinstance(value, bool) and expected in (\"number\", \"integer\"):\n return False\n return isinstance(value, py)\n\n\ndef validate_node(value, schema: dict, path: str = \"content\") -> list[str]:\n \"\"\"Validate `value` against a JSON Schema subset; return issue strings.\n\n Supported keywords: type, required, properties, additionalProperties\n (False only), items, minItems, maxItems, minLength, maxLength, minimum,\n maximum, enum.\n `$comment` and `description` carry authoring guidance and are ignored.\n \"\"\"\n issues: list[str] = []\n\n if \"enum\" in schema and value not in schema[\"enum\"]:\n allowed = \", \".join(repr(v) for v in schema[\"enum\"])\n issues.append(f\"{path}: {value!r} not in ({allowed})\")\n return issues\n\n expected = schema.get(\"type\")\n if expected and not _type_ok(value, expected):\n issues.append(f\"{path}: expected {expected}, got {type(value).__name__}\")\n return issues\n\n if isinstance(value, str):\n n = len(value.strip())\n if \"minLength\" in schema and n \u003c schema[\"minLength\"]:\n issues.append(f\"{path}: too short ({n} \u003c {schema['minLength']} chars)\")\n if \"maxLength\" in schema and n > schema[\"maxLength\"]:\n issues.append(f\"{path}: too long ({n} > {schema['maxLength']} chars)\")\n\n elif isinstance(value, (int, float)) and not isinstance(value, bool):\n if \"minimum\" in schema and value \u003c schema[\"minimum\"]:\n issues.append(f\"{path}: too small ({value} \u003c {schema['minimum']})\")\n if \"maximum\" in schema and value > schema[\"maximum\"]:\n issues.append(f\"{path}: too large ({value} > {schema['maximum']})\")\n\n elif isinstance(value, list):\n n = len(value)\n if \"minItems\" in schema and n \u003c schema[\"minItems\"]:\n issues.append(f\"{path}: too few items ({n} \u003c {schema['minItems']})\")\n if \"maxItems\" in schema and n > schema[\"maxItems\"]:\n issues.append(f\"{path}: too many items ({n} > {schema['maxItems']})\")\n item_schema = schema.get(\"items\")\n if isinstance(item_schema, dict):\n for i, item in enumerate(value):\n issues.extend(validate_node(item, item_schema, f\"{path}[{i}]\"))\n\n elif isinstance(value, dict):\n props = schema.get(\"properties\", {})\n for key in schema.get(\"required\", []):\n if key not in value:\n issues.append(f\"{path}: missing required field {key!r}\")\n if schema.get(\"additionalProperties\") is False:\n for key in value:\n if key not in props:\n issues.append(f\"{path}: unknown field {key!r}\")\n for key, sub in props.items():\n if key in value and isinstance(sub, dict):\n issues.extend(validate_node(value[key], sub, f\"{path}.{key}\"))\n\n return issues\n\n\ndef _brief_contract_issues(\n brief: dict,\n doc_type: str,\n lang: str | None = None,\n) -> list[str]:\n \"\"\"Cross-check the artifact brief against the selected document contract.\"\"\"\n issues: list[str] = []\n if \"page_target\" not in brief and \"length_target\" not in brief:\n issues.append(\"brief: requires page_target or length_target\")\n\n template_names = {\n *HTML_TEMPLATES,\n *MARP_TEMPLATES,\n *PPTX_TEMPLATES,\n *SCREEN_TEMPLATES,\n }\n template = brief.get(\"template\")\n if not isinstance(template, str):\n return issues\n\n allowed_templates = {\n name for name in template_names\n if name == doc_type or name.startswith(f\"{doc_type}-\")\n }\n allowed_templates.add(doc_type)\n if template not in allowed_templates:\n allowed = \", \".join(sorted(allowed_templates)) or doc_type\n issues.append(\n f\"brief.template: {template!r} does not match content type \"\n f\"{doc_type!r} (allowed: {allowed})\"\n )\n return issues\n\n formats = brief.get(\"formats\")\n if isinstance(formats, list):\n if template in MARP_TEMPLATES:\n supported_formats = {\"html\", \"md\", \"pdf\", \"pptx\"}\n elif doc_type == \"slides\":\n # A slide deliverable combines the WeasyPrint source/PDF with the\n # editable python-pptx fallback, even though `template` names the\n # primary authoring path.\n supported_formats = {\"html\", \"pdf\", \"pptx\", \"png\"}\n elif template in HTML_TEMPLATES:\n supported_formats = {\"html\", \"pdf\", \"png\"}\n elif template in SCREEN_TEMPLATES:\n supported_formats = {\"html\", \"png\"}\n elif template in PPTX_TEMPLATES:\n supported_formats = {\"pptx\"}\n else:\n supported_formats = set()\n unsupported = sorted(\n value for value in formats\n if isinstance(value, str) and value not in supported_formats\n )\n if unsupported:\n issues.append(\n f\"brief.formats: template {template!r} does not support \"\n f\"{', '.join(unsupported)} (allowed: \"\n f\"{', '.join(sorted(supported_formats)) or 'none'})\"\n )\n\n if isinstance(lang, str):\n lang_key = lang.casefold()\n if lang_key == \"cn\" or lang_key.startswith(\"zh-\") or lang_key == \"zh\":\n requested_family = \"cn\"\n elif lang_key == \"en\" or lang_key.startswith(\"en-\"):\n requested_family = \"en\"\n elif lang_key == \"ko\" or lang_key.startswith(\"ko-\"):\n requested_family = \"ko\"\n else:\n requested_family = None\n template_family = (\n \"en\" if template.endswith(\"-en\")\n else \"ko\" if template.endswith(\"-ko\")\n else \"cn\"\n )\n korean_pptx_fallback = (\n template == \"slides-en\"\n and requested_family == \"ko\"\n and formats == [\"pptx\"]\n )\n korean_marp_fallback = (\n template == \"slides-marp\"\n and requested_family == \"ko\"\n )\n if (\n requested_family is not None\n and requested_family != template_family\n and not korean_pptx_fallback\n and not korean_marp_fallback\n ):\n issues.append(\n f\"brief.template: {template!r} is the {template_family} variant \"\n f\"and does not match language {lang!r}\"\n )\n\n page_target = brief.get(\"page_target\")\n print_spec = HTML_TEMPLATES.get(template) or HTML_TEMPLATES.get(doc_type)\n max_pages = print_spec.build_max_pages if print_spec is not None else 0\n if (\n doc_type == \"resume\"\n and isinstance(page_target, int)\n and not isinstance(page_target, bool)\n and page_target != 2\n ):\n issues.append(\n f\"brief.page_target: resume templates require exactly 2 pages, got {page_target}\"\n )\n elif (\n isinstance(page_target, int)\n and not isinstance(page_target, bool)\n and max_pages > 0\n and page_target > max_pages\n ):\n issues.append(\n f\"brief.page_target: {page_target} exceeds template \"\n f\"{template!r} maximum {max_pages}\"\n )\n return issues\n\n\ndef _changelog_contract_issues(content: dict) -> list[str]:\n \"\"\"Enforce the per-version change envelope across the three arrays.\n\n The supported JSON Schema subset cannot express a sum across sibling\n arrays, so keep the executable 4-8 item rule next to schema validation.\n \"\"\"\n issues: list[str] = []\n versions = content.get(\"versions\")\n if not isinstance(versions, list):\n return issues\n fields = (\"breaking\", \"features\", \"fixes\")\n for index, version in enumerate(versions):\n if not isinstance(version, dict):\n continue\n if any(field in version and not isinstance(version[field], list) for field in fields):\n continue\n present = [field for field in fields if field in version]\n if not present:\n issues.append(\n f\"content.versions[{index}]: requires at least one of \"\n \"'breaking', 'features', or 'fixes'\"\n )\n continue\n total = sum(len(version[field]) for field in present)\n if total \u003c 4 or total > 8:\n issues.append(\n f\"content.versions[{index}]: expected 4-8 total change entries \"\n f\"across breaking/features/fixes, got {total}\"\n )\n return issues\n\n\ndef validate_content_file(data) -> tuple[str | None, list[str]]:\n \"\"\"Validate a parsed content IR envelope. Returns (doc_type, issues).\"\"\"\n if not isinstance(data, dict):\n return None, [\"content file must be a JSON object\"]\n issues = [\n f\"top-level: unknown field {key!r}\"\n for key in sorted(set(data) - _ENVELOPE_FIELDS)\n ]\n lang = data.get(\"lang\")\n if not isinstance(lang, str) or not _LANG_TAG.fullmatch(lang):\n issues.append(\n \"top-level 'lang' must be a language tag such as cn, en, ko, or zh-TW\"\n )\n doc_type = data.get(\"type\")\n if not isinstance(doc_type, str) or doc_type not in content_schema_types():\n known = \", \".join(content_schema_types()) or \"none\"\n issues.append(f\"top-level 'type' must be one of: {known}\")\n return None, issues\n body = data.get(\"content\")\n if not isinstance(body, dict):\n issues.append(\"top-level 'content' must be an object\")\n return doc_type, issues\n brief = data.get(\"brief\")\n if brief is not None:\n issues.extend(validate_node(brief, BRIEF_SCHEMA, \"brief\"))\n if isinstance(brief, dict):\n issues.extend(_brief_contract_issues(brief, doc_type, lang))\n issues.extend(validate_node(body, load_schema(doc_type)))\n if doc_type == \"changelog\":\n issues.extend(_changelog_contract_issues(body))\n return doc_type, issues\n\n\n# ---------- coverage: content values must survive into the filled HTML ----------\n\ndef _normalize(text: str, *, cjk: bool) -> str:\n \"\"\"Collapse whitespace; for CJK values drop it entirely.\n\n Filling may legitimately insert spaces between CJK and Latin runs, so\n CJK needles compare whitespace-free.\n \"\"\"\n if cjk:\n return re.sub(r\"\\s+\", \"\", text)\n return \" \".join(text.split())\n\n\ndef _contains_atomic(haystack: str, needle: str) -> bool:\n \"\"\"Match an atomic value without accepting it inside a larger token.\n\n ASCII letters and digits get explicit boundaries, so `62%` cannot pass\n against `162%` and `Ada` cannot pass against `Adams`. CJK edges stay\n unbounded because adjacent Han characters are normal sentence flow.\n \"\"\"\n if not needle:\n return True\n left = r\"(?\u003c![A-Za-z0-9_])\" if re.match(r\"[A-Za-z0-9_]\", needle[0]) else \"\"\n right = r\"(?![A-Za-z0-9_])\" if re.match(r\"[A-Za-z0-9_]\", needle[-1]) else \"\"\n return re.search(left + re.escape(needle) + right, haystack) is not None\n\n\ndef html_resource_evidence(raw: str) -> tuple[set[str], bool]:\n custom_properties = _document_custom_properties(raw)\n parser = _HtmlAttributeParser(\n *_css_hidden_filters(\n raw,\n fail_closed=True,\n custom_properties=custom_properties,\n ),\n custom_properties=custom_properties,\n )\n parser.feed(raw)\n if parser._ambiguous_markup:\n return set(), True\n if parser.base_href:\n values = {urljoin(parser.base_href, value) for value in parser.values}\n else:\n values = parser.values\n return values, (\n parser._visibility_ambiguous or parser.ambiguous_resources\n )\n\n\ndef html_resource_attributes(raw: str) -> set[str]:\n return html_resource_evidence(raw)[0]\n\n\ndef _asset_present(needle: str, attributes: set[str]) -> bool:\n expected_url = urlsplit(needle)\n expected_path = posixpath.normpath(unquote(expected_url.path))\n expected_is_url = bool(expected_url.scheme or expected_url.netloc)\n expected_is_absolute = expected_path.startswith(\"/\")\n for raw in attributes:\n actual_url = urlsplit(raw)\n actual_path = posixpath.normpath(unquote(actual_url.path))\n actual_is_url = bool(actual_url.scheme or actual_url.netloc)\n if expected_is_url:\n if (\n actual_url.scheme.casefold() == expected_url.scheme.casefold()\n and actual_url.netloc.casefold() == expected_url.netloc.casefold()\n and actual_path == expected_path\n ):\n return True\n elif actual_is_url:\n continue\n elif expected_is_absolute:\n if actual_path == expected_path:\n return True\n elif (\n actual_path == expected_path\n or actual_path.endswith(f\"/{expected_path}\")\n ):\n return True\n return False\n\n\ndef _leaf_values(node, path: str):\n if isinstance(node, dict):\n for key, sub in node.items():\n yield from _leaf_values(sub, f\"{path}.{key}\")\n elif isinstance(node, list):\n for i, sub in enumerate(node):\n yield from _leaf_values(sub, f\"{path}[{i}]\")\n else:\n yield path, node\n\n\ndef coverage_issues(\n content: dict | list,\n html_text: str,\n html_attributes: set[str] | None = None,\n *,\n root_path: str = \"content\",\n force_assets: bool = False,\n) -> tuple[list[str], int, int]:\n \"\"\"Return (issues, checked, skipped) for content-to-HTML coverage.\n\n Only short atomic values are held verbatim; image paths and long prose\n are skipped (skipped counts the prose fields).\n \"\"\"\n plain = _normalize(html_text, cjk=False)\n plain_cjk = _normalize(html_text, cjk=True)\n issues: list[str] = []\n checked = skipped = 0\n\n for index, (path, value) in enumerate(_leaf_values(content, root_path)):\n if index >= MAX_COVERAGE_VALUES:\n issues.append(f\"content: too many atomic values to check (limit {MAX_COVERAGE_VALUES})\")\n break\n if len(issues) >= MAX_COVERAGE_ISSUES:\n issues.append(\n f\"content: coverage issue limit reached ({MAX_COVERAGE_ISSUES}); \"\n \"remaining values not checked\"\n )\n break\n if isinstance(value, bool) or value is None:\n continue\n needle = str(value).strip()\n if not needle:\n continue\n if isinstance(value, str):\n is_asset = force_assets or bool(\n re.search(r\"\\.image(s\\[\\d+\\])?$\", path)\n or re.search(r\"\\.(png|jpe?g|svg|webp)$\", needle, re.I)\n )\n # Asset paths are consumed by attributes, not visible text. Check\n # them before the prose-length cutoff: a long URL is still a\n # required resource, not prose that may be rephrased.\n if is_asset:\n if html_attributes is not None:\n checked += 1\n if not _asset_present(needle, html_attributes):\n issues.append(f\"{path}: asset not found in document attributes: {needle!r}\")\n continue\n if len(needle) > COVERAGE_MAX_LEN:\n skipped += 1\n continue\n checked += 1\n cjk = bool(_CJK.search(needle))\n normalized = _normalize(needle, cjk=cjk)\n present = _contains_atomic(plain_cjk if cjk else plain, normalized)\n # Whitespace-stripped fallback: markup can split a compact value across\n # sibling nodes (\"\u003cspan>62\u003c/span>\u003cspan>%\u003c/span>\" extracts as \"62\\n%\"),\n # which whitespace collapse alone cannot rejoin. Do not apply this to\n # space-separated Latin values: `12 34` is not the same fact as `1234`.\n if not present and (cjk or not re.search(r\"\\s\", needle)):\n present = _contains_atomic(plain_cjk, _normalize(needle, cjk=True))\n if not present:\n issues.append(f\"{path}: value not found in document text: {needle!r}\")\n\n return issues, checked, skipped\n\n\ndef check_content(\n paths: list[str],\n *,\n phase_codes: dict[str, int] | None = None,\n) -> int:\n \"\"\"CLI: --check-content content.json [filled.html]\n\n Validates the content IR against its schema; with a filled HTML file,\n also verifies every short atomic value made it into the visible text.\n \"\"\"\n def finish(phase: str, code: int) -> int:\n if phase_codes is not None:\n phase_codes[phase] = code\n return code\n\n args = [p for p in paths if not p.startswith(\"-\")]\n if not args or len(args) > 2:\n known = \", \".join(content_schema_types()) or \"none\"\n print(\"ERROR: usage: --check-content content.json [filled.html]\")\n print(f\" known types: {known}\")\n return finish(\"contract\", 2)\n\n content_path = resolve_input(args[0])\n rel = rel_to_root(content_path)\n if not content_path.exists():\n print(f\"ERROR: {args[0]}: file not found\")\n return finish(\"contract\", 2)\n try:\n data = json.loads(content_path.read_text(encoding=\"utf-8\"))\n except json.JSONDecodeError as exc:\n print(f\"ERROR: {rel}: invalid JSON: {exc}\")\n return finish(\"contract\", 1)\n\n doc_type, issues = validate_content_file(data)\n if issues:\n print(f\"ERROR: {rel}: {len(issues)} schema issue(s)\")\n for issue in issues:\n print(f\" {issue}\")\n return finish(\"contract\", 1)\n print(f\"OK: {rel}: valid {doc_type} content\")\n finish(\"contract\", 0)\n\n if len(args) == 1:\n return 0\n\n html_path = resolve_input(args[1])\n html_rel = rel_to_root(html_path)\n if not html_path.exists():\n print(f\"ERROR: {args[1]}: file not found\")\n return finish(\"coverage\", 2)\n html_raw = html_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n html_text, text_ambiguous = visible_html_evidence(html_raw, fail_closed=True)\n html_attributes, resource_ambiguous = html_resource_evidence(html_raw)\n content_missing, checked, skipped = coverage_issues(\n data[\"content\"], html_text, html_attributes\n )\n text_missing = [\n issue for issue in content_missing\n if \"asset not found in document attributes\" not in issue\n ]\n asset_missing = [\n issue for issue in content_missing\n if \"asset not found in document attributes\" in issue\n ]\n required_assets = (data.get(\"brief\") or {}).get(\"required_assets\", [])\n if required_assets:\n required_asset_missing, asset_checked, _ = coverage_issues(\n required_assets,\n html_text,\n html_attributes,\n root_path=\"brief.required_assets\",\n force_assets=True,\n )\n asset_missing.extend(required_asset_missing)\n checked += asset_checked\n missing = [*text_missing, *asset_missing]\n if missing:\n definite_missing = [\n *(text_missing if not text_ambiguous else []),\n *(asset_missing if not resource_ambiguous else []),\n ]\n indeterminate = [\n *(text_missing if text_ambiguous else []),\n *(asset_missing if resource_ambiguous else []),\n ]\n if not definite_missing:\n print(\n f\"ERROR: {html_rel}: content coverage is indeterminate because \"\n \"visibility or resource selection could not be proven statically\"\n )\n for issue in missing:\n print(f\" {issue}\")\n return finish(\"coverage\", 2)\n print(\n f\"ERROR: {html_rel}: {len(definite_missing)} content value(s) \"\n \"definitely missing from document\"\n )\n for issue in definite_missing:\n print(f\" {issue}\")\n if indeterminate:\n print(\n f\" NOTE: {len(indeterminate)} additional value(s) could not be \"\n \"proven because visibility or resource selection is indeterminate\"\n )\n for issue in indeterminate:\n print(f\" {issue}\")\n return finish(\"coverage\", 1)\n note = f\" ({skipped} prose field(s) not held verbatim)\" if skipped else \"\"\n print(f\"OK: {html_rel}: all {checked} atomic content values present{note}\")\n return finish(\"coverage\", 0)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":28433,"content_sha256":"74dcb3b2438239cf09b76903f4337f6f107d66ed968f81cf9a2a9cd42ef0eb7b"},{"filename":"scripts/deliver.py","content":"\"\"\"One-shot delivery gate for a filled Kami document.\n\nThe individual checks exist so they can be debugged one at a time; a finished\ndocument needs all of them, in order, every time. Remembering that order is\nthe part an agent drops, so this module owns it:\n\n HTML placeholders, strict math (rendered in place when present),\n Markdown residue, data charts, template style, content coverage\n (with an IR)\n render the PDF next to the HTML through the shared render pipeline\n PDF page contract, resume balance, fonts, Markdown residue,\n density, orphans, page images for the perceptual pass\n\nIt ends with one verdict line. ERROR items block delivery; WARN items are\njudgment calls to look at in the page images; GAP lines are `[DATA NEEDED]`\nmarkers the closing message must list.\n\nUsage:\n python3 scripts/build.py --deliver filled.html [content.json]\n\"\"\"\nfrom __future__ import annotations\n\nimport contextlib\nimport io\nimport json\nimport re\nfrom pathlib import Path\n\nfrom chart import check_charts, find_charts\nfrom checks import (\n check_density,\n check_markdown_residue,\n check_orphans,\n check_placeholders,\n check_resume_balance,\n resume_thin_source,\n)\nfrom content import check_content\nfrom lint import check_style\nfrom math_render import MathRenderError, _latex_spans\nfrom math_render import main as math_main\nfrom optional_deps import MissingDepError\nfrom render import render_pdf\nfrom shared import HTML_TEMPLATES, SCREEN_TEMPLATES, TEMPLATES, resolve_input\nfrom verify import check_fonts\nfrom visual import REVIEW_CHECKLIST, render_pages, visual_output_dir\n\n# Content IR type names that differ from the template family name.\n_IR_TO_FAMILY = {\"slides\": \"slides-weasy\"}\n_LOCALE_SUFFIX = re.compile(r\"-(en|ko)$\")\n_STYLE_BLOCK = re.compile(r\"\u003cstyle[^>]*>(.*?)\u003c/style>\", re.S | re.I)\n_CSS_COMMENT = re.compile(r\"/\\*.*?\\*/\", re.S)\n_SELECTOR = re.compile(r\"([^{}@;]+)\\{\")\n_GAP = re.compile(r\"\\[DATA NEEDED[^\\]]*\\]\")\n_TAG = re.compile(r\"\u003c[^>]+>\")\n\n\ndef _selectors(html: str) -> set[str]:\n css = _CSS_COMMENT.sub(\"\", \" \".join(_STYLE_BLOCK.findall(html)))\n return {\" \".join(s.split()) for s in _SELECTOR.findall(css) if s.strip()}\n\n\ndef detect_template(html: str) -> tuple[str | None, float]:\n \"\"\"Return the template whose stylesheet the document still carries.\n\n Filled documents keep their template CSS untouched (SKILL.md «4 · Fill the template»), so\n selector overlap identifies the family without asking the author.\n \"\"\"\n doc = _selectors(html)\n if not doc:\n return None, 0.0\n best, best_score = None, 0.0\n sources = {name: spec.source for name, spec in HTML_TEMPLATES.items()}\n sources.update(SCREEN_TEMPLATES)\n for name, source in sources.items():\n path = TEMPLATES / source\n if not path.exists():\n continue\n ref = _selectors(path.read_text(encoding=\"utf-8\", errors=\"replace\"))\n if not ref:\n continue\n score = len(doc & ref) / len(ref)\n if score > best_score:\n best, best_score = name, score\n return (best, best_score) if best_score >= 0.5 else (None, best_score)\n\n\ndef family(name: str) -> str:\n return _LOCALE_SUFFIX.sub(\"\", _IR_TO_FAMILY.get(name, name))\n\n\nMAX_DETAIL_LINES = 5\n\n\nclass Report:\n def __init__(self, base: Path) -> None:\n self.errors = 0\n self.warnings = 0\n self.prefix = f\"{base}/\"\n self.items: list[dict] = []\n\n def line(self, status: str, label: str, detail: str = \"\") -> None:\n if status == \"ERROR\":\n self.errors += 1\n elif status == \"WARN\":\n self.warnings += 1\n self.items.append({\"status\": status, \"check\": label, \"detail\": detail})\n print(f\"{status + ':':\u003c7}{label}{' ' + detail if detail else ''}\")\n\n def check(self, label: str, fn, argv: list[str], *, warn_only: bool = False) -> int:\n buffer = io.StringIO()\n with contextlib.redirect_stdout(buffer):\n try:\n code = fn(argv)\n except Exception as exc: # a crashing gate must not cost the verdict\n print(f\"ERROR: {type(exc).__name__}: {exc}\")\n code = 2\n output = [\n ln.strip().replace(self.prefix, \"\")\n for ln in buffer.getvalue().splitlines()\n if ln.strip() and not ln.startswith(\"OK:\")\n ]\n if code == 0:\n self.line(\"OK\", label)\n else:\n self.line(\"WARN\" if warn_only and code == 1 else \"ERROR\", label)\n self.items[-1][\"findings\"] = output\n for ln in output[:MAX_DETAIL_LINES]:\n print(f\" {ln}\")\n if len(output) > MAX_DETAIL_LINES:\n print(f\" ... {len(output) - MAX_DETAIL_LINES} more; run the single check for the full list\")\n return code\n\n\ndef _page_contract(report: Report, fam: str | None, pages: int, target: int | None) -> None:\n if fam is None:\n report.line(\"WARN\", \"page contract\", f\"{pages} page(s); template not recognized, no ceiling applied\")\n return\n ceiling = HTML_TEMPLATES[fam].build_max_pages if fam in HTML_TEMPLATES else 0\n if fam == \"resume\":\n # Exactly two pages; the balance check below enforces and explains it.\n report.line(\"OK\" if pages == 2 else \"ERROR\", \"page contract\", f\"{pages} page(s), resume requires exactly 2\")\n elif ceiling and pages > ceiling:\n report.line(\"ERROR\", \"page contract\", f\"{pages} page(s), {fam} allows at most {ceiling}\")\n else:\n limit = f\"at most {ceiling}\" if ceiling else \"no ceiling\"\n report.line(\"OK\", \"page contract\", f\"{pages} page(s), {fam}: {limit}\")\n if target and pages != target and fam != \"resume\":\n report.line(\"WARN\", \"page target\", f\"brief asks for {target}, rendered {pages}\")\n\n\ndef deliver(args: list[str], result: dict | None = None) -> int:\n \"\"\"Run every gate and print one verdict; `result`, when given, receives\n the same outcome as data (the MCP server's kami_deliver reads it).\"\"\"\n if result is None:\n result = {}\n files = [a for a in args if not a.startswith(\"-\")]\n if not files or len(files) > 2 or not files[0].lower().endswith((\".html\", \".htm\")):\n print(\"ERROR: usage: --deliver filled.html [content.json]\")\n return 2\n html_path = resolve_input(files[0]).resolve()\n content_path = resolve_input(files[1]).resolve() if len(files) == 2 else None\n if not html_path.is_file():\n print(f\"ERROR: {files[0]}: file not found\")\n return 2\n if content_path is not None and not content_path.is_file():\n print(f\"ERROR: {files[1]}: file not found\")\n return 2\n\n raw = html_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n ir_type, page_target = None, None\n if content_path is not None:\n try:\n data = json.loads(content_path.read_text(encoding=\"utf-8\"))\n ir_type = data.get(\"type\") if isinstance(data, dict) else None\n ir_type = ir_type if isinstance(ir_type, str) else None\n brief = data.get(\"brief\") if isinstance(data, dict) else None\n if isinstance(brief, dict) and isinstance(brief.get(\"page_target\"), int):\n page_target = brief[\"page_target\"]\n except (json.JSONDecodeError, OSError):\n pass # check_content below reports the malformed IR\n detected, _ = detect_template(raw)\n fam = family(ir_type) if ir_type else (family(detected) if detected else None)\n screen = fam == \"landing-page\"\n\n print(f\"Kami deliver: {html_path}\")\n print(f\" template: {detected or 'not recognized'}\"\n f\"{f', content type {ir_type}' if ir_type else ''}\")\n report = Report(html_path.parent)\n\n # --- HTML side -------------------------------------------------------\n report.check(\"placeholders\", check_placeholders, [str(html_path)])\n try:\n spans, issues = _latex_spans(raw)\n except MathRenderError as exc:\n spans, issues = [], [str(exc)]\n math_failed = bool(issues)\n if issues:\n report.line(\"ERROR\", \"math\", \"; \".join(issues))\n elif spans:\n buffer = io.StringIO()\n with contextlib.redirect_stdout(buffer):\n code = math_main([\"--in-place\", str(html_path)])\n if code == 0:\n report.line(\"OK\", \"math\", f\"rendered {len(spans)} formula(s) to SVG in place\")\n else:\n math_failed = True\n report.line(\"ERROR\", \"math\", buffer.getvalue().strip()\n + \" (run bash scripts/ensure_mathjax.sh first)\")\n else:\n report.line(\"OK\", \"math\", \"no formulas\")\n report.check(\"markdown residue (html)\", check_markdown_residue, [str(html_path)])\n if find_charts(raw):\n report.check(\"data charts\", check_charts, [str(html_path)])\n # The output path rarely keeps the template file name, so hand the style\n # gate the detected family; an unrecognized document keeps the name fallback.\n report.check(\"template style\",\n lambda argv: check_style(argv, screen=screen if fam else None),\n [str(html_path)], warn_only=True)\n if content_path is not None:\n report.check(\"content coverage\", check_content, [str(content_path), str(html_path)])\n\n gaps = list(dict.fromkeys(_GAP.findall(_TAG.sub(\" \", raw))))\n\n # --- Render and PDF side ---------------------------------------------\n pages_png: list[Path] = []\n rendered_pdf: Path | None = None\n if screen:\n report.line(\"WARN\", \"screen template\",\n \"no PDF; screenshot the responsive matrix per locale (design.md «Responsive screenshot verification»)\")\n elif math_failed:\n # render_pdf would re-run MathJax on the same TeX and report the same\n # cause a second time; one cause, one ERROR.\n report.line(\"SKIP\", \"render\", \"math failed; fix it and re-run --deliver\")\n else:\n pdf = html_path.with_suffix(\".pdf\")\n try:\n pages = render_pdf(html_path, pdf)\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n except Exception as exc: # WeasyPrint and math failures both block delivery\n report.line(\"ERROR\", \"render\", str(exc))\n pages = 0\n if pages:\n rendered_pdf = pdf\n report.line(\"OK\", \"render\", f\"{pdf.name} ({pages} page(s))\")\n _page_contract(report, fam, pages, page_target)\n if fam == \"resume\":\n thin = resume_thin_source(pdf) if pages == 2 else None\n if thin:\n report.line(\"WARN\", \"resume balance\", thin)\n else:\n report.check(\"resume balance\", check_resume_balance, [str(pdf)])\n report.check(\"fonts\", check_fonts, [str(pdf)])\n report.check(\"markdown residue (pdf)\", check_markdown_residue, [str(pdf)])\n report.check(\"density\", check_density, [str(pdf)], warn_only=True)\n report.check(\"orphans\", check_orphans, [str(pdf)], warn_only=True)\n try:\n pages_png = render_pages(pdf)\n report.line(\"OK\", \"page images\", f\"{visual_output_dir(pdf).name}/ ({len(pages_png)} image(s))\")\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n except Exception as exc:\n report.line(\"ERROR\", \"page images\", str(exc))\n\n for gap in gaps:\n print(f\"GAP: {gap}\")\n result.update({\n \"html\": str(html_path),\n \"template\": detected,\n \"content_type\": ir_type,\n \"checks\": report.items,\n \"gaps\": gaps,\n \"page_images\": [str(p) for p in pages_png],\n \"review_checklist\": list(REVIEW_CHECKLIST) if pages_png else [],\n })\n if rendered_pdf is not None:\n result[\"pdf\"] = str(rendered_pdf)\n\n if report.errors:\n verdict = f\"NOT READY: {report.errors} error(s), {report.warnings} warning(s)\"\n else:\n verdict = f\"READY: 0 errors, {report.warnings} warning(s), {len(gaps)} data gap(s) to report\"\n print(verdict)\n result.update({\"ready\": not report.errors, \"verdict\": verdict})\n if pages_png:\n print(\"Next: view every page image against this checklist, fix what you see, re-run --deliver:\")\n for item in REVIEW_CHECKLIST:\n print(f\" - {item}\")\n return 1 if report.errors else 0\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12384,"content_sha256":"e3167b71cb8a35042a95295813a9144e9258abeaad40f2b3872b8887a73292ed"},{"filename":"scripts/diagram_geometry.py","content":"\"\"\"Check explicitly annotated SVG geometry, without guessing browser text bounds.\n\nThe HTML remains the only geometry source. Unmarked SVG is outside this check;\nrectangular nodes/label masks and line/polyline edges use SVG user coordinates.\n\"\"\"\nfrom __future__ import annotations\n\nfrom html.parser import HTMLParser\nimport math\nimport re\n\n\nNUMBER = re.compile(r\"[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?\")\nMARKS = (\"data-node\", \"data-edge\", \"data-label-for\")\n\n\ndef numbers(value: str) -> list[float]:\n if not isinstance(value, str):\n raise ValueError(\"geometry attribute requires a numeric value\")\n if re.sub(NUMBER, \"\", value).strip(\" ,\\t\\r\\n\"):\n raise ValueError(\"geometry must use numeric SVG user coordinates\")\n result = [float(n) for n in NUMBER.findall(value)]\n if not result or not all(math.isfinite(n) for n in result):\n raise ValueError(\"geometry must contain finite numbers\")\n return result\n\n\ndef scalar(attrs: dict, key: str) -> float:\n value = numbers(attrs.get(key, \"0\"))\n if len(value) != 1:\n raise ValueError(f\"{key} must be one number\")\n return value[0]\n\n\ndef overlaps(a, b):\n return max(a[0], b[0]) \u003c min(a[2], b[2]) and max(a[1], b[1]) \u003c min(a[3], b[3])\n\n\ndef crosses(start, end, box):\n \"\"\"Open-rectangle clipping: grazing a boundary is not crossing its content.\"\"\"\n low, high = 0.0, 1.0\n for axis in (0, 1):\n delta = end[axis] - start[axis]\n if delta == 0:\n if not box[axis] \u003c start[axis] \u003c box[axis + 2]:\n return False\n continue\n a = (box[axis] - start[axis]) / delta\n b = (box[axis + 2] - start[axis]) / delta\n low, high = max(low, min(a, b)), min(high, max(a, b))\n return low \u003c high\n\n\ndef endpoint_ok(point, other, box):\n x, y = point\n left, top, right, bottom = box\n # Up to 8 user units allows the board's intentional 4px standoff.\n sides = [(abs(x-left), top \u003c= y \u003c= bottom and x \u003c= left, (-1, 0)),\n (abs(x-right), top \u003c= y \u003c= bottom and x >= right, (1, 0)),\n (abs(y-top), left \u003c= x \u003c= right and y \u003c= top, (0, -1)),\n (abs(y-bottom), left \u003c= x \u003c= right and y >= bottom, (0, 1))]\n dx, dy = other[0]-x, other[1]-y\n # At the target, the preceding point must also lie outward from its edge.\n return any(distance \u003c= 8 and within and dx*nx + dy*ny > 0\n for distance, within, (nx, ny) in sides)\n\n\nclass DiagramParser(HTMLParser):\n def __init__(self):\n super().__init__(convert_charrefs=True)\n self.stack = []\n self.scopes = []\n self.active = []\n self.issues = []\n\n def issue(self, line, message):\n self.issues.append((line, message))\n\n def handle_starttag(self, tag, attributes):\n attrs = dict(attributes)\n if tag == \"svg\":\n scope = {\"nodes\": {}, \"edges\": {}, \"labels\": {}, \"unmarked_edges\": []}\n self.scopes.append(scope)\n self.active.append(scope)\n if not self.active:\n return\n inherited = self.stack[-1][1] if self.stack else False\n unsupported = inherited or \"transform\" in attrs or \"style\" in attrs\n self.stack.append((tag, unsupported))\n marked = [key for key in MARKS if key in attrs]\n if not marked:\n if \"data-from\" in attrs or \"data-to\" in attrs:\n self.active[-1][\"unmarked_edges\"].append(self.getpos()[0])\n return\n line = self.getpos()[0]\n try:\n if len(marked) != 1 or not attrs[marked[0]]:\n raise ValueError(\"use one nonempty semantic marker per element\")\n if unsupported:\n raise ValueError(\"marked geometry needs direct coordinates, without ancestor transform/style\")\n kind = marked[0]\n name = attrs[kind]\n collection = {\"data-node\": \"nodes\", \"data-edge\": \"edges\", \"data-label-for\": \"labels\"}[kind]\n target = self.active[-1][collection]\n if name in target:\n raise ValueError(f\"duplicate {kind} {name!r}\")\n if kind == \"data-edge\":\n if tag == \"line\":\n points = [(scalar(attrs, \"x1\"), scalar(attrs, \"y1\")),\n (scalar(attrs, \"x2\"), scalar(attrs, \"y2\"))]\n elif tag == \"polyline\":\n coords = numbers(attrs.get(\"points\", \"\"))\n if len(coords) \u003c 4 or len(coords) % 2:\n raise ValueError(\"polyline needs at least two complete points\")\n points = list(zip(coords[::2], coords[1::2]))\n else:\n raise ValueError(\"mark the line/polyline shaft, not its arrowhead or a path\")\n if any(a == b for a, b in zip(points, points[1:])):\n raise ValueError(\"edge has a zero-length segment\")\n target[name] = (line, points, attrs.get(\"data-from\"), attrs.get(\"data-to\"))\n else:\n if tag != \"rect\":\n raise ValueError(\"nodes and label masks must be rect elements\")\n x, y, w, h = (scalar(attrs, k) for k in (\"x\", \"y\", \"width\", \"height\"))\n if w \u003c= 0 or h \u003c= 0 or not all(math.isfinite(v) for v in (x+w, y+h)):\n raise ValueError(\"rectangle width and height must be positive\")\n target[name] = (line, (x, y, x+w, y+h))\n except ValueError as error:\n self.issue(line, str(error))\n\n def handle_endtag(self, tag):\n if not self.active:\n return\n # HTML void elements outside SVG never enter this stack.\n if tag == \"svg\":\n # Drop unclosed children too, so they cannot leak into a later SVG.\n while self.stack and self.stack.pop()[0] != \"svg\":\n pass\n self.active.pop()\n elif self.stack and self.stack[-1][0] == tag:\n self.stack.pop()\n\n def handle_startendtag(self, tag, attrs):\n self.handle_starttag(tag, attrs)\n self.handle_endtag(tag)\n\n def validate(self):\n for scope in self.scopes:\n nodes, edges, labels = (scope[k] for k in (\"nodes\", \"edges\", \"labels\"))\n if nodes or edges or labels:\n for line in scope[\"unmarked_edges\"]:\n self.issue(line, \"relationship endpoints require data-edge\")\n for group, kind in ((nodes, \"nodes\"), (labels, \"label masks\")):\n entries = list(group.items())\n for i, (name, (line, box)) in enumerate(entries):\n for other, (_, other_box) in entries[i+1:]:\n if overlaps(box, other_box):\n self.issue(line, f\"{kind} {name!r} and {other!r} overlap\")\n for name, (line, box) in labels.items():\n if name not in edges:\n self.issue(line, f\"label mask {name!r} has no matching edge\")\n for node, (_, node_box) in nodes.items():\n if overlaps(box, node_box):\n self.issue(line, f\"label mask {name!r} overlaps node {node!r}\")\n for name, (line, points, source, target) in edges.items():\n if source not in nodes or target not in nodes:\n self.issue(line, f\"edge {name!r} references missing node: {source!r} -> {target!r}\")\n continue\n for node, point, other in ((source, points[0], points[1]),\n (target, points[-1], points[-2])):\n if not endpoint_ok(point, other, nodes[node][1]):\n self.issue(line, f\"edge {name!r} does not attach outward to node {node!r}; move endpoint/route\")\n for node, (_, box) in nodes.items():\n if any(crosses(a, b, box) for a, b in zip(points, points[1:])):\n self.issue(line, f\"edge {name!r} crosses node {node!r}; move route or node\")\n for label, (_, box) in labels.items():\n if label != name and any(crosses(a, b, box) for a, b in zip(points, points[1:])):\n self.issue(line, f\"edge {name!r} crosses label mask {label!r}; move route or mask\")\n return self.issues\n\n\ndef scan_geometry(html: str) -> list[tuple[int, str]]:\n parser = DiagramParser()\n parser.feed(html)\n parser.close()\n return parser.validate()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8427,"content_sha256":"130adb2939c7caee0d7b8f6eceb0f5a8e9e4becd753b7c8f7584b6070a1ff85c"},{"filename":"scripts/ensure_mathjax.sh","content":"#!/usr/bin/env bash\n# Ensure strict TeX -> SVG rendering is available for Kami.\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nRUNTIME_DIR=\"$SCRIPT_DIR/mathjax-runtime\"\n\nif ! command -v node >/dev/null 2>&1; then\n echo \"ERROR: Node.js is required for strict LaTeX rendering\" >&2\n exit 1\nfi\nif ! command -v npm >/dev/null 2>&1; then\n echo \"ERROR: npm is required to install the strict LaTeX renderer\" >&2\n exit 1\nfi\nunset NODE_OPTIONS\nNODE_MAJOR=\"$(node -p 'process.versions.node.split(\".\")[0]')\"\nif (( NODE_MAJOR \u003c 20 || NODE_MAJOR == 21 )); then\n echo \"ERROR: strict LaTeX rendering requires Node.js 20 or Node.js 22+\" >&2\n exit 1\nfi\n\nif node \"$SCRIPT_DIR/mathjax_svg.js\" --probe >/dev/null 2>&1; then\n echo \"OK: locked MathJax runtime available\"\n exit 0\nfi\n\nif [[ \"$(uname -s)\" == \"Darwin\" ]]; then\n # shared.configure_weasyprint_runtime repurposes XDG_CACHE_HOME for fontconfig.\n # Keep MathJax in its own stable user cache instead.\n CACHE_HOME=\"$HOME/.cache\"\nelse\n CACHE_HOME=\"${XDG_CACHE_HOME:-$HOME/.cache}\"\nfi\nMATHJAX_VERSION=\"$(node -p 'require(process.argv[1]).dependencies[\"@mathjax/src\"]' \"$RUNTIME_DIR/package.json\")\"\nMATH_ROOT=\"$CACHE_HOME/kami/mathjax/$MATHJAX_VERSION\"\nMATH_PARENT=\"$(dirname \"$MATH_ROOT\")\"\nmkdir -p \"$MATH_PARENT\"\nLOCK_DIR=\"$MATH_PARENT/.install-$MATHJAX_VERSION.lock\"\nOWNER_FILE=\"$LOCK_DIR/pid\"\nSTAGING_FILE=\"$LOCK_DIR/staging\"\nHAVE_LOCK=0\nSTAGING_ROOT=\"\"\n\nacquire_lock() {\n if mkdir \"$LOCK_DIR\" 2>/dev/null; then\n printf '%s\\n' \"$\" > \"$OWNER_FILE\"\n HAVE_LOCK=1\n return 0\n fi\n return 1\n}\n\nreclaim_stale_lock() {\n local owner_pid stale_staging\n [[ -f \"$OWNER_FILE\" ]] || return 1\n owner_pid=\"$(sed -n '1p' \"$OWNER_FILE\" 2>/dev/null || true)\"\n if [[ \"$owner_pid\" =~ ^[0-9]+$ ]] && kill -0 \"$owner_pid\" 2>/dev/null; then\n return 1\n fi\n stale_staging=\"\"\n if [[ -f \"$STAGING_FILE\" ]]; then\n IFS= read -r stale_staging \u003c \"$STAGING_FILE\" || true\n fi\n case \"$stale_staging\" in\n \"$MATH_PARENT/.install-$MATHJAX_VERSION-\"*)\n if [[ -d \"$stale_staging\" && ! -L \"$stale_staging\" ]]; then\n rm -rf -- \"$stale_staging\"\n fi\n ;;\n esac\n rm -f -- \"$STAGING_FILE\" \"$OWNER_FILE\"\n rmdir \"$LOCK_DIR\" 2>/dev/null\n}\n\nif ! acquire_lock; then\n for ((attempt = 0; attempt \u003c 30; attempt++)); do\n if node \"$SCRIPT_DIR/mathjax_svg.js\" --probe >/dev/null 2>&1; then\n echo \"OK: another process installed the locked MathJax runtime\"\n exit 0\n fi\n if reclaim_stale_lock; then\n acquire_lock && break\n elif [[ ! -d \"$LOCK_DIR\" ]]; then\n acquire_lock && break\n fi\n sleep 1\n done\n if (( ! HAVE_LOCK )); then\n # A process killed between mkdir and writing its owner leaves an empty\n # directory. After the bounded wait, reclaim only that exact empty lock.\n if [[ ! -e \"$OWNER_FILE\" && ! -e \"$STAGING_FILE\" ]]; then\n rmdir \"$LOCK_DIR\" 2>/dev/null || true\n acquire_lock || true\n fi\n fi\n if (( ! HAVE_LOCK )); then\n echo \"ERROR: timed out waiting for the concurrent MathJax install\" >&2\n exit 1\n fi\nfi\ncleanup() {\n if [[ -n \"$STAGING_ROOT\" && -d \"$STAGING_ROOT\" ]]; then\n rm -rf -- \"$STAGING_ROOT\"\n fi\n if [[ -f \"$OWNER_FILE\" ]]; then\n local owner_pid\n owner_pid=\"$(sed -n '1p' \"$OWNER_FILE\" 2>/dev/null || true)\"\n if [[ \"$owner_pid\" == \"$\" ]]; then\n rm -f -- \"$STAGING_FILE\" \"$OWNER_FILE\"\n rmdir \"$LOCK_DIR\" 2>/dev/null || true\n fi\n fi\n}\ntrap cleanup EXIT\n\nif node \"$SCRIPT_DIR/mathjax_svg.js\" --probe >/dev/null 2>&1; then\n echo \"OK: another process installed the locked MathJax runtime\"\n exit 0\nfi\nif [[ -e \"$MATH_ROOT\" ]]; then\n echo \"ERROR: incomplete or invalid MathJax cache already exists at $MATH_ROOT\" >&2\n echo \"Move that version directory aside, then run this command again.\" >&2\n exit 1\nfi\nSTAGING_ROOT=\"$(mktemp -d \"$MATH_PARENT/.install-$MATHJAX_VERSION-XXXXXX\")\"\nprintf '%s\\n' \"$STAGING_ROOT\" > \"$STAGING_FILE\"\n\necho \"Installing locked MathJax $MATHJAX_VERSION for strict LaTeX SVG rendering\"\ncp \"$RUNTIME_DIR/package.json\" \"$RUNTIME_DIR/package-lock.json\" \"$STAGING_ROOT/\"\n(\n cd \"$STAGING_ROOT\"\n npm ci --ignore-scripts --no-audit --no-fund\n)\n\nnode - \"$STAGING_ROOT\" \"$MATHJAX_VERSION\" \u003c\u003c'NODE'\nconst root = process.argv[2];\nconst expected = process.argv[3];\nconst found = require(require.resolve(\"@mathjax/src/package.json\", { paths: [root] })).version;\nif (found !== expected) {\n throw new Error(`expected @mathjax/src ${expected}, found ${found}`);\n}\nNODE\n\nmv \"$STAGING_ROOT\" \"$MATH_ROOT\"\nrm -f -- \"$STAGING_FILE\" \"$OWNER_FILE\"\nrmdir \"$LOCK_DIR\"\nSTAGING_ROOT=\"\"\ntrap - EXIT\nnode \"$SCRIPT_DIR/mathjax_svg.js\" --probe >/dev/null\necho \"OK: locked MathJax runtime installed\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":4672,"content_sha256":"fefbe8d001bf7517a65c808abec9f58db32e1c230803c18734c292b737a85550"},{"filename":"scripts/ensure-fonts.sh","content":"#!/usr/bin/env bash\nset -euo pipefail\n\n# Portable across bash 3.2+ (macOS stock /bin/bash) and bash 4+ (Linux, Homebrew).\n# Avoids `declare -A` so the script runs on a fresh macOS without `brew install bash`.\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nSKILL_FONT_DIR=\"$(cd \"$SCRIPT_DIR/..\" && pwd)/assets/fonts\"\nREPO_FONT_DIR=\"$SKILL_FONT_DIR\"\n\n# Download target lives OUTSIDE the skill directory on purpose.\n#\n# Claude Desktop skill ZIPs exclude the large bundled fonts (TsangerJinKai TTFs,\n# Source Han Serif K OTFs). The old code downloaded them back into the skill's\n# own assets/fonts, which pushed the installed skill past Claude Desktop's size\n# limit (\"upload/execution too big\"). We instead drop them in the XDG user font\n# dir, which fontconfig scans by default on both macOS (Homebrew) and Linux, yet\n# does NOT show up in macOS Font Book. WeasyPrint then resolves \"TsangerJinKai02\"\n# / \"Source Han Serif K\" from here when the template's relative @font-face path\n# is absent; online renders still fall back to the jsDelivr URL baked alongside\n# each @font-face declaration.\nFONT_DIR=\"${KAMI_FONT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/fonts/kami}\"\n\n# Partial downloads from an interrupted run must not linger in FONT_DIR, and\n# two concurrent runs must not fight over one temp path: temp files carry this\n# run's PID and are swept on exit.\nTMP_SUFFIX=\"tmp.$\"\ncleanup_tmp() {\n rm -f \"$FONT_DIR\"/*.\"$TMP_SUFFIX\" \"$SKILL_FONT_DIR\"/*.\"$TMP_SUFFIX\" 2>/dev/null || true\n}\ntrap cleanup_tmp EXIT\n\nMIN_SIZE_CN=10000000 # 10MB for TsangerJinKai (large CJK glyph set)\nMIN_SIZE_KO=6500000 # 6.5MB for Source Han Serif K (Adobe full subset)\n\n# TsangerJinKai (CN): index N pairs CN_NAMES[N] with CN_LOCAL_NAMES[N].\nCN_NAMES=(\"仓耳今楷02-W04.ttf\" \"仓耳今楷02-W05.ttf\")\nCN_LOCAL_NAMES=(\"TsangerJinKai02-W04.ttf\" \"TsangerJinKai02-W05.ttf\")\n\n# Source Han Serif K (KO): mirror filenames match the repo filenames, so there\n# is no rename step (unlike Tsanger's Chinese-named official downloads).\nKO_NAMES=(\"SourceHanSerifKR-Regular.otf\" \"SourceHanSerifKR-Medium.otf\")\n\n# Mirror order is intentionally jsdmirror-first here, opposite of the\n# templates' @font-face fallback (which lists jsdelivr first). Reasoning:\n# this script runs interactively when fonts are missing locally, often from\n# China where jsdmirror is reachable and faster than jsdelivr; templates run\n# anywhere and prioritize jsdelivr's broader global coverage.\nMIRROR_SOURCES=(\n \"https://cdn.jsdmirror.com/gh/tw93/Kami@main/assets/fonts\"\n \"https://cdn.jsdelivr.net/gh/tw93/Kami@main/assets/fonts\"\n)\n\ncheck_size() {\n local file=\"$1\"\n local min_size=\"$2\"\n [[ -f \"$file\" ]] || return 1\n local size\n size=$(wc -c \u003c \"$file\" | tr -d ' ')\n [[ \"$size\" -ge \"$min_size\" ]]\n}\n\n# Restore missing or truncated local copies from complete repository fonts.\n# Validate before replacement so a failed copy cannot damage the existing file.\nif [ -d \"$SCRIPT_DIR/../../../assets/fonts\" ]; then\n ROOT_FONT_DIR=\"$(cd \"$SCRIPT_DIR/../../../assets/fonts\" && pwd)\"\n mkdir -p \"$SKILL_FONT_DIR\"\n for name in \"${CN_LOCAL_NAMES[@]}\" \"${KO_NAMES[@]}\"; do\n case \"$name\" in\n Tsanger*) min_size=\"$MIN_SIZE_CN\" ;;\n *) min_size=\"$MIN_SIZE_KO\" ;;\n esac\n target=\"$SKILL_FONT_DIR/$name\"\n if ! check_size \"$target\" \"$min_size\" && check_size \"$ROOT_FONT_DIR/$name\" \"$min_size\"; then\n cp \"$ROOT_FONT_DIR/$name\" \"$target.$TMP_SUFFIX\"\n check_size \"$target.$TMP_SUFFIX\" \"$min_size\"\n mv \"$target.$TMP_SUFFIX\" \"$target\"\n echo \"OK: copied $name from the repository root into $SKILL_FONT_DIR\"\n fi\n done\nfi\n\ncn_present_in() {\n local dir=\"$1\" name\n for name in \"${CN_LOCAL_NAMES[@]}\"; do\n check_size \"$dir/$name\" \"$MIN_SIZE_CN\" || return 1\n done\n return 0\n}\n\nko_present_in() {\n local dir=\"$1\" name\n for name in \"${KO_NAMES[@]}\"; do\n check_size \"$dir/$name\" \"$MIN_SIZE_KO\" || return 1\n done\n return 0\n}\n\nrefresh_fontconfig() {\n # The XDG font dir is already on fontconfig's default scan path, so a cache\n # refresh is all that is needed for WeasyPrint to pick the fonts up. Optional:\n # absence of fc-cache (e.g. minimal sandbox) is non-fatal, fontconfig rescans\n # the directory lazily on next use.\n if command -v fc-cache >/dev/null 2>&1; then\n fc-cache -f \"$FONT_DIR\" >/dev/null 2>&1 || true\n fi\n}\n\ndownload_tsanger() {\n local cn_name=\"$1\"\n local local_name=\"$2\"\n local target=\"$FONT_DIR/$local_name\"\n\n # Source 1: official tsanger.cn\n local official_url=\"https://tsanger.cn/download/${cn_name}\"\n echo \" Trying: tsanger.cn (official)\"\n if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL \"$official_url\" -o \"$target.$TMP_SUFFIX\" 2>/dev/null; then\n if check_size \"$target.$TMP_SUFFIX\" \"$MIN_SIZE_CN\"; then\n mv \"$target.$TMP_SUFFIX\" \"$target\"\n echo \" OK: $local_name downloaded ($(du -h \"$target\" | cut -f1))\"\n return 0\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n\n # Source 2+: CDN mirrors (already named TsangerJinKai02-W0x.ttf)\n for src in \"${MIRROR_SOURCES[@]}\"; do\n local url=\"$src/$local_name\"\n echo \" Trying: $url\"\n if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL \"$url\" -o \"$target.$TMP_SUFFIX\" 2>/dev/null; then\n if check_size \"$target.$TMP_SUFFIX\" \"$MIN_SIZE_CN\"; then\n mv \"$target.$TMP_SUFFIX\" \"$target\"\n echo \" OK: $local_name downloaded ($(du -h \"$target\" | cut -f1))\"\n return 0\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n done\n\n echo \" ERROR: all sources failed for $local_name\"\n return 1\n}\n\ndownload_ko_serif() {\n local local_name=\"$1\"\n local target=\"$FONT_DIR/$local_name\"\n\n # CDN mirrors only: Source Han Serif K has no single official direct-download\n # URL, so we serve the committed OTFs from the same jsDelivr/jsdmirror gh path.\n for src in \"${MIRROR_SOURCES[@]}\"; do\n local url=\"$src/$local_name\"\n echo \" Trying: $url\"\n if curl --retry 2 --connect-timeout 15 --max-time 300 -fSL \"$url\" -o \"$target.$TMP_SUFFIX\" 2>/dev/null; then\n if check_size \"$target.$TMP_SUFFIX\" \"$MIN_SIZE_KO\"; then\n mv \"$target.$TMP_SUFFIX\" \"$target\"\n echo \" OK: $local_name downloaded ($(du -h \"$target\" | cut -f1))\"\n return 0\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n else\n rm -f \"$target.$TMP_SUFFIX\"\n fi\n done\n\n echo \" ERROR: all sources failed for $local_name\"\n return 1\n}\n\n# A repo checkout ships the committed font files. Templates resolve their\n# relative `../fonts/*` @font-face path against them directly, so there is\n# nothing to download or register. These branches are skipped inside a Claude\n# Desktop skill, whose assets/fonts has the large fonts stripped out.\n\ncn_failed=0\nif cn_present_in \"$REPO_FONT_DIR\"; then\n echo \"OK: TsangerJinKai fonts present in repo checkout ($REPO_FONT_DIR)\"\nelse\n mkdir -p \"$FONT_DIR\"\n if cn_present_in \"$FONT_DIR\"; then\n echo \"OK: TsangerJinKai fonts present ($FONT_DIR)\"\n else\n echo \"Downloading TsangerJinKai fonts to $FONT_DIR ...\"\n for i in \"${!CN_NAMES[@]}\"; do\n cn_name=\"${CN_NAMES[$i]}\"\n local_name=\"${CN_LOCAL_NAMES[$i]}\"\n if check_size \"$FONT_DIR/$local_name\" \"$MIN_SIZE_CN\"; then\n echo \" OK: $local_name already present\"\n continue\n fi\n if ! download_tsanger \"$cn_name\" \"$local_name\"; then\n cn_failed=$((cn_failed + 1))\n fi\n done\n if [[ \"$cn_failed\" -gt 0 ]]; then\n echo \"\"\n echo \"Some TsangerJinKai files could not be downloaded. Alternatives:\"\n echo \" 1. Install Source Han Serif SC: brew install --cask font-source-han-serif-sc\"\n echo \" 2. Copy TsangerJinKai02-W04.ttf and W05.ttf manually into $FONT_DIR\"\n # Don't exit yet, try the KO recovery too so a Korean-only user still gets KO fonts.\n fi\n fi\nfi\n\nko_failed=0\nif ko_present_in \"$REPO_FONT_DIR\"; then\n echo \"OK: Source Han Serif K fonts present in repo checkout ($REPO_FONT_DIR)\"\nelse\n mkdir -p \"$FONT_DIR\"\n if ko_present_in \"$FONT_DIR\"; then\n echo \"OK: Source Han Serif K fonts present ($FONT_DIR)\"\n else\n echo \"Downloading Source Han Serif K fonts to $FONT_DIR ...\"\n for local_name in \"${KO_NAMES[@]}\"; do\n if check_size \"$FONT_DIR/$local_name\" \"$MIN_SIZE_KO\"; then\n echo \" OK: $local_name already present\"\n continue\n fi\n if ! download_ko_serif \"$local_name\"; then\n ko_failed=$((ko_failed + 1))\n fi\n done\n if [[ \"$ko_failed\" -gt 0 ]]; then\n echo \"\"\n echo \"Some Source Han Serif K files could not be downloaded. Alternatives:\"\n echo \" 1. Download from https://github.com/adobe-fonts/source-han-serif/releases\"\n echo \" 2. Copy SourceHanSerifKR-Regular.otf and -Medium.otf manually into $FONT_DIR\"\n fi\n fi\nfi\n\nif [[ \"$cn_failed\" -gt 0 || \"$ko_failed\" -gt 0 ]]; then\n exit 1\nfi\n\nrefresh_fontconfig\necho \"OK: all fonts ready\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":8899,"content_sha256":"6e028ceed7c9548f8ced6525685456772c1e5b5ad7ec9eb4f1735d56b8f23f9d"},{"filename":"scripts/font_compat.py","content":"\"\"\"Hand WeasyPrint TrueType instead of CID-keyed CFF fonts.\n\nWeasyPrint subsets a CID-keyed CFF font (Source Han Serif K, Kami's Korean\nserif, ships as such an .otf) into a PDF that Poppler draws but MuPDF and\nmacOS Quartz, the engine behind Preview, draw blank: the Korean text is in the\nPDF and extracts fine, and the page shows only Latin text (production.md\npitfall 25). The same glyphs as TrueType outlines subset cleanly for every\nreader, so before each render this rewrites `@font-face` sources that resolve\nto a local CID-keyed CFF file to a TrueType copy converted once and cached.\n\nOnly sources found on disk are rewritten: a relative or file:// path, or a\nremote URL whose file name exists in the skill font directory or the\nensure-fonts download directory. Fonts fontconfig resolves by family name\nnever pass through `@font-face` and are left alone.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport os\nimport re\nimport tempfile\nfrom pathlib import Path\nfrom urllib.parse import unquote, urlparse\n\nCONVERTER_VERSION = \"1\"\n\n_FONT_FACE = re.compile(r\"@font-face\\s*\\{[^}]*\\}\", re.I)\n_URL = re.compile(r\"\"\"url\\(\\s*(['\"]?)([^'\")]+?)\\1\\s*\\)\"\"\", re.I)\n_FORMAT_OPENTYPE = re.compile(r\"\"\"(url\\([^)]*\\)\\s*)format\\(\\s*(['\"]?)opentype\\2\\s*\\)\"\"\", re.I)\n\n\ndef _cache_dir() -> Path:\n base = os.environ.get(\"XDG_CACHE_HOME\") or str(Path.home() / \".cache\")\n return Path(base) / \"kami\" / \"fonts\"\n\n\ndef _font_dirs() -> list[Path]:\n skill_fonts = Path(__file__).resolve().parent.parent / \"assets\" / \"fonts\"\n data = os.environ.get(\"XDG_DATA_HOME\") or str(Path.home() / \".local\" / \"share\")\n user_fonts = Path(os.environ.get(\"KAMI_FONT_DIR\") or Path(data) / \"fonts\" / \"kami\")\n return [skill_fonts, user_fonts]\n\n\ndef _local_path(url: str, base: Path) -> Path | None:\n parsed = urlparse(url)\n if parsed.scheme in (\"http\", \"https\"):\n name = Path(unquote(parsed.path)).name\n for folder in _font_dirs():\n if (folder / name).is_file():\n return folder / name\n return None\n if parsed.scheme == \"file\":\n path = Path(unquote(parsed.path))\n elif parsed.scheme:\n return None\n else:\n path = (base / unquote(url)).resolve()\n return path if path.is_file() else None\n\n\ndef is_cid_cff(path: Path) -> bool:\n if path.suffix.lower() not in (\".otf\", \".otc\", \".ttc\"):\n return False\n try:\n with path.open(\"rb\") as fh:\n if fh.read(4) != b\"OTTO\":\n return False\n from fontTools.ttLib import TTFont\n font = TTFont(str(path), lazy=True)\n if \"CFF \" not in font:\n return False\n top = font[\"CFF \"].cff.topDictIndex[0]\n return getattr(top, \"ROS\", None) is not None\n except Exception:\n return False\n\n\ndef _convert(src: Path, dst: Path) -> None:\n from fontTools.pens.cu2quPen import Cu2QuPen\n from fontTools.pens.ttGlyphPen import TTGlyphPen\n from fontTools.ttLib import TTFont, newTable\n\n font = TTFont(str(src))\n order = font.getGlyphOrder()\n glyph_set = font.getGlyphSet()\n glyphs = {}\n for name in order:\n pen = TTGlyphPen(glyph_set)\n glyph_set[name].draw(Cu2QuPen(pen, 1.0, reverse_direction=True))\n glyphs[name] = pen.glyph()\n del font[\"CFF \"]\n for tag in (\"VORG\",):\n if tag in font:\n del font[tag]\n font[\"loca\"] = newTable(\"loca\")\n glyf = font[\"glyf\"] = newTable(\"glyf\")\n glyf.glyphOrder = order\n glyf.glyphs = glyphs\n maxp = font[\"maxp\"] = newTable(\"maxp\")\n maxp.tableVersion = 0x00010000\n for field in (\"maxZones\",):\n setattr(maxp, field, 1)\n for field in (\"maxTwilightPoints\", \"maxStorage\", \"maxFunctionDefs\", \"maxInstructionDefs\",\n \"maxStackElements\", \"maxSizeOfInstructions\", \"maxComponentElements\"):\n setattr(maxp, field, 0)\n font[\"head\"].glyphDataFormat = 0\n font[\"post\"].formatType = 3.0\n font.sfntVersion = \"\\x00\\x01\\x00\\x00\"\n dst.parent.mkdir(parents=True, exist_ok=True)\n # Write beside the destination and rename, so a concurrent render never\n # reads a half-written font.\n fd, tmp = tempfile.mkstemp(dir=dst.parent, suffix=\".ttf.tmp\")\n os.close(fd)\n try:\n font.save(tmp)\n os.replace(tmp, dst)\n finally:\n if os.path.exists(tmp):\n os.unlink(tmp)\n\n\ndef truetype_copy(path: Path) -> Path:\n stat = path.stat()\n key = hashlib.sha256(\n f\"{CONVERTER_VERSION}:{path.resolve()}:{stat.st_size}:{stat.st_mtime_ns}\".encode()\n ).hexdigest()[:16]\n dst = _cache_dir() / f\"{path.stem}-{key}.ttf\"\n if not dst.is_file():\n _convert(path, dst)\n return dst\n\n\ndef use_truetype_fonts(html: str, base: Path) -> str:\n \"\"\"Rewrite @font-face sources that point at CID-keyed CFF files.\"\"\"\n def face(block: re.Match) -> str:\n text = block.group(0)\n\n def url(m: re.Match) -> str:\n local = _local_path(m.group(2).strip(), base)\n if local is None or not is_cid_cff(local):\n return m.group(0)\n try:\n return f'url(\"{truetype_copy(local).as_uri()}\")'\n except Exception:\n return m.group(0) # conversion failure keeps the original source\n\n rewritten = _URL.sub(url, text)\n if rewritten != text:\n # A TrueType file declared as format(\"opentype\") is still accepted,\n # but say what it is.\n rewritten = _FORMAT_OPENTYPE.sub(\n lambda m: m.group(1) + 'format(\"truetype\")' if \"kami/fonts\" in m.group(1) else m.group(0),\n rewritten)\n return rewritten\n\n if \"@font-face\" not in html:\n return html\n return _FONT_FACE.sub(face, html)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5703,"content_sha256":"5e15677bfdd1efa5f457b13fb2a1756de090b310b028c024dcc83d4c8ef52cdc"},{"filename":"scripts/hangul.py","content":"\"\"\"Keep Korean words whole at line ends in the PDF render.\n\nKorean breaks lines between words (eojeol), not between syllables. Browsers do\nthat with `word-break: keep-all`; WeasyPrint ignores it and breaks inside a\nword (\"파이프라 / 인을\"). Left alone, agents patch it in the source with\nper-syllable joiners or a span around every word, which makes the HTML\nunreadable. This runs once inside `render.render_pdf` instead: it puts U+2060\nWORD JOINER between the characters of every word that contains Hangul, in the\nrendered copy only. The joiner draws nothing and does not reach the PDF text\nlayer, so extracted text, search, and the PDF gates read the same words.\n\nWords longer than MAX_WORD_UNITS stay breakable so a long compound in a narrow\ntable cell wraps instead of overflowing.\n\"\"\"\nfrom __future__ import annotations\n\nimport re\n\nJOINER = \"⁠\"\nMAX_WORD_UNITS = 16\n\n_HANGUL = re.compile(r\"[가-힣ᄀ-ᇿ㄰-㆏]\")\n_TOKEN = re.compile(r\"(\u003c!--.*?-->|\u003c[^>]+>)\", re.S)\n_TAG_NAME = re.compile(r\"\u003c\\s*(/?)\\s*([a-zA-Z][\\w:-]*)\")\n_UNIT = re.compile(r\"&#?\\w+;|.\", re.S)\n\n# Inside these the text is not prose (or is not text at all).\n_SKIP = {\"head\", \"style\", \"script\", \"svg\", \"math\", \"pre\", \"code\", \"textarea\", \"title\"}\n# A word may continue across these; any other tag ends it.\n_INLINE = {\"a\", \"abbr\", \"b\", \"bdi\", \"bdo\", \"cite\", \"data\", \"dfn\", \"em\", \"i\", \"kbd\",\n \"mark\", \"q\", \"s\", \"samp\", \"small\", \"span\", \"strong\", \"sub\", \"sup\", \"time\",\n \"u\", \"var\", \"wbr\", \"font\"}\n\n\ndef _is_space(unit: str) -> bool:\n return unit.isspace()\n\n\ndef _join_text(text: str, carry: bool) -> tuple[str, bool]:\n \"\"\"Join units inside Hangul words. `carry` says the previous text ended\n mid-word on a Hangul-bearing word, so this text continues it.\"\"\"\n units = _UNIT.findall(text)\n out: list[str] = []\n i = 0\n first_word = True\n tail_open = False\n while i \u003c len(units):\n if _is_space(units[i]):\n out.append(units[i])\n i += 1\n first_word = False\n continue\n j = i\n while j \u003c len(units) and not _is_space(units[j]):\n j += 1\n word = units[i:j]\n continues = first_word and carry\n hangul = any(_HANGUL.match(u) for u in word)\n if (hangul or continues) and len(word) \u003c= MAX_WORD_UNITS:\n if continues:\n out.append(JOINER)\n out.append(JOINER.join(word))\n else:\n out.append(\"\".join(word))\n tail_open = j == len(units) and (hangul or continues)\n i = j\n first_word = False\n return \"\".join(out), tail_open\n\n\ndef _starts_hangul_word(text: str) -> bool:\n m = re.match(r\"\\S+\", text)\n return bool(m) and bool(_HANGUL.search(m.group(0)))\n\n\ndef join_hangul_words(html: str) -> str:\n if not _HANGUL.search(html):\n return html\n parts = _TOKEN.split(html)\n skip_depth = 0\n skip_stack: list[str] = []\n carry = False # previous text ended inside a Hangul word\n pending_plain = False # previous text ended inside a plain word that a Hangul word may continue\n out: list[str] = []\n for part in parts:\n if not part:\n continue\n if part.startswith(\"\u003c\"):\n out.append(part)\n if part.startswith(\"\u003c!--\"):\n continue\n m = _TAG_NAME.match(part)\n if not m:\n continue\n closing, name = m.group(1) == \"/\", m.group(2).lower()\n self_closing = part.rstrip().endswith(\"/>\")\n if name in _SKIP and not self_closing:\n if closing:\n if skip_stack and skip_stack[-1] == name:\n skip_stack.pop()\n else:\n skip_stack.append(name)\n skip_depth = len(skip_stack)\n if name not in _INLINE:\n carry = pending_plain = False\n continue\n if skip_depth:\n out.append(part)\n continue\n starts_word = bool(part) and not part[0].isspace()\n joined_carry = starts_word and (carry or (pending_plain and _starts_hangul_word(part)))\n text, tail = _join_text(part, joined_carry)\n out.append(text)\n ends_word = not part[-1].isspace()\n carry = tail\n pending_plain = ends_word and not tail\n return \"\".join(out)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4342,"content_sha256":"730f2405c120260d4ea31982c927a2586e5ad9910f337519d02f98884bed19fe"},{"filename":"scripts/highlight.py","content":"\"\"\"Lightweight syntax highlighting for Kami HTML templates.\n\nScans HTML for \u003cpre>\u003ccode class=\"language-*\"> blocks and applies\nPygments-based inline-style highlighting using Kami design tokens.\nBlocks without a language- class pass through unchanged.\n\"\"\"\nfrom __future__ import annotations\n\nimport html as html_mod\nimport re\nimport sys\n\nfrom shared import token_value\n\nCODE_BLOCK_RE = re.compile(\n r'(?P\u003copen>\u003cpre\\b[^>]*>\\s*\u003ccode\\b[^>]*>)'\n r'(?P\u003ccode>.*?)'\n r'(?P\u003cclose>\u003c/code\\s*>\\s*\u003c/pre\\s*>)',\n re.DOTALL | re.IGNORECASE,\n)\nCLASS_ATTR_RE = re.compile(\n r'''(?:^|\\s)class\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=\u003c>`]+))''',\n re.IGNORECASE,\n)\n\n_KAMI_PALETTE: dict[str, str] | None = None\n\n\ndef _kami_palette() -> dict[str, str]:\n \"\"\"Resolve design-token colors lazily.\n\n Kept out of module scope so importing this module (e.g. by build.py for a\n command that never highlights code) does not read tokens.json. That keeps\n the import resilient on a half-installed checkout, matching shared.py's\n baked-in fallbacks.\n \"\"\"\n global _KAMI_PALETTE\n if _KAMI_PALETTE is None:\n _KAMI_PALETTE = {\n \"brand\": token_value(\"brand\"),\n \"stone\": token_value(\"stone\"),\n \"olive\": token_value(\"olive\"),\n \"dark_warm\": token_value(\"dark-warm\"),\n \"near_black\": token_value(\"near-black\"),\n }\n return _KAMI_PALETTE\n\n\n_WARNED_MISSING_PYGMENTS = False\n\n\ndef _warn_missing_pygments() -> None:\n global _WARNED_MISSING_PYGMENTS\n if _WARNED_MISSING_PYGMENTS:\n return\n print(\n \"WARN: Pygments is not installed; language-tagged code blocks will render monochrome. \"\n \"Install with `python3 -m pip install Pygments` to enable syntax highlighting.\",\n file=sys.stderr,\n )\n _WARNED_MISSING_PYGMENTS = True\n\n\ndef _build_kami_style():\n from pygments.style import Style\n from pygments.token import (\n Comment, Keyword, Literal, Name, Number, Operator,\n Punctuation, String, Token,\n )\n\n palette = _kami_palette()\n\n class KamiStyle(Style):\n background_color = \"\"\n default_style = \"\"\n styles = {\n Token: \"\",\n Comment: palette[\"stone\"],\n Comment.Single: palette[\"stone\"],\n Comment.Multiline: palette[\"stone\"],\n Comment.Preproc: palette[\"stone\"],\n Keyword: palette[\"brand\"],\n Keyword.Constant: palette[\"brand\"],\n Keyword.Namespace: palette[\"brand\"],\n Keyword.Type: palette[\"brand\"],\n Name.Builtin: palette[\"brand\"],\n Name.Function: palette[\"near_black\"],\n Name.Class: palette[\"near_black\"],\n Name.Decorator: palette[\"olive\"],\n String: palette[\"olive\"],\n String.Doc: palette[\"stone\"],\n Number: palette[\"dark_warm\"],\n Number.Float: palette[\"dark_warm\"],\n Number.Integer: palette[\"dark_warm\"],\n Literal: palette[\"dark_warm\"],\n Operator: \"\",\n Punctuation: \"\",\n }\n\n return KamiStyle\n\n\ndef _language_from_open_tag(open_tag: str) -> str | None:\n \"\"\"Return the first ``language-*`` class token from a code start tag.\"\"\"\n code_tag = re.search(r\"\u003ccode\\b(?P\u003cattrs>[^>]*)>\", open_tag, re.IGNORECASE)\n if code_tag is None:\n return None\n class_attr = CLASS_ATTR_RE.search(code_tag.group(\"attrs\"))\n if class_attr is None:\n return None\n class_value = next(value for value in class_attr.groups() if value is not None)\n for token in class_value.split():\n match = re.fullmatch(r\"language-([\\w+-]+)\", token, re.IGNORECASE)\n if match:\n return match.group(1)\n return None\n\n\ndef _highlight_block(match: re.Match[str]) -> str:\n from pygments import highlight as pyg_highlight\n from pygments.formatters import HtmlFormatter\n from pygments.lexers import get_lexer_by_name\n\n open_tag = match.group(\"open\")\n language = _language_from_open_tag(open_tag)\n if language is None:\n return match.group(0)\n code = match.group(\"code\")\n close_tag = match.group(\"close\")\n # Inline markup means the block is already styled; Pygments would escape\n # the tags into visible literal text.\n if re.search(r\"\u003c[A-Za-z/!]\", code):\n return match.group(0)\n\n code_text = html_mod.unescape(code)\n\n try:\n lexer = get_lexer_by_name(language, stripall=False, ensurenl=False)\n except Exception:\n return match.group(0)\n\n formatter = HtmlFormatter(\n style=_build_kami_style(),\n noclasses=True,\n nowrap=True,\n )\n\n highlighted = pyg_highlight(code_text, lexer, formatter)\n # HtmlFormatter ends the last line with a newline; keep the author's ending.\n if highlighted.endswith(\"\\n\") and not code_text.endswith(\"\\n\"):\n highlighted = highlighted[:-1]\n return f'{open_tag}{highlighted}{close_tag}'\n\n\ndef highlight_code_blocks(html_text: str) -> str:\n \"\"\"Apply syntax highlighting to language-tagged code blocks.\n\n Returns HTML unchanged if Pygments is not installed or no\n language-tagged blocks are found.\n \"\"\"\n matches = list(CODE_BLOCK_RE.finditer(html_text))\n if not matches or not any(\n _language_from_open_tag(match.group(\"open\")) for match in matches\n ):\n return html_text\n\n try:\n import pygments # noqa: F401\n except ImportError:\n _warn_missing_pygments()\n return html_text\n\n return CODE_BLOCK_RE.sub(_highlight_block, html_text)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5678,"content_sha256":"c6cf1ad3a4c2aca6c62c595d8ae9c10320c7ccd2f9e9c49a95bb01b2c26ce99e"},{"filename":"scripts/html_visibility.py","content":"\"\"\"HTML visibility evidence shared by content coverage and residue checks.\n\nThis is a bounded static analysis of authored HTML/CSS, not a browser layout\nengine. Coverage fails closed on ambiguous visibility; residue checks retain\npotentially visible copy so unsupported styling cannot hide raw markup.\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nfrom html import unescape\nfrom html.parser import HTMLParser\n\n\ndef _decode_css_escapes(text: str) -> str:\n \"\"\"Decode the CSS escapes needed to recognize property names and values.\"\"\"\n def replace_hex(match: re.Match) -> str:\n try:\n return chr(int(match.group(1), 16))\n except (ValueError, OverflowError):\n return \"\"\n\n text = re.sub(r\"\\\\([0-9a-fA-F]{1,6})\\s?\", replace_hex, text)\n return re.sub(r\"\\\\([^\\r\\n])\", r\"\\1\", text)\n\n\ndef _css_numeric_value(value: str) -> tuple[float, str] | None:\n \"\"\"Parse a small, deterministic subset of CSS numeric expressions.\"\"\"\n value = re.sub(r\"\\s+\", \"\", value.lower())\n direct = re.fullmatch(r\"([-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+))(%|[a-z]+)?\", value)\n if direct:\n return float(direct.group(1)), direct.group(2) or \"\"\n function = re.fullmatch(r\"(calc|min|max|clamp)\\((.*)\\)\", value)\n if not function:\n return None\n name, body = function.groups()\n if name == \"calc\":\n return _css_numeric_value(body)\n parts = [_css_numeric_value(part) for part in body.split(\",\")]\n if not parts or any(part is None for part in parts):\n return None\n parsed = [part for part in parts if part is not None]\n units = {unit for number, unit in parsed if number != 0 and unit}\n if len(units) > 1:\n return None\n unit = next(iter(units), next((u for _, u in parsed if u), \"\"))\n values = [number for number, _ in parsed]\n if name == \"min\":\n return min(values), unit\n if name == \"max\":\n return max(values), unit\n if len(values) == 3:\n lower, preferred, upper = values\n return max(lower, min(preferred, upper)), unit\n return None\n\n\ndef _is_zero_css_value(value: str) -> bool:\n parsed = _css_numeric_value(value)\n return parsed is not None and parsed[0] == 0\n\n\ndef _is_extreme_css_offset(value: str) -> bool:\n parsed = _css_numeric_value(value)\n if parsed is None:\n return False\n number, unit = parsed\n threshold = 100 if unit in {\"%\", \"em\", \"rem\"} else 2000\n return abs(number) >= threshold\n\n\nclass _DocumentStyleCollector(HTMLParser):\n \"\"\"Collect stylesheet bodies and decoded inline style attributes.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(convert_charrefs=True)\n self.style_blocks: list[str] = []\n self.inline_styles: list[str] = []\n self._style_parts: list[str] | None = None\n\n def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:\n if tag.lower() == \"style\":\n self._style_parts = []\n self.inline_styles.extend(\n value\n for name, value in attrs\n if name.lower() == \"style\" and value is not None\n )\n\n def handle_endtag(self, tag: str) -> None:\n if tag.lower() == \"style\" and self._style_parts is not None:\n self.style_blocks.append(\"\".join(self._style_parts))\n self._style_parts = None\n\n def handle_data(self, data: str) -> None:\n if self._style_parts is not None:\n self._style_parts.append(data)\n\n def close(self) -> None:\n super().close()\n if self._style_parts is not None:\n self.style_blocks.append(\"\".join(self._style_parts))\n self._style_parts = None\n\n\ndef _custom_property_names(css: str) -> list[str]:\n \"\"\"Return custom-property declarations while ignoring quoted text.\n\n This intentionally over-collects declarations from selectors and at-rules:\n any declaration outside an unconditional root rule makes cross-rule\n resolution unsafe. False positives therefore disable an optimization\n instead of admitting hidden evidence.\n \"\"\"\n names: list[str] = []\n quote = \"\"\n escaped = False\n index = 0\n while index \u003c len(css):\n char = css[index]\n if escaped:\n escaped = False\n index += 1\n continue\n if char == \"\\\\\":\n escaped = True\n index += 1\n continue\n if quote:\n if char == quote:\n quote = \"\"\n index += 1\n continue\n if char in {'\"', \"'\"}:\n quote = char\n index += 1\n continue\n if css.startswith(\"--\", index):\n match = re.match(r\"--[\\w-]+\", css[index:])\n if match:\n name = match.group(0)\n end = index + len(name)\n cursor = end\n while cursor \u003c len(css) and css[cursor].isspace():\n cursor += 1\n if cursor \u003c len(css) and css[cursor] == \":\":\n names.append(name)\n index = end\n continue\n index += 1\n return names\n\n\ndef _top_level_css_rules(css: str) -> list[tuple[str, str]] | None:\n \"\"\"Split a stylesheet into balanced top-level ``(prelude, body)`` rules.\"\"\"\n rules: list[tuple[str, str]] = []\n start = 0\n index = 0\n quote = \"\"\n escaped = False\n paren_depth = 0\n bracket_depth = 0\n while index \u003c len(css):\n char = css[index]\n if escaped:\n escaped = False\n index += 1\n continue\n if char == \"\\\\\":\n escaped = True\n index += 1\n continue\n if quote:\n if char == quote:\n quote = \"\"\n index += 1\n continue\n if char in {'\"', \"'\"}:\n quote = char\n index += 1\n continue\n if char == \"(\":\n paren_depth += 1\n elif char == \")\":\n if paren_depth == 0:\n return None\n paren_depth -= 1\n elif char == \"[\":\n bracket_depth += 1\n elif char == \"]\":\n if bracket_depth == 0:\n return None\n bracket_depth -= 1\n elif char == \";\" and paren_depth == 0 and bracket_depth == 0:\n start = index + 1\n elif char == \"{\" and paren_depth == 0 and bracket_depth == 0:\n prelude = css[start:index].strip()\n body_start = index + 1\n depth = 1\n inner_quote = \"\"\n inner_escaped = False\n index += 1\n while index \u003c len(css) and depth:\n inner = css[index]\n if inner_escaped:\n inner_escaped = False\n elif inner == \"\\\\\":\n inner_escaped = True\n elif inner_quote:\n if inner == inner_quote:\n inner_quote = \"\"\n elif inner in {'\"', \"'\"}:\n inner_quote = inner\n elif inner == \"{\":\n depth += 1\n elif inner == \"}\":\n depth -= 1\n index += 1\n if depth or inner_quote:\n return None\n rules.append((prelude, css[body_start:index - 1]))\n start = index\n paren_depth = 0\n bracket_depth = 0\n continue\n elif char == \"}\" and paren_depth == 0 and bracket_depth == 0:\n return None\n index += 1\n if quote or escaped or paren_depth or bracket_depth:\n return None\n return rules\n\n\ndef _css_declarations(body: str) -> list[tuple[str, str]] | None:\n \"\"\"Parse a declaration block without splitting quoted or functional values.\"\"\"\n if \"{\" in body or \"}\" in body:\n return None\n parts: list[str] = []\n start = 0\n quote = \"\"\n escaped = False\n paren_depth = 0\n bracket_depth = 0\n for index, char in enumerate(body):\n if escaped:\n escaped = False\n continue\n if char == \"\\\\\":\n escaped = True\n continue\n if quote:\n if char == quote:\n quote = \"\"\n continue\n if char in {'\"', \"'\"}:\n quote = char\n elif char == \"(\":\n paren_depth += 1\n elif char == \")\":\n if paren_depth == 0:\n return None\n paren_depth -= 1\n elif char == \"[\":\n bracket_depth += 1\n elif char == \"]\":\n if bracket_depth == 0:\n return None\n bracket_depth -= 1\n elif char == \";\" and paren_depth == 0 and bracket_depth == 0:\n parts.append(body[start:index])\n start = index + 1\n if quote or escaped or paren_depth or bracket_depth:\n return None\n parts.append(body[start:])\n\n declarations: list[tuple[str, str]] = []\n for part in parts:\n name, separator, value = part.partition(\":\")\n if not separator:\n continue\n declarations.append((re.sub(r\"\\s+\", \"\", name), value.strip()))\n return declarations\n\n\ndef _document_custom_properties(raw: str) -> dict[str, str]:\n \"\"\"Return unconditional root properties safe to resolve across rules.\n\n The checker has no CSS cascade. A property is reusable only when every\n declaration is on an unconditional ``:root`` or ``html`` rule and every\n value is identical. Scoped, conditional, inline, registered, or malformed\n declarations disable that property instead of weakening fail-closed checks.\n \"\"\"\n collector = _DocumentStyleCollector()\n collector.feed(raw)\n collector.close()\n\n all_names: list[str] = []\n root_values: dict[str, list[str]] = {}\n for block in collector.style_blocks:\n clean = re.sub(r\"/\\*.*?\\*/\", \"\", block, flags=re.S)\n clean = _decode_css_escapes(clean)\n if re.search(r\"@property\\b\", clean, flags=re.I):\n return {}\n all_names.extend(_custom_property_names(clean))\n rules = _top_level_css_rules(clean)\n if rules is None:\n return {}\n for selectors, body in rules:\n roots = [selector.strip().lower() for selector in selectors.split(\",\")]\n if not roots or any(selector not in {\":root\", \"html\"} for selector in roots):\n continue\n declarations = _css_declarations(body)\n if declarations is None:\n return {}\n for name, value in declarations:\n if not name.startswith(\"--\"):\n continue\n value = re.sub(r\"!\\s*important\\s*$\", \"\", value, flags=re.I).strip()\n root_values.setdefault(name, []).append(value)\n for inline in collector.inline_styles:\n clean = re.sub(r\"/\\*.*?\\*/\", \"\", inline, flags=re.S)\n all_names.extend(_custom_property_names(_decode_css_escapes(clean)))\n\n counts: dict[str, int] = {}\n for name in all_names:\n counts[name] = counts.get(name, 0) + 1\n return {\n name: values[0]\n for name, values in root_values.items()\n if counts.get(name) == len(values) and len(set(values)) == 1\n }\n\n\ndef _style_state(\n style: str,\n *,\n fail_closed: bool,\n replaced_element: bool = False,\n custom_properties: dict[str, str] | None = None,\n) -> tuple[bool, bool]:\n \"\"\"Return ``(hidden, ambiguous)`` for a declaration block.\n\n CSS comments are removed first because browsers accept declarations such\n as ``display/**/: none``. The coverage gate must not treat those elements\n as visible merely because the declaration was split by a comment.\n \"\"\"\n clean = re.sub(r\"/\\*.*?\\*/\", \"\", style, flags=re.S)\n clean = _decode_css_escapes(clean)\n declarations = _css_declarations(clean)\n if declarations is None:\n return False, fail_closed\n properties: dict[str, tuple[str, bool]] = {\n name: (value, False) for name, value in (custom_properties or {}).items()\n }\n for name, value in declarations:\n name = re.sub(r\"\\s+\", \"\", name)\n if not name.startswith(\"--\"):\n name = name.lower()\n important = re.search(r\"!\\s*important\\s*$\", value, flags=re.I) is not None\n value = re.sub(r\"!\\s*important\\s*$\", \"\", value, flags=re.I)\n value = re.sub(r\"\\s+\", \"\", value)\n previous = properties.get(name)\n if previous is None or important or not previous[1]:\n properties[name] = (value, important)\n\n def resolved(name: str, depth: int = 0) -> tuple[str, bool]:\n if name not in properties:\n return \"\", False\n if depth > 8:\n return \"\", True\n value = properties[name][0]\n match = re.fullmatch(r\"(?i:var)\\((--[\\w-]+)(?:,(.*))?\\)\", value)\n if not match:\n return value, False\n custom_name, fallback = match.groups()\n if custom_name in properties:\n return resolved(custom_name, depth + 1)\n if fallback is None:\n return \"\", True\n temporary_name = f\"--kami-fallback-{depth}\"\n properties[temporary_name] = (fallback, False)\n try:\n return resolved(temporary_name, depth + 1)\n finally:\n properties.pop(temporary_name, None)\n\n def resolved_css(name: str) -> tuple[str, bool]:\n value, ambiguous = resolved(name)\n return value.lower(), ambiguous\n\n display, display_ambiguous = resolved_css(\"display\")\n visibility, visibility_ambiguous = resolved_css(\"visibility\")\n if fail_closed and (\n (display_ambiguous and \"display\" in properties)\n or (visibility_ambiguous and \"visibility\" in properties)\n ):\n return False, True\n if display == \"none\" or display.startswith(\"var(\"):\n return True, False\n if visibility in {\"hidden\", \"collapse\"} or visibility.startswith(\"var(\"):\n return True, False\n opacity, opacity_ambiguous = resolved_css(\"opacity\")\n if opacity_ambiguous and \"opacity\" in properties and fail_closed:\n return False, True\n if _is_zero_css_value(opacity):\n return True, False\n font_size, font_size_ambiguous = resolved_css(\"font-size\")\n if font_size_ambiguous and \"font-size\" in properties and fail_closed:\n return False, True\n if _is_zero_css_value(font_size):\n return True, False\n color, color_ambiguous = resolved_css(\"color\")\n if color_ambiguous and \"color\" in properties and fail_closed:\n return False, True\n if color == \"transparent\":\n return True, False\n transform, transform_ambiguous = resolved_css(\"transform\")\n if transform_ambiguous and \"transform\" in properties and fail_closed:\n return False, True\n if (\n re.search(r\"scale(?:x|y)?\\([-+]?(?:0+(?:\\.0*)?|\\.0+)\\)\", transform)\n or re.search(\n r\"scale\\([-+]?(?:0+(?:\\.0*)?|\\.0+),\"\n r\"[-+]?(?:0+(?:\\.0*)?|\\.0+)\\)\",\n transform,\n )\n ):\n return True, False\n matrix = re.search(r\"matrix\\(([^)]*)\\)\", transform)\n if matrix:\n try:\n values = [float(value) for value in matrix.group(1).split(\",\")]\n except ValueError:\n values = []\n if len(values) == 6 and all(value == 0 for value in values[:4]):\n return True, False\n if re.search(r\"translate(?:x|y)?\\([^)]*(?:-[2-9]\\d{3,}|-[1-9]\\d{4,})\", transform):\n return True, False\n if fail_closed and \"transform\" in properties and \"var(\" in transform:\n return False, True\n for property_name in (\"scale\", \"zoom\"):\n value, ambiguous = resolved_css(property_name)\n if ambiguous and property_name in properties and fail_closed:\n return False, True\n if _is_zero_css_value(value):\n return True, False\n content_visibility, content_visibility_ambiguous = resolved_css(\"content-visibility\")\n if content_visibility_ambiguous and \"content-visibility\" in properties and fail_closed:\n return False, True\n if content_visibility == \"hidden\":\n return True, False\n filter_value, filter_ambiguous = resolved_css(\"filter\")\n if filter_ambiguous and \"filter\" in properties and fail_closed:\n return False, True\n if re.search(r\"opacity\\((?:0+(?:\\.0*)?|\\.0+)(?:%)?\\)\", filter_value):\n return True, False\n if fail_closed and \"filter\" in properties and \"url(\" in filter_value:\n return False, True\n if fail_closed:\n for property_name in (\"mask\", \"mask-image\", \"-webkit-mask\", \"-webkit-mask-image\"):\n value, ambiguous = resolved_css(property_name)\n if property_name in properties and (ambiguous or value not in {\"\", \"none\"}):\n return False, True\n if fail_closed:\n for property_name in (\n \"top\", \"right\", \"bottom\", \"left\", \"inset\", \"inset-inline\",\n \"inset-block\", \"margin-top\", \"margin-right\", \"margin-bottom\",\n \"margin-left\",\n ):\n value, ambiguous = resolved_css(property_name)\n if property_name not in properties:\n continue\n if ambiguous:\n return False, True\n if _is_extreme_css_offset(value):\n return True, False\n text_indent, text_indent_ambiguous = resolved_css(\"text-indent\")\n if text_indent_ambiguous and \"text-indent\" in properties and fail_closed:\n return False, True\n if _is_extreme_css_offset(text_indent):\n return True, False\n clip, clip_ambiguous = resolved_css(\"clip\")\n clip_path, clip_path_ambiguous = resolved_css(\"clip-path\")\n if fail_closed and (\n (clip_ambiguous and \"clip\" in properties)\n or (clip_path_ambiguous and \"clip-path\" in properties)\n ):\n return False, True\n if clip in {\"rect(0,0,0,0)\", \"rect(0px,0px,0px,0px)\"}:\n return True, False\n if clip_path in {\"inset(50%)\", \"inset(100%)\", \"circle(0)\", \"circle(0px)\"}:\n return True, False\n if fail_closed and \"clip\" in properties and clip not in {\"\", \"auto\"}:\n return False, True\n if fail_closed and \"clip-path\" in properties and clip_path not in {\"\", \"none\"}:\n return False, True\n width, width_ambiguous = resolved_css(\"width\")\n max_width, max_width_ambiguous = resolved_css(\"max-width\")\n height, height_ambiguous = resolved_css(\"height\")\n max_height, max_height_ambiguous = resolved_css(\"max-height\")\n overflow, overflow_ambiguous = resolved_css(\"overflow\")\n if fail_closed and (\n (width_ambiguous and \"width\" in properties)\n or (max_width_ambiguous and \"max-width\" in properties)\n or (height_ambiguous and \"height\" in properties)\n or (max_height_ambiguous and \"max-height\" in properties)\n ):\n return False, True\n if overflow_ambiguous and \"overflow\" in properties and fail_closed:\n return False, True\n zero_width = _is_zero_css_value(width) or _is_zero_css_value(max_width)\n zero_height = _is_zero_css_value(height) or _is_zero_css_value(max_height)\n if (zero_width or zero_height) and (\n replaced_element or overflow in {\"hidden\", \"clip\"}\n ):\n return True, False\n if fail_closed:\n for property_name, value, ambiguous in (\n (\"opacity\", opacity, opacity_ambiguous),\n (\"font-size\", font_size, font_size_ambiguous),\n (\"width\", width, width_ambiguous),\n (\"max-width\", max_width, max_width_ambiguous),\n (\"height\", height, height_ambiguous),\n (\"max-height\", max_height, max_height_ambiguous),\n ):\n if property_name in properties and (\n ambiguous or (\"(\" in value and _css_numeric_value(value) is None)\n ):\n return False, True\n return False, False\n\n\n_CSS_NON_ELEMENT_AT_RULES = {\n \"keyframes\", \"-webkit-keyframes\", \"-moz-keyframes\", \"font-face\", \"page\",\n \"counter-style\", \"property\", \"font-feature-values\", \"font-palette-values\",\n}\n\n\ndef _style_rules(css: str, conditional: bool = False) -> list[tuple[str, str, bool]] | None:\n \"\"\"Flatten a stylesheet into ``(selectors, body, conditional)`` style rules.\n\n Bodies of at-rules that never select elements (``@keyframes``,\n ``@font-face``, ``@page``) are skipped. Rules nested in any other at-rule\n (``@media``, ``@supports``, ``@container``) are marked conditional, since\n the checker cannot tell whether the condition holds at render time.\n \"\"\"\n rules = _top_level_css_rules(css)\n if rules is None:\n return None\n flat: list[tuple[str, str, bool]] = []\n for prelude, body in rules:\n if prelude.startswith(\"@\"):\n name = re.match(r\"@([\\w-]*)\", prelude).group(1).lower()\n if name in _CSS_NON_ELEMENT_AT_RULES:\n continue\n nested = _style_rules(body, True)\n if nested is None:\n return None\n flat.extend(nested)\n continue\n flat.append((prelude, body, conditional))\n return flat\n\n\ndef _css_hidden_filters(\n raw: str,\n *,\n fail_closed: bool,\n custom_properties: dict[str, str] | None = None,\n) -> tuple[\n set[str], set[str], set[str], set[tuple[str, str | None]],\n set[str], set[str], set[str], set[tuple[str, str | None]], bool,\n]:\n \"\"\"Return conservative class, id, and tag filters for hidden CSS rules.\n\n The stdlib parser does not implement the CSS cascade. For compound\n selectors, it marks the target class/id when available; for a bare target\n such as ``.concealed img``, it marks the nearest ancestor class/id. This\n can reject an ambiguous document, but it cannot turn hidden evidence into\n a coverage pass. Pseudo-elements are ignored because they do not hide the\n underlying element.\n \"\"\"\n hidden_classes: set[str] = set()\n hidden_ids: set[str] = set()\n hidden_tags: set[str] = set()\n hidden_attrs: set[tuple[str, str | None]] = set()\n ambiguous_classes: set[str] = set()\n ambiguous_ids: set[str] = set()\n ambiguous_tags: set[str] = set()\n ambiguous_attrs: set[tuple[str, str | None]] = set()\n globally_ambiguous = False\n decoded_markup = unescape(raw)\n if fail_closed and re.search(\n r\"\u003clink\\b[^>]*\\bstylesheet\\b[^>]*>\",\n decoded_markup,\n flags=re.I,\n ):\n ambiguous_tags.add(\"*\")\n globally_ambiguous = True\n style_blocks = re.findall(r\"\u003cstyle\\b[^>]*>(.*?)\u003c/style\\s*>\", raw, flags=re.I | re.S)\n for block in style_blocks:\n clean = re.sub(r\"/\\*.*?\\*/\", \"\", block, flags=re.S)\n clean = _decode_css_escapes(clean)\n if fail_closed and re.search(r\"@import\\b\", clean, flags=re.I):\n ambiguous_tags.add(\"*\")\n globally_ambiguous = True\n style_rules = _style_rules(clean)\n if style_rules is None:\n if fail_closed:\n ambiguous_tags.add(\"*\")\n globally_ambiguous = True\n continue\n style_rules = [\n (selectors, body, False)\n for selectors, body in re.findall(r\"([^{}]+)\\{([^{}]*)\\}\", clean, flags=re.S)\n ]\n for selectors, body, conditional in style_rules:\n hides, body_ambiguous = _style_state(\n body,\n fail_closed=fail_closed,\n custom_properties=custom_properties,\n )\n if not hides and not body_ambiguous:\n continue\n if conditional:\n if not fail_closed:\n continue\n body_ambiguous = True\n class_store = ambiguous_classes if body_ambiguous else hidden_classes\n id_store = ambiguous_ids if body_ambiguous else hidden_ids\n tag_store = ambiguous_tags if body_ambiguous else hidden_tags\n attr_store = ambiguous_attrs if body_ambiguous else hidden_attrs\n selectors = _decode_css_escapes(selectors)\n if (\n re.search(r\":[\\w-]+\\s*\\(\", selectors)\n or re.search(r\"[+~]\", selectors)\n or re.search(r\"\\[[^\\]]*(?:[~|^$*]=)\", selectors)\n or re.search(r\"\\[[^\\]]*,[^\\]]*\\]\", selectors)\n ):\n if fail_closed:\n ambiguous_tags.add(\"*\")\n globally_ambiguous = True\n continue\n for selector in selectors.split(\",\"):\n selector = selector.strip()\n if not selector or \"::\" in selector:\n continue\n compounds = [\n part for part in re.split(r\"\\s+|[>+~]\", selector) if part\n ]\n if not compounds:\n continue\n target = compounds[-1]\n target_classes = set(re.findall(r\"\\.([\\w-]+)\", target))\n target_ids = set(re.findall(r\"#([\\w-]+)\", target))\n target_attrs = {\n (\n match.group(1).lower(),\n next(\n (value for value in match.groups()[1:] if value is not None),\n None,\n ),\n )\n for match in re.finditer(\n r\"\\[\\s*([\\w-]+)(?:\\s*=\\s*(?:\\\"([^\\\"]*)\\\"|'([^']*)'|([^\\]\\s]+)))?\\s*\\]\",\n target,\n )\n }\n if target_classes or target_ids:\n class_store.update(target_classes)\n id_store.update(target_ids)\n continue\n if target_attrs:\n attr_store.update(target_attrs)\n continue\n if target.startswith(\":root\"):\n tag_store.add(\"html\")\n continue\n ancestors = \" \".join(compounds[:-1])\n ancestor_classes = set(re.findall(r\"\\.([\\w-]+)\", ancestors))\n ancestor_ids = set(re.findall(r\"#([\\w-]+)\", ancestors))\n if ancestor_classes or ancestor_ids:\n class_store.update(ancestor_classes)\n id_store.update(ancestor_ids)\n continue\n tag_match = re.match(r\"(?:\\*|[a-zA-Z][\\w-]*)\", target)\n if tag_match:\n tag_store.add(tag_match.group(0).lower())\n else:\n ambiguous_tags.add(\"*\")\n globally_ambiguous = True\n return (\n hidden_classes,\n hidden_ids,\n hidden_tags,\n hidden_attrs,\n ambiguous_classes,\n ambiguous_ids,\n ambiguous_tags,\n ambiguous_attrs,\n globally_ambiguous,\n )\n\n\nclass _HtmlVisibilityParser(HTMLParser):\n \"\"\"Shared fail-closed visibility state for text and resource parsers.\"\"\"\n\n _VOID_TAGS = {\n \"area\", \"base\", \"br\", \"col\", \"embed\", \"hr\", \"img\", \"input\",\n \"link\", \"meta\", \"param\", \"source\", \"track\", \"wbr\",\n }\n _P_IMPLICIT_CLOSE_STARTS = {\n \"address\", \"article\", \"aside\", \"blockquote\", \"details\", \"div\",\n \"dl\", \"fieldset\", \"figcaption\", \"figure\", \"footer\", \"form\",\n \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\", \"header\", \"hgroup\", \"hr\",\n \"main\", \"menu\", \"nav\", \"ol\", \"p\", \"pre\", \"section\", \"table\", \"ul\",\n }\n _OPTIONAL_REPEAT_TAGS = {\n \"dd\", \"dt\", \"li\", \"option\", \"tbody\", \"td\", \"tfoot\", \"th\", \"thead\", \"tr\",\n }\n _REPLACED_TAGS = {\"audio\", \"embed\", \"iframe\", \"image\", \"img\", \"object\", \"svg\", \"video\"}\n _PRESENTATION_STYLE_ATTRS = {\n \"clip\", \"clip-path\", \"color\", \"display\", \"filter\", \"font-size\", \"height\",\n \"mask\", \"mask-image\", \"max-height\", \"max-width\", \"opacity\", \"overflow\",\n \"transform\", \"visibility\", \"width\",\n }\n _SVG_POSITIONED_TAGS = {\n \"circle\", \"ellipse\", \"foreignobject\", \"image\", \"rect\", \"svg\", \"text\", \"tspan\", \"use\",\n }\n\n def __init__(\n self,\n hidden_classes: set[str],\n hidden_ids: set[str],\n hidden_tags: set[str],\n hidden_attrs: set[tuple[str, str | None]],\n ambiguous_classes: set[str],\n ambiguous_ids: set[str],\n ambiguous_tags: set[str],\n ambiguous_attrs: set[tuple[str, str | None]],\n visibility_ambiguous: bool,\n *,\n skip_tags: set[str],\n fail_closed: bool,\n residue_mode: bool = False,\n custom_properties: dict[str, str] | None = None,\n ) -> None:\n super().__init__(convert_charrefs=True)\n self._custom_properties = custom_properties\n self._hidden_classes = hidden_classes\n self._hidden_ids = hidden_ids\n self._hidden_tags = hidden_tags\n self._hidden_attrs = hidden_attrs\n self._ambiguous_classes = ambiguous_classes\n self._ambiguous_ids = ambiguous_ids\n self._ambiguous_tags = ambiguous_tags\n self._ambiguous_attrs = ambiguous_attrs\n self._skip_tags = skip_tags\n self._fail_closed = fail_closed\n self._residue_mode = residue_mode\n self._visibility_ambiguous = visibility_ambiguous\n self._skip_depth = 0\n self._ambiguous_depth = 0\n self._skip_stack: list[tuple[str, bool, bool]] = []\n self._svg_viewports: list[tuple[float, float, float, float]] = []\n self._ambiguous_markup = False\n\n def _mark_ambiguous(self) -> None:\n self._ambiguous_markup = True\n self._skip_stack.clear()\n self._svg_viewports.clear()\n self._skip_depth = 1 if self._fail_closed else 0\n self._ambiguous_depth = 1 if self._fail_closed else 0\n\n def _pop_top(self) -> None:\n tag, hidden, ambiguous = self._skip_stack.pop()\n if tag == \"svg\" and self._svg_viewports:\n self._svg_viewports.pop()\n if hidden:\n self._skip_depth = max(0, self._skip_depth - 1)\n if ambiguous:\n self._ambiguous_depth = max(0, self._ambiguous_depth - 1)\n\n @staticmethod\n def _svg_viewport(attrs_map: dict[str, str]) -> tuple[float, float, float, float] | None:\n raw = attrs_map.get(\"viewbox\", \"\")\n if raw:\n try:\n values = [float(value) for value in re.split(r\"[\\s,]+\", raw.strip())]\n except ValueError:\n values = []\n if len(values) == 4 and values[2] > 0 and values[3] > 0:\n return values[0], values[1], values[2], values[3]\n width = _css_numeric_value(attrs_map.get(\"width\", \"\"))\n height = _css_numeric_value(attrs_map.get(\"height\", \"\"))\n if width and height and width[0] > 0 and height[0] > 0:\n return 0.0, 0.0, width[0], height[0]\n return None\n\n def _svg_position_state(\n self,\n tag: str,\n attrs_map: dict[str, str],\n ) -> tuple[bool, bool]:\n if tag not in self._SVG_POSITIONED_TAGS:\n return False, False\n viewport = self._svg_viewports[-1] if self._svg_viewports else None\n if viewport is not None and tag in {\"foreignobject\", \"text\", \"tspan\"}:\n # A coordinate point does not prove that the complete glyph or\n # foreign-object box intersects the viewport. Renderer box data is\n # required, so SVG text cannot be deterministic atomic evidence.\n return False, True\n parsed_positions: dict[str, float] = {}\n for name in (\"x\", \"y\", \"dx\", \"dy\"):\n if name not in attrs_map:\n continue\n parsed = _css_numeric_value(attrs_map[name])\n if parsed is None or parsed[1] not in {\"\", \"px\", \"pt\"}:\n return False, True\n parsed_positions[name] = parsed[0]\n if tag == \"svg\" and viewport is not None and parsed_positions:\n # Nested SVG establishes another viewport. Proving its transformed\n # overlap requires the renderer, so do not accept facts from it as\n # deterministic static evidence.\n return False, True\n if any(parsed_positions.get(name, 0) != 0 for name in (\"dx\", \"dy\")):\n return False, True\n if viewport is None:\n if any(abs(value) >= 10000 for value in parsed_positions.values()):\n return True, False\n return False, False\n min_x, min_y, view_width, view_height = viewport\n x = parsed_positions.get(\"x\", min_x)\n y = parsed_positions.get(\"y\", min_y)\n max_x = min_x + view_width\n max_y = min_y + view_height\n if tag == \"image\":\n image_width = _css_numeric_value(attrs_map.get(\"width\", \"\"))\n image_height = _css_numeric_value(attrs_map.get(\"height\", \"\"))\n if (\n image_width is None\n or image_height is None\n or image_width[1] not in {\"\", \"px\", \"pt\"}\n or image_height[1] not in {\"\", \"px\", \"pt\"}\n or image_width[0] \u003c= 0\n or image_height[0] \u003c= 0\n ):\n return False, True\n right = x + image_width[0]\n bottom = y + image_height[0]\n if right \u003c= min_x or x >= max_x or bottom \u003c= min_y or y >= max_y:\n return True, False\n if x \u003c min_x or right > max_x or y \u003c min_y or bottom > max_y:\n return False, True\n elif x \u003c min_x or x > max_x or y \u003c min_y or y > max_y:\n return False, True\n return False, False\n\n @staticmethod\n def _implicitly_closed_by_start(open_tag: str, new_tag: str) -> bool:\n if open_tag == \"p\" and new_tag in _HtmlVisibilityParser._P_IMPLICIT_CLOSE_STARTS:\n return True\n if open_tag == \"li\" and new_tag == \"li\":\n return True\n if open_tag in {\"dd\", \"dt\"} and new_tag in {\"dd\", \"dt\"}:\n return True\n if open_tag in {\"rp\", \"rt\"} and new_tag in {\"rp\", \"rt\"}:\n return True\n if open_tag == \"option\" and new_tag in {\"option\", \"optgroup\"}:\n return True\n if open_tag == \"optgroup\" and new_tag == \"optgroup\":\n return True\n if open_tag in {\"td\", \"th\"} and new_tag in {\n \"td\", \"th\", \"tr\", \"tbody\", \"thead\", \"tfoot\",\n }:\n return True\n if open_tag == \"tr\" and new_tag in {\"tr\", \"tbody\", \"thead\", \"tfoot\"}:\n return True\n if open_tag in {\"tbody\", \"thead\", \"tfoot\"} and new_tag in {\n \"tbody\", \"thead\", \"tfoot\",\n }:\n return True\n return False\n\n @staticmethod\n def _implicitly_closed_by_end(open_tag: str, end_tag: str) -> bool:\n return (\n (open_tag == \"p\" and end_tag in {\"address\", \"article\", \"aside\", \"blockquote\", \"body\", \"div\", \"footer\", \"form\", \"header\", \"main\", \"nav\", \"section\"})\n or (open_tag == \"li\" and end_tag in {\"menu\", \"ol\", \"ul\"})\n or (open_tag in {\"dd\", \"dt\"} and end_tag == \"dl\")\n or (open_tag in {\"rp\", \"rt\"} and end_tag == \"ruby\")\n or (open_tag == \"option\" and end_tag in {\"optgroup\", \"select\"})\n or (open_tag == \"optgroup\" and end_tag == \"select\")\n or (open_tag in {\"td\", \"th\"} and end_tag in {\"tr\", \"tbody\", \"thead\", \"tfoot\", \"table\"})\n or (open_tag == \"tr\" and end_tag in {\"tbody\", \"thead\", \"tfoot\", \"table\"})\n or (open_tag in {\"tbody\", \"thead\", \"tfoot\"} and end_tag == \"table\")\n )\n\n @staticmethod\n def _implicit_start_boundaries(new_tag: str) -> set[str]:\n if new_tag == \"li\":\n return {\"menu\", \"ol\", \"ul\"}\n if new_tag in {\"dd\", \"dt\"}:\n return {\"dl\"}\n if new_tag in {\"option\", \"optgroup\"}:\n return {\"datalist\", \"select\"}\n if new_tag in {\"rp\", \"rt\"}:\n return {\"ruby\"}\n if new_tag in {\"td\", \"th\"}:\n return {\"table\", \"tr\"}\n if new_tag in {\"tr\", \"tbody\", \"thead\", \"tfoot\"}:\n return {\"table\"}\n return set()\n\n def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:\n tag = tag.lower()\n if self._ambiguous_markup:\n return\n boundaries = self._implicit_start_boundaries(tag)\n boundary = max(\n (\n index\n for index, (open_tag, _, _) in enumerate(self._skip_stack)\n if open_tag in boundaries\n ),\n default=-1,\n )\n implicit_match = next(\n (\n index\n for index in range(len(self._skip_stack) - 1, boundary, -1)\n if self._implicitly_closed_by_start(self._skip_stack[index][0], tag)\n ),\n None,\n )\n if implicit_match is not None:\n while len(self._skip_stack) > implicit_match:\n self._pop_top()\n attrs_map = {name.lower(): (value or \"\") for name, value in attrs}\n classes = set(attrs_map.get(\"class\", \"\").split())\n presentation_style = \";\".join(\n f\"{name}:{value}\"\n for name, value in attrs_map.items()\n if name in self._PRESENTATION_STYLE_ATTRS\n )\n inline_hidden, inline_ambiguous = _style_state(\n attrs_map.get(\"style\", \"\"),\n fail_closed=self._fail_closed,\n replaced_element=tag in self._REPLACED_TAGS,\n custom_properties=self._custom_properties,\n )\n presentation_hidden, presentation_ambiguous = _style_state(\n presentation_style,\n fail_closed=self._fail_closed,\n replaced_element=tag in self._REPLACED_TAGS,\n )\n svg_position_hidden, svg_position_ambiguous = self._svg_position_state(\n tag,\n attrs_map,\n )\n known_hidden = (\n tag in self._skip_tags\n or tag in self._hidden_tags\n or \"*\" in self._hidden_tags\n or any(\n name in attrs_map\n and (expected is None or attrs_map[name] == expected)\n for name, expected in self._hidden_attrs\n )\n or \"hidden\" in attrs_map\n or (\n not self._residue_mode\n and attrs_map.get(\"aria-hidden\", \"\").lower() == \"true\"\n )\n or bool(classes & self._hidden_classes)\n or attrs_map.get(\"id\", \"\") in self._hidden_ids\n or inline_hidden\n or (self._fail_closed and presentation_hidden)\n or (self._fail_closed and svg_position_hidden)\n or (\n self._fail_closed\n and tag not in {\"svg\", \"g\"}\n and attrs_map.get(\"fill\", \"\").strip().lower() == \"none\"\n )\n or (\n self._fail_closed\n and _is_zero_css_value(attrs_map.get(\"fill-opacity\", \"\"))\n )\n )\n # A container's fill is only inherited: a descendant can set its own.\n container_fill_none = (\n tag in {\"svg\", \"g\"}\n and attrs_map.get(\"fill\", \"\").strip().lower() == \"none\"\n )\n css_ambiguous = (\n container_fill_none\n or tag in self._ambiguous_tags\n or \"*\" in self._ambiguous_tags\n or any(\n name in attrs_map\n and (expected is None or attrs_map[name] == expected)\n for name, expected in self._ambiguous_attrs\n )\n or bool(classes & self._ambiguous_classes)\n or attrs_map.get(\"id\", \"\") in self._ambiguous_ids\n )\n if known_hidden or (self._skip_depth and not self._ambiguous_depth):\n inline_ambiguous = False\n presentation_ambiguous = False\n svg_position_ambiguous = False\n css_ambiguous = False\n current_ambiguous = self._fail_closed and (\n css_ambiguous\n or inline_ambiguous\n or presentation_ambiguous\n or svg_position_ambiguous\n )\n ambiguous_hidden = (\n current_ambiguous and not known_hidden and self._skip_depth == 0\n )\n if not known_hidden and (self._ambiguous_depth or ambiguous_hidden):\n self._handle_ambiguous_starttag(tag, attrs)\n hidden = known_hidden or current_ambiguous\n is_void = tag in self._VOID_TAGS\n if not is_void:\n self._skip_stack.append((tag, hidden, ambiguous_hidden))\n if tag == \"svg\":\n self._svg_viewports.append(\n self._svg_viewport(attrs_map) or (0.0, 0.0, 4096.0, 4096.0)\n )\n if hidden:\n self._skip_depth += 1\n if ambiguous_hidden:\n self._ambiguous_depth += 1\n if hidden or self._skip_depth:\n return\n self._handle_visible_starttag(tag, attrs)\n\n def handle_endtag(self, tag: str) -> None:\n tag = tag.lower()\n if self._ambiguous_markup:\n return\n if tag in self._VOID_TAGS:\n return\n match = next(\n (index for index in range(len(self._skip_stack) - 1, -1, -1)\n if self._skip_stack[index][0] == tag),\n None,\n )\n if match is None:\n return\n if match != len(self._skip_stack) - 1:\n intervening = self._skip_stack[match + 1:]\n if not intervening or not self._implicitly_closed_by_end(\n intervening[0][0],\n tag,\n ):\n self._mark_ambiguous()\n return\n closing = self._skip_stack[match:]\n del self._skip_stack[match:]\n for open_tag, _, _ in reversed(closing):\n if open_tag == \"svg\" and self._svg_viewports:\n self._svg_viewports.pop()\n self._skip_depth = max(\n 0,\n self._skip_depth - sum(1 for _, hidden, _ in closing if hidden),\n )\n self._ambiguous_depth = max(\n 0,\n self._ambiguous_depth\n - sum(1 for _, _, ambiguous in closing if ambiguous),\n )\n\n def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:\n # In HTML, a trailing slash is ignored on non-void elements. Treating\n # \u003cdiv hidden/> as closed would expose everything that follows even\n # though browsers and WeasyPrint keep it inside the hidden div.\n foreign_svg = tag.lower() == \"svg\" or bool(self._svg_viewports)\n self.handle_starttag(tag, attrs)\n if foreign_svg and tag.lower() not in self._VOID_TAGS:\n self.handle_endtag(tag)\n\n def handle_data(self, data: str) -> None:\n if self._ambiguous_markup:\n if not self._fail_closed:\n self._handle_visible_data(data)\n elif self._ambiguous_depth:\n self._handle_ambiguous_data(data)\n elif self._skip_depth == 0:\n self._handle_visible_data(data)\n\n def _handle_visible_starttag(\n self, _tag: str, _attrs: list[tuple[str, str | None]],\n ) -> None:\n return\n\n def _handle_ambiguous_starttag(\n self,\n _tag: str,\n _attrs: list[tuple[str, str | None]],\n ) -> None:\n return\n\n def _handle_ambiguous_data(self, _data: str) -> None:\n return\n\n def _handle_visible_data(self, _data: str) -> None:\n return\n\n\nclass _VisibleTextParser(_HtmlVisibilityParser):\n \"\"\"Extract visible text from filled HTML while skipping code-like blocks.\"\"\"\n\n _SKIP_TAGS = {\n \"clippath\", \"code\", \"datalist\", \"defs\", \"desc\", \"head\", \"mask\",\n \"metadata\", \"noembed\", \"noframes\", \"noscript\", \"pattern\", \"pre\", \"rp\",\n \"script\", \"style\", \"symbol\", \"template\", \"title\",\n }\n def __init__(\n self,\n hidden_classes: set[str],\n hidden_ids: set[str],\n hidden_tags: set[str],\n hidden_attrs: set[tuple[str, str | None]],\n ambiguous_classes: set[str],\n ambiguous_ids: set[str],\n ambiguous_tags: set[str],\n ambiguous_attrs: set[tuple[str, str | None]],\n visibility_ambiguous: bool,\n *,\n fail_closed: bool,\n residue_mode: bool = False,\n custom_properties: dict[str, str] | None = None,\n ) -> None:\n super().__init__(\n hidden_classes,\n hidden_ids,\n hidden_tags,\n hidden_attrs,\n ambiguous_classes,\n ambiguous_ids,\n ambiguous_tags,\n ambiguous_attrs,\n visibility_ambiguous,\n skip_tags=self._SKIP_TAGS,\n fail_closed=fail_closed,\n residue_mode=residue_mode,\n custom_properties=custom_properties,\n )\n self.parts: list[str] = []\n\n def _handle_visible_data(self, data: str) -> None:\n self.parts.append(data)\n\n def _handle_ambiguous_data(self, data: str) -> None:\n if data.strip():\n self._visibility_ambiguous = True\n\n\ndef visible_html_evidence(\n raw: str,\n *,\n fail_closed: bool = False,\n residue_mode: bool = False,\n) -> tuple[str, bool]:\n custom_properties = _document_custom_properties(raw)\n css_filters = (\n _css_hidden_filters(\n raw,\n fail_closed=fail_closed,\n custom_properties=custom_properties,\n )\n if not residue_mode\n else (set(), set(), set(), set(), set(), set(), set(), set(), False)\n )\n parser = _VisibleTextParser(\n *css_filters,\n fail_closed=fail_closed,\n residue_mode=residue_mode,\n custom_properties=custom_properties,\n )\n parser.feed(raw)\n if fail_closed and parser._ambiguous_markup:\n return \"\", True\n return \"\\n\".join(parser.parts), parser._visibility_ambiguous\n\n\ndef visible_html_text(\n raw: str,\n *,\n fail_closed: bool = False,\n residue_mode: bool = False,\n) -> str:\n return visible_html_evidence(\n raw,\n fail_closed=fail_closed,\n residue_mode=residue_mode,\n )[0]\n","content_type":"text/x-python; charset=utf-8","language":"python","size":45738,"content_sha256":"1477abe7df5cde6494111eecc5393164a5c842b54472f452184ccd3543c0f518"},{"filename":"scripts/lint.py","content":"\"\"\"Template lint rules and cross-template consistency checks.\n\nSplits out from build.py:\n - scan_file: per-line + per-block lint for HTML/CSS/PPTX templates.\n - check_all: scan every template and aggregate findings by rule.\n - check_cross_template_consistency: pair CN/EN templates and report :root\n variable drift outside the allowlist.\n\nEach `Finding` is anchored to a file path + line number so editors can jump\nstraight to the violation. Rules encode real WeasyPrint pitfalls (rgba on\nbackground, thin border with border-radius, etc.), not style preferences.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom shared import (\n COOL_GRAY_BLOCKLIST,\n HTML_TEMPLATES,\n ROOT,\n SITE_ROOT,\n SCREEN_TEMPLATES,\n TEMPLATES,\n TOKENS_FILE,\n iter_template_files,\n rel_to_root,\n resolve_input,\n)\nfrom tokens import ROOT_BLOCK, parse_root_vars\nfrom diagram_geometry import scan_geometry\n\n# Font-stack vars legitimately differ between a base template and its locale\n# variants (-en, -ko); every other :root var must match across the pair.\nCROSS_TEMPLATE_ALLOWED_VARS = {\"--serif\", \"--sans\", \"--mono\", \"--latin-ui\"}\n\nRGBA_BG_DIRECT = re.compile(r\"background(?:-color)?\\s*:\\s*[^;]*rgba\\s*\\(\", re.IGNORECASE)\nRGBA_VAR_DEF = re.compile(r\"--([\\w-]+)\\s*:\\s*[^;]*rgba\\s*\\(\", re.IGNORECASE)\nBG_VAR_USE = re.compile(r\"background(?:-color)?\\s*:\\s*[^;]*var\\s*\\(\\s*--([\\w-]+)\", re.IGNORECASE)\nRGBA_BORDER_DIRECT = re.compile(r\"border(?:-\\w+)?\\s*:\\s*[^;]*rgba\\s*\\(\", re.IGNORECASE)\nBORDER_VAR_USE = re.compile(r\"border(?:-\\w+)?\\s*:\\s*[^;]*var\\s*\\(\\s*--([\\w-]+)\", re.IGNORECASE)\nLINE_HEIGHT_VALUE = re.compile(\n r\"line-height\\s*:\\s*([0-9]*\\.?[0-9]+)(?![\\w.%])\",\n re.IGNORECASE,\n)\nUNICODE_ARROW = re.compile(r\"→\") # U+2192; should not appear in EN template body\nHEX_ANY = re.compile(\n r\"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\"\n r\"(?![0-9a-fA-F])\"\n)\n# Thin closed border: border shorthand (not single-side) with sub-1pt width -- pitfall #2\nTHIN_CLOSED_BORDER = re.compile(\n r\"border(?!-(?:left|right|top|bottom))\\s*:\\s*[^;{}]*(?\u003c![\\d.])0\\.\\d+pt\",\n re.IGNORECASE,\n)\nBORDER_RADIUS_PROP = re.compile(r\"border-radius\\s*:\", re.IGNORECASE)\nRULE_BODY_RE = re.compile(r\"\\{([^{}]*)\\}\")\nCSS_BLOCK_COMMENT_RE = re.compile(r\"/\\*.*?\\*/\", re.DOTALL)\nSVG_BLOCK_RE = re.compile(r\"\u003csvg\\b.*?\u003c/svg>\", re.DOTALL | re.IGNORECASE)\n\n# WeasyPrint-unsafe artifacts of un-normalized beautiful-mermaid SVG. These must\n# never reach a PDF-bound template/diagram: WeasyPrint does not resolve\n# color-mix(), render \u003cforeignObject>, or fetch a runtime web font. The author\n# must pipe Mermaid output through scripts/mermaid_normalize.py first. Screen-only\n# landing pages are exempt (color-mix in CSS is fine in a real browser).\nMERMAID_UNSAFE = {\n \"mermaid-color-mix\": re.compile(r\"color-mix\\s*\\(\", re.IGNORECASE),\n \"mermaid-foreignobject\": re.compile(r\"\u003cforeignObject\\b\", re.IGNORECASE),\n \"mermaid-webfont-import\": re.compile(r\"fonts\\.googleapis\\.com\", re.IGNORECASE),\n}\n\n\n@dataclass\nclass Finding:\n file: Path\n line: int\n rule: str\n excerpt: str\n\n\ndef _strip_css_block_comments(text: str) -> str:\n \"\"\"Replace `/* ... */` with spaces of the same length so commented-out\n rgba()/cool-gray literals don't trip the per-line scan. Length-preserving\n so line numbers and per-line search offsets remain correct.\n \"\"\"\n def repl(m: re.Match[str]) -> str:\n return \"\".join(ch if ch == \"\\n\" else \" \" for ch in m.group(0))\n return CSS_BLOCK_COMMENT_RE.sub(repl, text)\n\n\ndef scan_file(path: Path, screen: bool | None = None) -> list[Finding]:\n return scan_text(path.read_text(encoding=\"utf-8\", errors=\"replace\"), path, screen=screen)\n\n\ndef scan_text(raw_text: str, path: Path, line_offset: int = 0,\n screen: bool | None = None) -> list[Finding]:\n \"\"\"Run the per-line and per-block rules over `raw_text`.\n\n Split out of scan_file so a CSS snippet that lives inside a Markdown fence\n can be scanned with the same rules as a template, reporting line numbers\n back in the enclosing document via `line_offset`. `screen` says whether\n the file is a browser-only page; None falls back to the template file name.\n \"\"\"\n findings = [Finding(path, line_offset + line, \"diagram-geometry\", message)\n for line, message in scan_geometry(raw_text)]\n text = _strip_css_block_comments(raw_text)\n lines = text.splitlines()\n is_en = path.name.endswith(\"-en.html\")\n # Screen-only landing pages do not pass through WeasyPrint. Their browser\n # gradients may use alpha, and CJK screen copy has a documented 1.65\n # line-height ceiling instead of the print ceiling of 1.55.\n is_screen = path.name in set(SCREEN_TEMPLATES.values()) if screen is None else screen\n\n def line_for(offset: int) -> int:\n return line_offset + text.count(\"\\n\", 0, offset) + 1\n\n # Pass 1: scan declarations across the whole stylesheet. CSS may wrap a\n # declaration across lines or place several custom properties on one line;\n # a per-line `search` silently missed both shapes.\n rgba_vars = {m.group(1) for m in RGBA_VAR_DEF.finditer(text)}\n for match in (() if is_screen else RGBA_BG_DIRECT.finditer(text)):\n findings.append(Finding(\n path,\n line_for(match.start()),\n \"rgba-background\",\n \"rgba() used directly on background (tag double-rectangle bug)\",\n ))\n for match in (() if is_screen else BG_VAR_USE.finditer(text)):\n if match.group(1) in rgba_vars:\n findings.append(Finding(\n path,\n line_for(match.start()),\n \"rgba-background\",\n f\"background: var(--{match.group(1)}) resolves to rgba() (tag double-rectangle bug)\",\n ))\n for match in (() if is_screen else RGBA_BORDER_DIRECT.finditer(text)):\n findings.append(Finding(\n path,\n line_for(match.start()),\n \"rgba-border\",\n \"rgba() used on border (violates solid-color invariant)\",\n ))\n for match in (() if is_screen else BORDER_VAR_USE.finditer(text)):\n if match.group(1) in rgba_vars:\n findings.append(Finding(\n path,\n line_for(match.start()),\n \"rgba-border\",\n f\"border: var(--{match.group(1)}) resolves to rgba() (solid-color invariant)\",\n ))\n\n # Pass 2: per-line rule checks\n is_python = path.suffix == \".py\"\n for i, raw in enumerate(lines, start=1):\n line = raw.strip()\n if not line:\n continue\n # Skip comment lines. Note: '#' alone is NOT a CSS or HTML comment; it\n # is the start of a CSS id selector (e.g. `#hero-bg { ... }`) or part of\n # a hex literal. Only treat '#' as a comment when scanning Python.\n if line.startswith(\"//\"):\n continue\n if line.startswith(\"\u003c!--\"):\n continue\n if is_python and line.startswith(\"#\"):\n continue\n\n if is_en and UNICODE_ARROW.search(raw):\n # skip CSS comment lines (/* ... */) and the arrow-in-CSS-content patterns\n stripped = raw.lstrip()\n if not stripped.startswith(\"/*\") and not stripped.startswith(\"*\") and \"content:\" not in raw:\n findings.append(Finding(path, line_offset + i, \"arrow-unicode-in-en\",\n \"to (U+2192) in English template; use 'to' or '->' per patterns Section 2\"))\n\n for match in LINE_HEIGHT_VALUE.finditer(raw):\n ceiling = 1.65 if is_screen and not is_en else 1.55\n if float(match.group(1)) > ceiling:\n findings.append(Finding(path, line_offset + i, \"line-height-too-loose\",\n f\"{match.group(0)} exceeds {ceiling:.2f} ceiling\"))\n\n for hex_match in HEX_ANY.finditer(raw):\n h = hex_match.group(0).lower()\n if h in COOL_GRAY_BLOCKLIST:\n findings.append(Finding(path, line_offset + i, \"cool-gray\",\n f\"{h} is a cool / neutral gray, use warm undertone\"))\n\n if not is_screen and not is_python:\n for rule, pattern in MERMAID_UNSAFE.items():\n if pattern.search(raw):\n findings.append(Finding(path, line_offset + i, rule,\n \"un-normalized Mermaid SVG (run scripts/mermaid_normalize.py before embedding)\"))\n\n # Pass 3: thin-border-radius block scan (pitfall #2 double-ring).\n # Judge each rule block as a whole, so a one-line rule or a radius on the\n # opening-brace line is seen the same as the expanded multi-line form.\n # Match bodies only: a selector group spanning the whole HTML makes the\n # scan quadratic on long brace-free text.\n raw_lines = raw_text.split(\"\\n\")\n for block in RULE_BODY_RE.finditer(text):\n body = block.group(1)\n if not BORDER_RADIUS_PROP.search(body):\n continue\n for border in THIN_CLOSED_BORDER.finditer(body):\n line = text.count(\"\\n\", 0, block.start(1) + border.start()) + 1\n if \"skip-thin-border-radius\" in raw_lines[line - 1]:\n continue\n findings.append(Finding(path, line_offset + line, \"thin-border-radius\",\n \"thin border (\u003c1pt) with border-radius -- pitfall #2 double-ring risk\"))\n return findings\n\n\ndef check_all(verbose: bool) -> int:\n targets = iter_template_files(include_py=True, include_diagrams=True, include_marp_css=True)\n if not targets:\n print(\"ERROR: no templates found to lint (bad checkout?)\")\n return 2\n\n findings: list[Finding] = []\n for p in targets:\n file_findings = scan_file(p)\n findings.extend(file_findings)\n if verbose:\n print(f\"scanned {p.relative_to(ROOT)}: {len(file_findings)} finding(s)\")\n\n if not findings:\n print(f\"OK: no violations across {len(targets)} templates\")\n return 0\n\n by_rule: dict[str, list[Finding]] = {}\n for f in findings:\n by_rule.setdefault(f.rule, []).append(f)\n\n print(f\"ERROR: {len(findings)} violation(s) across {len({f.file for f in findings})} file(s)\")\n for rule, items in by_rule.items():\n print(f\"\\n[{rule}] {len(items)}\")\n for f in items:\n rel = f.file.relative_to(ROOT)\n print(f\" {rel}:{f.line} {f.excerpt}\")\n return 1\n\n\n# ---------- off-palette color guard ----------\n#\n# design.md core invariant: a single chromatic accent (ink-blue) plus warm\n# neutrals, zero cool tones. The salmon-border regression slipped past the\n# token-drift guard because it was a hardcoded hex inside a component rule, not\n# a :root token. This guard mechanizes the invariant: any hex literal in an\n# editorial template that is neither a registered token value nor a cool-gray\n# (those have their own rule) is an off-palette color. The single sanctioned\n# semantic exception (the changelog breaking-change badge) is registered as the\n# --breaking-* tokens, so it lands in `allowed` and passes.\n#\n# Scope is deliberately narrow: editorial TEMPLATES/*.html only. Diagrams use\n# warm-gray chart ramps that are intentionally not tokens, and inline \u003csvg>\n# charts carry their own fills -- both are skipped (diagrams by directory, svg\n# by block). :root blocks define the tokens themselves, so they are skipped too.\n\n\ndef _blank_block(text: str, regex: re.Pattern[str]) -> str:\n \"\"\"Replace each match with same-length whitespace (newlines preserved) so\n line numbers stay accurate after a block is masked out.\"\"\"\n def repl(m: re.Match[str]) -> str:\n return \"\".join(ch if ch == \"\\n\" else \" \" for ch in m.group(0))\n return regex.sub(repl, text)\n\n\ndef _load_token_names() -> set[str]:\n \"\"\"Return the set of registered token names (`--brand`, ...).\"\"\"\n if not TOKENS_FILE.exists():\n return set()\n try:\n return set(json.loads(TOKENS_FILE.read_text(encoding=\"utf-8\")))\n except json.JSONDecodeError:\n return set()\n\n\ndef _load_token_values() -> set[str]:\n \"\"\"Return the set of canonical token hex values (lowercased).\"\"\"\n if not TOKENS_FILE.exists():\n return set()\n try:\n data = json.loads(TOKENS_FILE.read_text(encoding=\"utf-8\"))\n except json.JSONDecodeError:\n return set()\n return {v.lower() for v in data.values() if isinstance(v, str) and v.startswith(\"#\")}\n\n\ndef _off_palette_findings(\n path: Path, allowed: set[str], raw: str | None = None, line_offset: int = 0\n) -> list[Finding]:\n \"\"\"Flag hex literals outside the registered palette.\n\n `raw` and `line_offset` let a caller pass a snippet lifted out of a larger\n document (a Markdown fence) and still get line numbers into that document.\n \"\"\"\n if raw is None:\n raw = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n text = _strip_css_block_comments(raw)\n text = _blank_block(text, ROOT_BLOCK)\n text = _blank_block(text, SVG_BLOCK_RE)\n findings: list[Finding] = []\n for i, line in enumerate(text.splitlines(), start=1):\n for m in HEX_ANY.finditer(line):\n h = m.group(0).lower()\n if h in allowed:\n continue\n if h in COOL_GRAY_BLOCKLIST:\n continue # reported by the cool-gray rule in scan_file\n findings.append(Finding(path, line_offset + i, \"off-palette\",\n f\"{h} is not a registered token; single-accent palette violated\"))\n return findings\n\n\nROOT_TOKEN_DEF = re.compile(\n r\"(--[\\w-]+)\\s*:\\s*\"\n r\"(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3}))\"\n r\"(?![0-9a-fA-F])\"\n)\n\n\ndef _root_token_findings(path: Path, allowed: set[str]) -> list[Finding]:\n \"\"\"Flag `:root` token definitions whose hex is off the registered palette.\n\n `_off_palette_findings` blanks the `:root` block before scanning property\n values, so a dead or off-palette token *defined* in `:root` but never written\n as a literal hex in a property escapes every guard (this is how a stray\n `--brand-deep: #a64f33` second accent hid in portfolio.html). This closes that\n gap for print templates: every `:root` chromatic token must resolve to a\n registered tokens.json value. Screen templates (landing pages) keep their own\n local tokens outside the print palette, so callers exempt them.\n \"\"\"\n raw = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n text = _strip_css_block_comments(raw)\n findings: list[Finding] = []\n for block in ROOT_BLOCK.finditer(text):\n body_start = block.start(1)\n for vm in ROOT_TOKEN_DEF.finditer(block.group(1)):\n h = vm.group(2).lower()\n if h in allowed:\n continue\n if h in COOL_GRAY_BLOCKLIST:\n continue # reported by the cool-gray rule in scan_file\n line = text.count(\"\\n\", 0, body_start + vm.start(2)) + 1\n findings.append(Finding(path, line, \"off-palette-token\",\n f\"{vm.group(1)}: {h} is a :root token off the registered palette \"\n \"(single-accent invariant; register in tokens.json or remove)\"))\n return findings\n\n\ndef check_off_palette(verbose: bool = False) -> int:\n allowed = _load_token_values()\n screen_names = set(SCREEN_TEMPLATES.values())\n targets = sorted(TEMPLATES.glob(\"*.html\"))\n if not targets:\n print(\"ERROR: no templates found for off-palette scan (bad checkout?)\")\n return 2\n findings: list[Finding] = []\n for p in targets:\n file_findings = _off_palette_findings(p, allowed)\n if p.name not in screen_names:\n file_findings.extend(_root_token_findings(p, allowed))\n findings.extend(file_findings)\n if verbose:\n print(f\"scanned {p.relative_to(ROOT)}: {len(file_findings)} off-palette finding(s)\")\n\n # Demo HTML inherits template CSS by copy, so a token change leaves stale\n # hexes behind in site/assets/demos with no guard: that is exactly how demos\n # kept shipping old colors after palette edits. Scan property values only\n # (demos carry local :root copies on purpose). Pure white is sanctioned:\n # deliberate white-paper print variants document it in their header.\n demo_allowed = allowed | {\"#ffffff\", \"#fff\"}\n demo_targets = sorted((SITE_ROOT / \"assets\" / \"demos\").glob(\"*.html\")) if SITE_ROOT else []\n if SITE_ROOT is not None and not demo_targets:\n print(\"ERROR: no demo HTML found under site/assets/demos (incomplete repository scan)\")\n return 2\n for p in demo_targets:\n file_findings = _off_palette_findings(p, demo_allowed)\n findings.extend(file_findings)\n if verbose:\n print(f\"scanned {p.relative_to(SITE_ROOT.parent)}: {len(file_findings)} off-palette finding(s)\")\n\n if not findings:\n print(f\"OK: no off-palette colors across {len(targets)} template(s) \"\n f\"and {len(demo_targets)} demo(s)\")\n return 0\n\n print(f\"\\nERROR: [off-palette] {len(findings)}\")\n for f in findings:\n base = SITE_ROOT.parent if SITE_ROOT and f.file.is_relative_to(SITE_ROOT) else ROOT\n print(f\" {f.file.relative_to(base)}:{f.line} {f.excerpt}\")\n return 1\n\n\n# ---------- filled-document style drift ----------\n#\n# check_all and check_off_palette scan assets/templates: the shapes the project\n# ships. Nothing scanned the other half of the workflow, the document an agent\n# produces by copying a template and editing it. That copy is where the design\n# system actually erodes, because every rule the template encodes lives in CSS\n# comments an editing pass is free to ignore. These checks read a filled file\n# with the template rules applied.\n\nSTYLE_BLOCK_RE = re.compile(r\"\u003cstyle\\b[^>]*>(.*?)\u003c/style>\", re.DOTALL | re.IGNORECASE)\nCSS_RULE_RE = re.compile(r\"([^{}]+)\\{([^{}]*)\\}\", re.DOTALL)\nBACKGROUND_DECL = re.compile(r\"background(?:-color)?\\s*:\\s*([^;]+)\", re.IGNORECASE)\nPADDING_DECL = re.compile(r\"padding\\s*:\", re.IGNORECASE)\nRADIUS_DECL = re.compile(r\"border-radius\\s*:\", re.IGNORECASE)\nEMPTY_BACKGROUNDS = (\"transparent\", \"none\", \"inherit\", \"initial\", \"unset\")\nINLINE_OR_FLOAT_DECL = re.compile(r\"float\\s*:\\s*(left|right)|display\\s*:\\s*inline\", re.IGNORECASE)\n\n# Two component classes are filled and rounded by definition and say nothing\n# about how the page raises a passage: chips and code. Scanning the shipped\n# templates with no exemptions at all, these are the only selectors that need\n# one; every other hit (.callout, .takeaway, .exec-summary, .analyst-box,\n# .risk-item, .team-culture, .os-highlight) is a real emphasis container and\n# must be counted. Keep this list at what that scan justified.\nEMPHASIS_EXEMPT_SELECTORS = (\"tag\", \"chip\", \"badge\", \"code\", \"pre\", \"kbd\")\n\n\ndef _css_source(path: Path) -> str:\n \"\"\"Return the CSS to analyze: \u003cstyle> blocks for HTML, whole file for CSS.\"\"\"\n raw = _strip_css_block_comments(path.read_text(encoding=\"utf-8\", errors=\"replace\"))\n if path.suffix.lower() == \".css\":\n return raw\n blocks = STYLE_BLOCK_RE.findall(raw)\n return \"\\n\".join(blocks)\n\n\ndef _resolve_css_value(value: str, root_vars: dict[str, str]) -> str:\n \"\"\"Resolve one level of var() against the document's own :root, normalized.\n\n Two selectors reaching the same fill through different spellings\n (`var(--ivory)` and `#faf9f5`) are one design decision, not two, so the\n count must compare resolved colors rather than source text.\n \"\"\"\n resolved = value.strip().lower()\n match = re.search(r\"var\\(\\s*(--[\\w-]+)\", resolved)\n if match:\n resolved = root_vars.get(match.group(1), match.group(1)).strip().lower()\n return re.sub(r\"\\s+\", \"\", resolved)\n\n\ndef _emphasis_container_findings(path: Path) -> list[Finding]:\n \"\"\"Flag a document that fills its emphasis blocks in more than one color.\n\n A template reuses one fill across every raised block (long-doc runs three\n components off `--ivory`; resume runs `.team-culture` and `.os-highlight`\n off the same `--ivory`), so the page\n reads as one system used repeatedly. Drift looks different: a generated\n document invents a white rounded card for the question, then a tinted\n rounded block for the caveat, and the page now carries two unrelated\n container languages plus the template's own left-rule callout. Counting\n distinct resolved fills separates \"one form, used often\" from \"several\n forms, invented as the document went along\".\n\n Inline and floated rules are chips (tags, role pills), not block emphasis.\n \"\"\"\n css = _css_source(path)\n if not css.strip():\n return []\n root_vars = parse_root_vars(css)\n fills: dict[str, tuple[str, int]] = {}\n for match in CSS_RULE_RE.finditer(css):\n selector = \" \".join(match.group(1).split())\n body = match.group(2)\n if any(name == token or name.startswith(token + \"-\")\n for name in re.findall(r\"[\\w-]+\", selector.lower())\n for token in EMPHASIS_EXEMPT_SELECTORS):\n continue\n if INLINE_OR_FLOAT_DECL.search(body):\n continue\n bg = BACKGROUND_DECL.search(body)\n if not bg:\n continue\n value = bg.group(1).strip().lower()\n if any(empty in value for empty in EMPTY_BACKGROUNDS):\n continue\n if not (PADDING_DECL.search(body) and RADIUS_DECL.search(body)):\n continue\n resolved = _resolve_css_value(value, root_vars)\n line = css.count(\"\\n\", 0, match.start(1)) + 1\n fills.setdefault(resolved, (selector, line))\n\n if len(fills) \u003c 2:\n return []\n detail = \", \".join(f\"{sel} ({fill})\" for fill, (sel, _) in fills.items())\n last_line = max(line for _, line in fills.values())\n return [Finding(path, last_line, \"emphasis-container-mix\",\n f\"{len(fills)} different emphasis fills in one document ({detail}); \"\n \"a page raises passages one way, reused, not a new container per idea\")]\n\n\ndef check_style(paths: list[str], screen: bool | None = None) -> int:\n \"\"\"CLI: --check-style filled.html [more.html ...]\n\n Applies the template rule set to a produced document. Same rules, other end\n of the pipeline.\n \"\"\"\n files = [p for p in paths if not p.startswith(\"-\")]\n if not files:\n print(\"ERROR: usage: --check-style path/to/filled.html [more.html ...]\")\n return 2\n\n allowed = _load_token_values()\n failures = 0\n scanned = 0\n for raw in files:\n path = resolve_input(raw)\n if not path.exists():\n print(f\"ERROR: {raw}: file not found\")\n failures += 1\n continue\n scanned += 1\n rel = rel_to_root(path)\n findings = scan_file(path, screen=screen)\n findings.extend(_off_palette_findings(path, allowed))\n findings.extend(_emphasis_container_findings(path))\n if not findings:\n print(f\"OK: {rel}: no style drift\")\n continue\n failures += 1\n print(f\"ERROR: {rel}: {len(findings)} style finding(s)\")\n for f in findings:\n print(f\" {rel}:{f.line} [{f.rule}] {f.excerpt}\")\n\n if scanned == 0:\n print(\"ERROR: no documents scanned\")\n return 2\n return 0 if failures == 0 else 1\n\n\n# ---------- documented-snippet drift ----------\n#\n# The reference docs teach by example, and an agent copies those examples more\n# readily than it reads the templates. Nothing checked them, so CHEATSHEET.md\n# shipped a `.card` recipe that pairs a 0.5pt border with an 8pt radius (the\n# double-ring pitfall the linter fails templates for) against `--border-cream`,\n# a token that no longer exists. Every snippet the docs hand out is scanned\n# with the same rules the templates answer to, and every var() it names must\n# resolve to something real.\n\nFENCE_RE = re.compile(r\"^```(css|html)\\s*$(.*?)^```\\s*$\", re.DOTALL | re.MULTILINE)\nVAR_USE_RE = re.compile(r\"var\\(\\s*(--[\\w-]+)\")\n# A doc teaches by contrast: the wrong line sits next to the right one, tagged\n# in a comment. Blank the tagged line so the rule fires on the fix, not the bug\n# it is warning about.\nNEGATIVE_EXAMPLE_LINE = re.compile(\n r\"^.*/\\*\\s*(avoid|wrong|bad|never|don't|do not)\\s*\\*/.*$\",\n re.IGNORECASE | re.MULTILINE,\n)\n# Any SVG markup: colors inside SVG answer to the diagram palette, not the\n# print token set, and _off_palette_findings already blanks \u003csvg> blocks in\n# templates. A doc snippet showing \u003cdefs> without its \u003csvg> wrapper gets the\n# same treatment rather than a different verdict for the same markup.\nSVG_MARKUP_HINT = re.compile(r\"\u003c(svg|defs|pattern|circle|path|rect|polyline|marker)\\b\", re.IGNORECASE)\n# The white-paper variant is a documented product feature (production.md Part 1).\nDOC_SANCTIONED_HEXES = {\"#ffffff\", \"#fff\"}\n# A bare `--name: #hex;` line declares a token rather than spending a literal.\n# Templates get the same pass: _off_palette_findings blanks their :root block.\n# The palette chapter documents screen tokens too, which tokens.json (print\n# only) does not own, so scanning declarations would fail the chapter for\n# doing its job.\nTOKEN_DECL_LINE = re.compile(r\"^\\s*--[\\w-]+\\s*:\\s*[^;]+;.*$\", re.MULTILINE)\n\n\ndef _documented_snippets(text: str) -> list[tuple[int, str]]:\n \"\"\"Return [(first line number of the snippet body, snippet), ...].\"\"\"\n return [\n (text.count(\"\\n\", 0, m.start(2)) + 1, m.group(2))\n for m in FENCE_RE.finditer(text)\n ]\n\n\ndef _undefined_token_findings(\n path: Path, snippet: str, line_offset: int, defined: set[str]\n) -> list[Finding]:\n \"\"\"Flag var() references a reader cannot resolve.\n\n A snippet may define its own variables inline, so anything the snippet\n itself declares counts as defined.\n \"\"\"\n local = set(re.findall(r\"(--[\\w-]+)\\s*:\", snippet))\n findings: list[Finding] = []\n for i, line in enumerate(snippet.splitlines(), start=1):\n for m in VAR_USE_RE.finditer(line):\n name = m.group(1)\n if name in defined or name in local:\n continue\n findings.append(Finding(path, line_offset + i, \"undefined-token\",\n f\"{name} is not a registered token and is not defined in the snippet\"))\n return findings\n\n\ndef check_docs(paths: list[str]) -> int:\n \"\"\"CLI: --check-docs [doc.md ...]\n\n Scans the CSS and HTML snippets the reference docs teach from. With no\n arguments it walks every doc that carries snippets.\n \"\"\"\n targets = [p for p in paths if not p.startswith(\"-\")]\n if not targets:\n candidates = [ROOT / \"CHEATSHEET.md\", ROOT / \"SKILL.md\"]\n candidates += sorted((ROOT / \"references\").glob(\"*.md\"))\n targets = [str(p) for p in candidates if p.exists()]\n\n # A snippet may legitimately name any token the shipped templates define,\n # not just the print palette: landing pages carry their own screen tokens\n # (--warm-sand, --dark-surface) that tokens.json deliberately does not own.\n defined = set(_load_token_names())\n for template in sorted(TEMPLATES.glob(\"*.html\")):\n defined |= set(parse_root_vars(template.read_text(encoding=\"utf-8\", errors=\"replace\")))\n allowed = _load_token_values() | DOC_SANCTIONED_HEXES\n failures = 0\n scanned = 0\n for raw_path in targets:\n path = resolve_input(raw_path)\n if not path.exists():\n print(f\"ERROR: {raw_path}: file not found\")\n failures += 1\n continue\n text = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n snippets = _documented_snippets(text)\n if not snippets:\n continue\n scanned += 1\n rel = rel_to_root(path)\n findings: list[Finding] = []\n for line_offset, raw_snippet in snippets:\n snippet = _blank_block(raw_snippet, NEGATIVE_EXAMPLE_LINE)\n findings.extend(scan_text(snippet, path, line_offset))\n if not SVG_MARKUP_HINT.search(snippet):\n spent = _blank_block(snippet, TOKEN_DECL_LINE)\n findings.extend(_off_palette_findings(path, allowed, spent, line_offset))\n findings.extend(_undefined_token_findings(path, snippet, line_offset, defined))\n if not findings:\n print(f\"OK: {rel}: {len(snippets)} snippet(s) clean\")\n continue\n failures += 1\n print(f\"ERROR: {rel}: {len(findings)} finding(s) across {len(snippets)} snippet(s)\")\n for f in sorted(findings, key=lambda f: f.line):\n print(f\" {rel}:{f.line} [{f.rule}] {f.excerpt}\")\n\n if scanned == 0:\n print(\"ERROR: no documents with snippets scanned\")\n return 2\n return 0 if failures == 0 else 1\n\n\n# ---------- cross-template consistency ----------\n#\n# The project intentionally ships CN/EN templates as forked single-file HTML\n# (no shared partials). The price of that decision is drift: a maintainer\n# updating one side of a pair can silently leave the other behind. This check\n# pairs each base template (e.g. `foo.html`) with every recognized locale\n# variant (`foo-en.html`, `foo-ko.html`), parses the `:root { ... }` block of\n# each, and flags variables that differ. Font-stack variables (`--serif`,\n# `--sans`, `--mono`, `--latin-ui`) are allowlisted because each locale\n# deliberately uses different fonts.\n\n_VARIANT_SUFFIXES: tuple[str, ...] = (\"-en\", \"-ko\")\n\n\ndef _pair_names() -> list[tuple[str, str]]:\n \"\"\"Return [(base_name, variant_name), ...] for every base template that has\n one of the recognized locale-variant siblings (`-en`, `-ko`).\n\n A base template is any registered name that does not itself end in a\n recognized variant suffix.\n \"\"\"\n pairs: list[tuple[str, str]] = []\n seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES)\n for name in sorted(seen):\n if any(name.endswith(s) for s in _VARIANT_SUFFIXES):\n continue\n for suffix in _VARIANT_SUFFIXES:\n variant = f\"{name}{suffix}\"\n if variant in seen:\n pairs.append((name, variant))\n return pairs\n\n\ndef _source_for(name: str) -> Path:\n \"\"\"Return the source path for a template name across registries.\"\"\"\n if name in HTML_TEMPLATES:\n return TEMPLATES / HTML_TEMPLATES[name].source\n if name in SCREEN_TEMPLATES:\n return TEMPLATES / SCREEN_TEMPLATES[name]\n raise KeyError(f\"unknown template name: {name}\")\n\n\ndef _extract_root_vars(html_path: Path) -> dict[str, str]:\n \"\"\"Return {var_name: value} merged across every `:root { ... }` block.\"\"\"\n text = html_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n return parse_root_vars(text)\n\n\ndef check_cross_template_consistency(verbose: bool = False) -> int:\n pairs = _pair_names()\n if not pairs:\n print(\"ERROR: no base-variant template pairs found (bad checkout?)\")\n return 2\n drift: list[tuple[str, str, str, str]] = [] # (pair, var, base_value, variant_value)\n\n for base_name, variant_name in pairs:\n try:\n base_path = _source_for(base_name)\n variant_path = _source_for(variant_name)\n except KeyError:\n continue\n if not base_path.exists() or not variant_path.exists():\n continue\n\n base_vars = _extract_root_vars(base_path)\n variant_vars = _extract_root_vars(variant_path)\n\n shared_keys = set(base_vars) & set(variant_vars)\n for key in sorted(shared_keys):\n if key in CROSS_TEMPLATE_ALLOWED_VARS:\n continue\n if base_vars[key].lower() != variant_vars[key].lower():\n drift.append((base_name, key, base_vars[key], variant_vars[key]))\n\n # A var defined on only one side is drift too: it usually means a\n # maintainer added or dropped a token on one side of the fork. That is\n # exactly the \"left the other side behind\" failure this check exists\n # to catch, so report it instead of silently comparing the overlap.\n for key in sorted((set(base_vars) ^ set(variant_vars)) - CROSS_TEMPLATE_ALLOWED_VARS):\n if key in base_vars:\n drift.append((base_name, key, base_vars[key], f\"missing from {variant_name}\"))\n else:\n drift.append((base_name, key, f\"missing from {base_name}\", variant_vars[key]))\n\n if verbose:\n print(f\" pair {base_name}/{variant_name}: checked {len(shared_keys)} shared vars\")\n\n if not drift:\n print(f\"OK: cross-template :root vars in sync across {len(pairs)} base-variant pair(s)\")\n return 0\n\n print(f\"\\nERROR: [cross-template-drift] {len(drift)}\")\n for pair, var, base_val, variant_val in drift:\n print(f\" {pair}: {var} base={base_val} variant={variant_val}\")\n return 1\n","content_type":"text/x-python; charset=utf-8","language":"python","size":32773,"content_sha256":"a89c6e0fbec33c805120372e241a9a71ffab00f91bd5d49b8db32cc51b834693"},{"filename":"scripts/math_render.py","content":"#!/usr/bin/env python3\nr\"\"\"Strict standard-delimiter TeX rendering for Kami HTML documents.\n\nAuthor formulas as \\( inline \\) or \\[ display \\] in HTML text nodes.\nThe renderer replaces them with self-contained MathJax SVG and fails closed\nwhen delimiters, TeX, the locked runtime, or the renderer output is invalid.\n\nCLI:\n python3 scripts/math_render.py --in-place filled.html\n python3 scripts/math_render.py --check filled.html\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport functools\nimport html\nimport json\nimport os\nimport re\nimport shutil\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom html.parser import HTMLParser\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).resolve().parent\nNODE_RENDERER = SCRIPT_DIR / \"mathjax_svg.js\"\nIGNORED_TAGS = frozenset(\n {\n \"code\", \"datalist\", \"iframe\", \"kbd\", \"math\", \"mjx-container\",\n \"noembed\", \"noscript\", \"option\", \"plaintext\", \"pre\", \"samp\", \"script\",\n \"select\", \"style\", \"svg\", \"template\", \"textarea\", \"title\", \"xmp\",\n }\n)\nFOREIGN_SELF_CLOSING_TAGS = frozenset({\"math\", \"svg\"})\nMAX_FORMULAS = 500\nMAX_FORMULA_CHARS = 10 * 1024\nMAX_TOTAL_TEX_CHARS = 100 * 1024\nMAX_RENDER_OUTPUT_BYTES = 24 * 1024 * 1024\nNODE_TIMEOUT_SECONDS = 30\nENTITY_RE = re.compile(\n r\"&(?:#[xX][0-9A-Fa-f]+;?|#[0-9]+;?|[A-Za-z][A-Za-z0-9]+;?)\"\n)\nUNSAFE_SVG_ELEMENTS = frozenset(\n {\"a\", \"embed\", \"foreignobject\", \"iframe\", \"object\", \"script\"}\n)\n\n\nclass MathRenderError(RuntimeError):\n \"\"\"Raised when math source or renderer output is invalid.\"\"\"\n\n\nclass MathJaxUnavailable(MathRenderError):\n \"\"\"Raised when the locked Node.js MathJax runtime is unavailable.\"\"\"\n\n\n@dataclass(frozen=True)\nclass LatexSpan:\n start: int\n end: int\n tex: str\n display: bool\n\n\nclass _TextRangeParser(HTMLParser):\n \"\"\"Collect exact raw text-node ranges while excluding literal/code regions.\"\"\"\n\n def __init__(self, raw: str):\n super().__init__(convert_charrefs=False)\n self.raw = raw\n self.ranges: list[tuple[int, int]] = []\n self.issues: list[str] = []\n self.legacy_placeholders = 0\n self._active_start: int | None = None\n self._ignored_stack: list[str] = []\n # HTMLParser advances its line number only on LF, not Unicode separators.\n self._line_offsets = [0] + [match.end() for match in re.finditer(\"\\n\", raw)]\n\n def _offset(self) -> int:\n line, column = self.getpos()\n return self._line_offsets[line - 1] + column\n\n def _start_text(self) -> None:\n if not self._ignored_stack and self._active_start is None:\n self._active_start = self._offset()\n\n def _finish_text(self, end: int) -> None:\n if self._active_start is not None and end > self._active_start:\n self.ranges.append((self._active_start, end))\n self._active_start = None\n\n def handle_data(self, _data: str) -> None:\n self._start_text()\n\n def handle_entityref(self, _name: str) -> None:\n self._start_text()\n\n def handle_charref(self, _name: str) -> None:\n self._start_text()\n\n def _check_legacy_class(self, attrs) -> None:\n if self._ignored_stack:\n return\n for name, value in attrs:\n if name.lower() != \"class\" or not value:\n continue\n tokens = {token.casefold() for token in value.split()}\n if tokens & {\"latex-inline\", \"latex-display\"}:\n self.legacy_placeholders += 1\n\n def handle_starttag(self, tag: str, attrs) -> None:\n self._finish_text(self._offset())\n tag = tag.lower()\n # HTML permits an option end tag to be omitted before a following\n # option or optgroup. Mirror that tree-builder rule so ordinary form\n # markup does not make strict document rendering fail.\n if self._ignored_stack and self._ignored_stack[-1] == \"option\":\n if tag in {\"option\", \"optgroup\"}:\n self._ignored_stack.pop()\n if tag not in IGNORED_TAGS:\n self._check_legacy_class(attrs)\n if tag in IGNORED_TAGS:\n self._ignored_stack.append(tag)\n\n def handle_startendtag(self, tag: str, attrs) -> None:\n self._finish_text(self._offset())\n tag = tag.lower()\n if tag not in IGNORED_TAGS:\n self._check_legacy_class(attrs)\n if tag in IGNORED_TAGS and tag not in FOREIGN_SELF_CLOSING_TAGS:\n self.issues.append(\n f\"self-closing \u003c{tag}/> is ambiguous for formula scanning\"\n )\n\n def handle_endtag(self, tag: str) -> None:\n self._finish_text(self._offset())\n tag = tag.lower()\n # The option end tag may also be omitted before the parent select or\n # datalist closes, and before an optgroup closes.\n if self._ignored_stack and self._ignored_stack[-1] == \"option\":\n if tag in {\"datalist\", \"optgroup\", \"select\"}:\n self._ignored_stack.pop()\n if tag not in IGNORED_TAGS or not self._ignored_stack:\n return\n if tag == self._ignored_stack[-1]:\n self._ignored_stack.pop()\n return\n self.issues.append(\n f\"mismatched ignored HTML closing tag \u003c/{tag}> inside \"\n f\"\u003c{self._ignored_stack[-1]}>\"\n )\n\n def handle_comment(self, _data: str) -> None:\n self._finish_text(self._offset())\n\n def handle_decl(self, _decl: str) -> None:\n self._finish_text(self._offset())\n\n def handle_pi(self, _data: str) -> None:\n self._finish_text(self._offset())\n\n def finish(self) -> list[tuple[int, int]]:\n self._finish_text(len(self.raw))\n if self._ignored_stack:\n self.issues.append(\n \"unclosed ignored HTML tag(s): \"\n + \", \".join(f\"\u003c{tag}>\" for tag in self._ignored_stack)\n )\n return self.ranges\n\n\nclass _SvgSafetyParser(HTMLParser):\n \"\"\"Inspect MathJax's HTML-serialized SVG without substring false positives.\"\"\"\n\n def __init__(self):\n super().__init__(convert_charrefs=False)\n self.root_name: str | None = None\n self.root_count = 0\n self.saw_svg = False\n self.unsafe = False\n self.malformed = False\n self._stack: list[str] = []\n\n def _inspect(self, tag: str, attrs) -> None:\n name = tag.casefold()\n if self.root_name is None:\n self.root_name = name\n self.saw_svg = self.saw_svg or name == \"svg\"\n self.unsafe = self.unsafe or name in UNSAFE_SVG_ELEMENTS\n for raw_name, value in attrs:\n attribute = raw_name.rsplit(\":\", 1)[-1].casefold()\n if attribute in {\"href\", \"src\"} or attribute.startswith(\"on\"):\n self.unsafe = True\n if attribute == \"style\" and value and re.search(\n r\"(?:javascript\\s*:|url\\s*\\()\", value, re.IGNORECASE\n ):\n self.unsafe = True\n\n def handle_starttag(self, tag: str, attrs) -> None:\n if not self._stack:\n self.root_count += 1\n self._inspect(tag, attrs)\n self._stack.append(tag.casefold())\n\n def handle_startendtag(self, tag: str, attrs) -> None:\n if not self._stack:\n self.root_count += 1\n self._inspect(tag, attrs)\n\n def handle_endtag(self, tag: str) -> None:\n name = tag.casefold()\n if not self._stack or self._stack[-1] != name:\n self.malformed = True\n return\n self._stack.pop()\n\n def handle_data(self, data: str) -> None:\n if not self._stack and data.strip():\n self.malformed = True\n\n def handle_decl(self, _decl: str) -> None:\n self.unsafe = True\n\n def handle_pi(self, _data: str) -> None:\n self.unsafe = True\n\n\ndef _next_delimiter(\n text: str,\n start: int,\n accepted: str,\n) -> tuple[int, str, int] | None:\n \"\"\"Return (delimiter backslash, marker, index after marker) in linear time.\"\"\"\n cursor = start\n while cursor \u003c len(text):\n slash = text.find(\"\\\\\", cursor)\n if slash \u003c 0:\n return None\n run_end = slash + 1\n while run_end \u003c len(text) and text[run_end] == \"\\\\\":\n run_end += 1\n run_length = run_end - slash\n if (\n run_length % 2 == 1\n and run_end \u003c len(text)\n and text[run_end] in accepted\n ):\n return run_end - 1, text[run_end], run_end + 1\n cursor = run_end\n return None\n\n\ndef _raw_boundary_offsets(\n text: str,\n decoded: str,\n requested: set[int],\n) -> dict[int, int]:\n \"\"\"Map only requested decoded boundaries back to raw HTML offsets.\"\"\"\n if not requested:\n return {}\n if \"&\" not in text:\n return {boundary: boundary for boundary in requested}\n\n targets = iter(sorted(requested))\n target = next(targets, None)\n offsets: dict[int, int] = {}\n raw_cursor = 0\n decoded_cursor = 0\n for match in ENTITY_RE.finditer(text):\n plain_length = match.start() - raw_cursor\n plain_end = decoded_cursor + plain_length\n while target is not None and target \u003c= plain_end:\n offsets[target] = raw_cursor + (target - decoded_cursor)\n target = next(targets, None)\n decoded_cursor = plain_end\n\n token = match.group(0)\n value = html.unescape(token)\n if value == token:\n entity_length = len(token)\n entity_end = decoded_cursor + entity_length\n while target is not None and target \u003c= entity_end:\n offsets[target] = match.start() + (target - decoded_cursor)\n target = next(targets, None)\n else:\n entity_length = len(value)\n entity_end = decoded_cursor + entity_length\n while target is not None and target \u003c= entity_end:\n offsets[target] = (\n match.end() if target == entity_end else match.start()\n )\n target = next(targets, None)\n decoded_cursor = entity_end\n raw_cursor = match.end()\n\n plain_length = len(text) - raw_cursor\n plain_end = decoded_cursor + plain_length\n while target is not None and target \u003c= plain_end:\n offsets[target] = raw_cursor + (target - decoded_cursor)\n target = next(targets, None)\n if target is not None or plain_end != len(decoded):\n raise MathRenderError(\"HTML entity mapping is ambiguous in a formula text node\")\n return offsets\n\n\ndef _preflight_text_range(\n text: str,\n formula_budget: int,\n tex_budget: int,\n) -> str | None:\n \"\"\"Reject formula-count and source-size excess before allocating span maps.\"\"\"\n decoded = html.unescape(text)\n formulas = 0\n tex_chars = 0\n cursor = 0\n saw_delimiter = False\n while delimiter := _next_delimiter(decoded, cursor, \"([)]\"):\n saw_delimiter = True\n _delimiter_start, marker, content_start = delimiter\n if marker in \")]\":\n cursor = content_start\n continue\n closing = \"]\" if marker == \"[\" else \")\"\n closing_delimiter = _next_delimiter(decoded, content_start, closing)\n if closing_delimiter is None:\n return decoded\n closing_start, _closing_marker, after_closing = closing_delimiter\n formula_chars = closing_start - content_start\n formulas += 1\n if formulas > formula_budget:\n raise MathRenderError(\n f\"more than {MAX_FORMULAS} formulas exceed the formula safety limit\"\n )\n if formula_chars > MAX_FORMULA_CHARS:\n raise MathRenderError(\n f\"formula exceeds the {MAX_FORMULA_CHARS}-character safety limit\"\n )\n tex_chars += formula_chars\n if tex_chars > tex_budget:\n raise MathRenderError(\n f\"formula source exceeds the {MAX_TOTAL_TEX_CHARS}-character safety limit\"\n )\n cursor = after_closing\n return decoded if saw_delimiter else None\n\n\ndef _scan_text_range(\n text: str,\n absolute_start: int,\n formula_budget: int,\n tex_budget: int,\n) -> tuple[list[LatexSpan], list[str], int]:\n decoded = _preflight_text_range(text, formula_budget, tex_budget)\n if decoded is None:\n return [], [], 0\n decoded_spans: list[tuple[int, int, str, bool]] = []\n issue_records: list[tuple[str, int]] = []\n delimiter_events = 0\n tex_chars = 0\n cursor = 0\n while delimiter := _next_delimiter(decoded, cursor, \"([)]\"):\n delimiter_start, marker, content_start = delimiter\n delimiter_events += 1\n if delimiter_events > formula_budget:\n raise MathRenderError(\n f\"more than {MAX_FORMULAS} TeX delimiters exceed the safety limit\"\n )\n if marker in \")]\":\n mode = \"inline\" if marker == \")\" else \"display\"\n issue_records.append(\n (f\"unmatched {mode} TeX closing delimiter\", delimiter_start)\n )\n cursor = content_start\n continue\n display = marker == \"[\"\n closing = \"]\" if display else \")\"\n closing_delimiter = _next_delimiter(decoded, content_start, closing)\n if closing_delimiter is None:\n mode = \"display\" if display else \"inline\"\n issue_records.append(\n (f\"unclosed {mode} TeX delimiter\", delimiter_start)\n )\n break\n closing_start, _closing_marker, after_closing = closing_delimiter\n tex = decoded[content_start:closing_start]\n if not tex.strip():\n issue_records.append(\n (\n f\"empty {'display' if display else 'inline'} TeX formula\",\n delimiter_start,\n )\n )\n if len(tex) > MAX_FORMULA_CHARS:\n raise MathRenderError(\n f\"formula exceeds the {MAX_FORMULA_CHARS}-character safety limit\"\n )\n tex_chars += len(tex)\n if tex_chars > tex_budget:\n raise MathRenderError(\n f\"formula source exceeds the {MAX_TOTAL_TEX_CHARS}-character safety limit\"\n )\n decoded_spans.append((delimiter_start, after_closing, tex, display))\n cursor = after_closing\n requested = {\n boundary\n for start, end, _tex, _display in decoded_spans\n for boundary in (start, end)\n }\n requested.update(position for _message, position in issue_records)\n raw_offsets = _raw_boundary_offsets(text, decoded, requested)\n spans = [\n LatexSpan(\n absolute_start + raw_offsets[start],\n absolute_start + raw_offsets[end],\n tex,\n display,\n )\n for start, end, tex, display in decoded_spans\n ]\n issues = [\n f\"{message} at character {absolute_start + raw_offsets[position] + 1}\"\n for message, position in issue_records\n ]\n return spans, issues, delimiter_events\n\n\ndef _latex_spans(raw: str) -> tuple[list[LatexSpan], list[str]]:\n parser = _TextRangeParser(raw)\n try:\n parser.feed(raw)\n parser.close()\n except Exception as exc:\n raise MathRenderError(f\"could not parse HTML text nodes: {exc}\") from exc\n\n ranges = parser.finish()\n spans: list[LatexSpan] = []\n issues: list[str] = list(parser.issues)\n delimiter_events = 0\n total_tex_chars = 0\n for start, end in ranges:\n found, range_issues, range_events = _scan_text_range(\n raw[start:end],\n start,\n MAX_FORMULAS - delimiter_events,\n MAX_TOTAL_TEX_CHARS - total_tex_chars,\n )\n spans.extend(found)\n delimiter_events += range_events\n total_tex_chars += sum(len(span.tex) for span in found)\n issues.extend(range_issues)\n if parser.legacy_placeholders:\n issues.append(\n f\"{parser.legacy_placeholders} legacy LaTeX placeholder(s) remain\"\n )\n return spans, issues\n\n\ndef _node_environment() -> dict[str, str]:\n environment = os.environ.copy()\n environment.pop(\"NODE_OPTIONS\", None)\n return environment\n\n\[email protected]_cache(maxsize=4)\ndef _validated_node(node: str) -> str:\n \"\"\"Reject Node releases outside the locked runtime's engine contract.\"\"\"\n try:\n result = subprocess.run(\n [node, \"-p\", 'process.versions.node.split(\".\")[0]'],\n capture_output=True,\n text=True,\n timeout=3,\n check=False,\n env=_node_environment(),\n )\n except (OSError, subprocess.TimeoutExpired) as exc:\n raise MathJaxUnavailable(f\"could not inspect Node.js: {exc}\") from exc\n try:\n major = int(result.stdout.strip())\n except ValueError as exc:\n detail = result.stderr.strip() or result.stdout.strip() or \"no version output\"\n raise MathJaxUnavailable(\n f\"could not inspect Node.js version: {detail[:500]}\"\n ) from exc\n if result.returncode or major \u003c 20 or major == 21:\n raise MathJaxUnavailable(\n \"strict LaTeX rendering requires Node.js 20 or Node.js 22+\"\n )\n return node\n\n\ndef _node_command(*args: str) -> list[str]:\n node = shutil.which(\"node\")\n if not node:\n raise MathJaxUnavailable(\n \"Node.js is unavailable; install Node.js, then run \"\n \"'bash scripts/ensure_mathjax.sh'.\"\n )\n if not NODE_RENDERER.is_file():\n raise MathJaxUnavailable(f\"MathJax renderer missing: {NODE_RENDERER}\")\n return [\n _validated_node(node),\n \"--max-old-space-size=256\",\n str(NODE_RENDERER),\n *args,\n ]\n\n\ndef probe_mathjax() -> dict:\n \"\"\"Return the locked runtime's availability without installing anything.\"\"\"\n try:\n command = _node_command(\"--probe\")\n except MathJaxUnavailable as exc:\n return {\"status\": \"missing\", \"version\": None, \"detail\": str(exc)}\n try:\n result = subprocess.run(\n command,\n capture_output=True,\n timeout=5,\n check=False,\n env=_node_environment(),\n )\n except (OSError, subprocess.TimeoutExpired) as exc:\n return {\n \"status\": \"degraded\",\n \"version\": None,\n \"detail\": f\"MathJax probe failed: {type(exc).__name__}: {exc}\",\n }\n if result.returncode:\n # Keep the doctor line short: Node prints a full require stack for a\n # missing module, and only its first line names the cause.\n stderr = result.stderr.decode(\"utf-8\", errors=\"replace\").strip()\n cause = next((line.strip() for line in stderr.splitlines() if line.strip()), \"\")\n cause = cause.removeprefix(\"MathJax render failed:\").strip()\n return {\n \"status\": \"missing\",\n \"version\": None,\n \"detail\": (\n f\"locked MathJax runtime unavailable ({cause[:300] or 'probe process failed'}); \"\n \"run 'bash scripts/ensure_mathjax.sh'\"\n ),\n }\n try:\n payload = json.loads(result.stdout.decode(\"utf-8\"))\n version = payload[\"version\"]\n except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as exc:\n return {\n \"status\": \"degraded\",\n \"version\": None,\n \"detail\": f\"invalid MathJax probe output: {exc}\",\n }\n return {\"status\": \"available\", \"version\": str(version)}\n\n\ndef _render_svg(formulas: list[dict]) -> list[str]:\n payload = json.dumps(formulas, ensure_ascii=False).encode(\"utf-8\")\n try:\n result = subprocess.run(\n _node_command(),\n input=payload,\n capture_output=True,\n timeout=NODE_TIMEOUT_SECONDS,\n check=False,\n env=_node_environment(),\n )\n except subprocess.TimeoutExpired as exc:\n raise MathRenderError(\n f\"MathJax exceeded the {NODE_TIMEOUT_SECONDS}-second safety timeout\"\n ) from exc\n except OSError as exc:\n raise MathJaxUnavailable(f\"could not start MathJax: {exc}\") from exc\n if result.returncode:\n detail = result.stderr.decode(\"utf-8\", errors=\"replace\").strip()\n if \"Cannot find module\" in detail or \"@mathjax/src\" in detail:\n raise MathJaxUnavailable(\n \"locked MathJax runtime unavailable; run \"\n \"'bash scripts/ensure_mathjax.sh'. \"\n + (detail[:3000] or \"renderer process failed\")\n )\n raise MathRenderError(detail[:3000] or \"MathJax process failed\")\n if len(result.stdout) > MAX_RENDER_OUTPUT_BYTES:\n raise MathRenderError(\n f\"MathJax output exceeds the {MAX_RENDER_OUTPUT_BYTES}-byte safety limit\"\n )\n try:\n rendered = json.loads(result.stdout.decode(\"utf-8\"))\n except (UnicodeDecodeError, json.JSONDecodeError) as exc:\n raise MathRenderError(f\"invalid MathJax output: {exc}\") from exc\n if not isinstance(rendered, list) or len(rendered) != len(formulas):\n raise MathRenderError(\"MathJax returned a mismatched formula count\")\n for index, fragment in enumerate(rendered, start=1):\n if not isinstance(fragment, str):\n raise MathRenderError(f\"formula {index} returned an invalid SVG fragment\")\n inspector = _SvgSafetyParser()\n try:\n inspector.feed(fragment)\n inspector.close()\n except Exception as exc:\n raise MathRenderError(\n f\"formula {index} returned malformed SVG: {exc}\"\n ) from exc\n if (\n inspector.root_name != \"mjx-container\"\n or inspector.root_count != 1\n or not inspector.saw_svg\n or inspector.unsafe\n or inspector.malformed\n or inspector._stack\n or not fragment.rstrip().endswith(\"\u003c/mjx-container>\")\n ):\n raise MathRenderError(f\"formula {index} returned unsafe SVG content\")\n return rendered\n\n\ndef render_latex_in_html(raw: str) -> str:\n \"\"\"Replace standard TeX delimiters in HTML text nodes with strict SVG.\"\"\"\n return _render_latex_counted(raw)[0]\n\n\ndef _render_latex_counted(raw: str) -> tuple[str, int]:\n \"\"\"Return (rendered HTML, formula count) from a single span scan.\"\"\"\n spans, issues = _latex_spans(raw)\n if issues:\n raise MathRenderError(\"; \".join(issues))\n if not spans:\n return raw, 0\n formulas = [{\"tex\": span.tex, \"display\": span.display} for span in spans]\n fragments = _render_svg(formulas)\n pieces: list[str] = []\n cursor = 0\n for span, fragment in zip(spans, fragments):\n pieces.append(raw[cursor:span.start])\n if span.display:\n pieces.append(\n '\u003cspan class=\"latex-display-svg\" '\n 'style=\"display:block;text-align:center;margin:10pt 0;'\n 'break-inside:avoid;\">'\n + fragment\n + \"\u003c/span>\"\n )\n else:\n pieces.append(\n '\u003cspan class=\"latex-inline-svg\" '\n 'style=\"display:inline-block;vertical-align:middle;'\n 'white-space:nowrap;\">'\n + fragment\n + \"\u003c/span>\"\n )\n cursor = span.end\n pieces.append(raw[cursor:])\n return \"\".join(pieces), len(spans)\n\n\ndef check_latex_html(raw: str) -> list[str]:\n \"\"\"Return every raw, unmatched, or legacy formula-source issue.\"\"\"\n spans, issues = _latex_spans(raw)\n if spans:\n issues.insert(0, f\"{len(spans)} raw LaTeX formula delimiter pair(s) remain\")\n return issues\n\n\ndef check_latex_file(argv: list[str]) -> int:\n \"\"\"CLI-shaped formula-source check shared with the MCP server.\"\"\"\n if len(argv) != 1:\n print(\"ERROR: usage: math check path/to/filled.html\")\n return 2\n path = Path(argv[0])\n if not path.is_file():\n print(f\"ERROR: {path} not found\")\n return 2\n try:\n issues = check_latex_html(path.read_text(encoding=\"utf-8\"))\n except (MathRenderError, OSError, UnicodeError) as exc:\n print(f\"ERROR: {path}: {exc}\")\n return 1\n if issues:\n print(f\"ERROR: {path}: \" + \"; \".join(issues))\n return 1\n print(f\"OK: {path}: no raw or unrendered LaTeX\")\n return 0\n\n\ndef _atomic_write_text(path: Path, content: str) -> None:\n if path.is_symlink():\n raise MathRenderError(f\"refusing to replace symlink in place: {path}\")\n mode = stat.S_IMODE(path.stat().st_mode)\n temporary: Path | None = None\n try:\n descriptor, name = tempfile.mkstemp(\n dir=path.parent,\n prefix=f\".{path.name}.\",\n suffix=\".tmp\",\n )\n temporary = Path(name)\n with os.fdopen(descriptor, \"w\", encoding=\"utf-8\", newline=\"\") as stream:\n stream.write(content)\n stream.flush()\n os.fsync(stream.fileno())\n os.chmod(temporary, mode)\n os.replace(temporary, path)\n temporary = None\n finally:\n if temporary is not None:\n temporary.unlink(missing_ok=True)\n\n\ndef main(argv: list[str]) -> int:\n parser = argparse.ArgumentParser()\n group = parser.add_mutually_exclusive_group(required=True)\n group.add_argument(\"--in-place\", type=Path)\n group.add_argument(\"--check\", type=Path)\n args = parser.parse_args(argv)\n path = args.in_place or args.check\n if not path.is_file():\n print(f\"ERROR: {path} not found\")\n return 1\n try:\n raw = path.read_text(encoding=\"utf-8\")\n except (OSError, UnicodeError) as exc:\n print(f\"ERROR: could not read {path}: {exc}\")\n return 1\n if args.in_place:\n try:\n rendered, count = _render_latex_counted(raw)\n if rendered != raw:\n _atomic_write_text(path, rendered)\n except (MathRenderError, OSError) as exc:\n print(f\"ERROR: strict LaTeX rendering failed: {exc}\")\n return 1\n print(f\"OK: {path}: rendered {count} LaTeX formula(s) to SVG\")\n return 0\n try:\n issues = check_latex_html(raw)\n except MathRenderError as exc:\n issues = [str(exc)]\n if issues:\n print(f\"ERROR: {path}: \" + \"; \".join(issues))\n return 1\n print(f\"OK: {path}: no raw or unrendered LaTeX\")\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main(sys.argv[1:]))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26307,"content_sha256":"d5501c7546af11a4fd69e498303dcd42d2601e966822a868db8ee89ebaa2a21f"},{"filename":"scripts/mathjax_svg.js","content":"#!/usr/bin/env node\n/* Render TeX formulas from stdin JSON to self-contained MathJax SVG fragments.\n * Input: [{\"tex\":\"x^2\", \"display\":true}, ...]\n * Output: [\"\u003cmjx-container ...>...\u003c/mjx-container>\", ...]\n */\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst nodeMajor = Number.parseInt(process.versions.node.split(\".\")[0], 10);\nif (!Number.isInteger(nodeMajor) || nodeMajor \u003c 20 || nodeMajor === 21) {\n process.stderr.write(\n \"MathJax render failed: strict LaTeX rendering requires Node.js 20 or Node.js 22+\\n\",\n );\n process.exit(1);\n}\n\nconst runtimePackage = require(\"./mathjax-runtime/package.json\");\nconst mathJaxVersion = runtimePackage.dependencies[\"@mathjax/src\"];\nconst cacheHome = process.platform === \"darwin\"\n ? path.join(os.homedir(), \".cache\")\n : (process.env.XDG_CACHE_HOME || path.join(os.homedir(), \".cache\"));\nconst mathRoot = path.join(cacheHome, \"kami\", \"mathjax\", mathJaxVersion);\nconst load = (id) => require(require.resolve(id, { paths: [mathRoot] }));\n\nconst packageModules = [\n \"ams/AmsConfiguration.js\",\n \"boldsymbol/BoldsymbolConfiguration.js\",\n \"braket/BraketConfiguration.js\",\n \"cancel/CancelConfiguration.js\",\n \"cases/CasesConfiguration.js\",\n \"centernot/CenternotConfiguration.js\",\n \"empheq/EmpheqConfiguration.js\",\n \"enclose/EncloseConfiguration.js\",\n \"extpfeil/ExtpfeilConfiguration.js\",\n \"gensymb/GensymbConfiguration.js\",\n \"mathtools/MathtoolsConfiguration.js\",\n \"mhchem/MhchemConfiguration.js\",\n \"newcommand/NewcommandConfiguration.js\",\n \"physics/PhysicsConfiguration.js\",\n \"tagformat/TagFormatConfiguration.js\",\n \"textcomp/TextcompConfiguration.js\",\n \"textmacros/TextMacrosConfiguration.js\",\n \"unicode/UnicodeConfiguration.js\",\n \"units/UnitsConfiguration.js\",\n \"upgreek/UpgreekConfiguration.js\",\n];\nconst packages = [\n \"base\", \"ams\", \"boldsymbol\", \"braket\", \"cancel\", \"cases\", \"centernot\",\n \"empheq\", \"enclose\", \"extpfeil\", \"gensymb\", \"mathtools\",\n \"mhchem\", \"newcommand\", \"physics\", \"tagformat\", \"textcomp\", \"textmacros\",\n \"unicode\", \"units\", \"upgreek\",\n];\n\ntry {\n const installed = load(\"@mathjax/src/package.json\");\n if (installed.version !== mathJaxVersion) {\n throw new Error(`expected @mathjax/src ${mathJaxVersion}, found ${installed.version}`);\n }\n const { mathjax } = load(\"@mathjax/src/js/mathjax.js\");\n const { TeX } = load(\"@mathjax/src/js/input/tex.js\");\n const { SVG } = load(\"@mathjax/src/js/output/svg.js\");\n const { liteAdaptor } = load(\"@mathjax/src/js/adaptors/liteAdaptor.js\");\n const { RegisterHTMLHandler } = load(\"@mathjax/src/js/handlers/html.js\");\n const { SafeHandler } = load(\"@mathjax/src/js/ui/safe/SafeHandler.js\");\n for (const moduleName of packageModules) {\n load(`@mathjax/src/js/input/tex/${moduleName}`);\n }\n\n const adaptor = liteAdaptor();\n const handler = RegisterHTMLHandler(adaptor);\n SafeHandler(handler);\n const tex = new TeX({\n packages,\n maxBuffer: 10 * 1024,\n formatError(_jax, err) {\n throw err;\n },\n });\n // `none` keeps every SVG self-contained, avoiding cross-formula path IDs.\n const svg = new SVG({ fontCache: \"none\" });\n const doc = mathjax.document(\"\", {\n InputJax: tex,\n OutputJax: svg,\n compileError(_document, _math, err) {\n throw err;\n },\n safeOptions: {\n allow: { URLs: \"none\", classes: \"none\", cssIDs: \"none\", styles: \"none\" },\n },\n });\n if (process.argv.includes(\"--probe\")) {\n const probeNode = doc.convert(\"x\", { display: false });\n const probeOutput = adaptor.outerHTML(probeNode);\n if (!probeOutput.startsWith(\"\u003cmjx-container\") || !probeOutput.includes(\"\u003csvg\")) {\n throw new Error(\"locked MathJax runtime failed its SVG smoke render\");\n }\n process.stdout.write(JSON.stringify({ version: installed.version, root: mathRoot }));\n process.exit(0);\n }\n\n const formulas = JSON.parse(fs.readFileSync(0, \"utf8\"));\n if (!Array.isArray(formulas)) {\n throw new Error(\"input must be a JSON array\");\n }\n const rendered = formulas.map(({ tex: source, display }, index) => {\n if (typeof source !== \"string\" || typeof display !== \"boolean\") {\n throw new Error(`formula ${index + 1} must contain string tex and boolean display`);\n }\n const node = doc.convert(source, { display });\n const output = adaptor.outerHTML(node);\n if (output.includes(\"data-mjx-error\") || output.includes(\"\u003cmerror\")) {\n throw new Error(`formula ${index + 1} produced a MathJax error node`);\n }\n return output;\n });\n process.stdout.write(JSON.stringify(rendered));\n} catch (err) {\n process.stderr.write(`MathJax render failed: ${err.stack || err.message}\\n`);\n process.exit(1);\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4646,"content_sha256":"b3c6f12aa905c66a857756135031f3e66559934bfc1ef1015b9ae8fc9ad44b21"},{"filename":"scripts/mathjax-runtime/package.json","content":"{\n \"name\": \"kami-mathjax-runtime\",\n \"private\": true,\n \"version\": \"1.0.0\",\n \"engines\": {\n \"node\": \"20 || >=22\"\n },\n \"dependencies\": {\n \"@mathjax/src\": \"4.1.3\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":176,"content_sha256":"5eb7b6e39adde0c20940fa262dfa34af190c17fcdfc1de29de84b7c0d1c2d4ef"},{"filename":"scripts/mcp_server.py","content":"#!/usr/bin/env python3\n\"\"\"kami MCP server: render, check, and screenshot tools over stdio JSON-RPC.\n\nLets any MCP-capable agent use Kami as an engine (render + verify) without\nreading SKILL.md: judgment stays in the skill prompt, execution lives here.\nRender only trusted local HTML: referenced file, HTTP, and HTTPS resources load\nwith this process's filesystem and network permissions.\nZero third-party dependencies for the protocol itself; tools that need\nweasyprint / pypdf / PyMuPDF surface the install hint as a tool error\ninstead of crashing the server.\n\nRegister with an MCP client, for example:\n claude mcp add kami -- python3 /absolute/path/to/kami/scripts/mcp_server.py\n\nTools:\n kami_templates discover templates, diagram library, content schema types\n kami_doctor report installed render, check, PPTX, and font capabilities\n kami_render render a filled HTML file to PDF (WeasyPrint + highlight)\n kami_check run deterministic checks with stable findings and coverage\n kami_deliver finish a document: every gate in order, render, one READY verdict\n kami_screenshot rasterize a PDF to page PNGs plus the review checklist\n\nTransport: newline-delimited JSON-RPC 2.0 on stdin/stdout (MCP stdio).\nAll check output is captured; nothing but protocol frames touches stdout.\n\"\"\"\nfrom __future__ import annotations\n\nimport contextlib\nimport io\nimport json\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom chart import check_charts\nfrom checks import (\n check_density,\n check_markdown_residue,\n check_orphans,\n check_placeholders,\n)\nfrom content import check_content\nfrom deliver import deliver\nfrom lint import check_style\nfrom math_render import check_latex_file\nfrom optional_deps import MissingDepError, doctor_report\nfrom render import render_pdf\nfrom shared import (\n DIAGRAM_TEMPLATES,\n HTML_TEMPLATES,\n MARP_TEMPLATES,\n PPTX_TEMPLATES,\n ROOT,\n SCREEN_TEMPLATES,\n content_schema_types,\n kami_version,\n)\nfrom visual import MAX_DPI, MIN_DPI, REVIEW_CHECKLIST, render_pages\nfrom verify import check_fonts\n\nPROTOCOL_VERSION = \"2025-11-25\"\nSUPPORTED_PROTOCOL_VERSIONS = {\"2024-11-05\", \"2025-03-26\", \"2025-06-18\", \"2025-11-25\"}\n\nCHECK_RULESET_VERSION = 4\nCHECK_REGISTRY = {\n \"html.placeholders\": {\n \"scope\": \"html\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Completed HTML must not expose unresolved template placeholders.\",\n },\n \"html.markdown-residue\": {\n \"scope\": \"html\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Rendered audience copy must not expose raw Markdown syntax.\",\n },\n \"html.math\": {\n \"scope\": \"html\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Completed HTML must not expose raw or invalid LaTeX source.\",\n },\n \"html.charts\": {\n \"scope\": \"html\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Every generated data chart must still match the data spec it embeds.\",\n },\n \"html.style\": {\n \"scope\": \"html\", \"severity\": \"warning\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Document CSS must keep to the template rule set (palette, emphasis, rules).\",\n },\n \"content.contract\": {\n \"scope\": \"content-ir\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Content IR must satisfy its document schema and optional artifact brief.\",\n },\n \"content.coverage\": {\n \"scope\": \"html+content-ir\", \"severity\": \"error\", \"required_engine\": \"stdlib\",\n \"explanation\": \"Every atomic fact and required asset in content IR must survive into the document.\",\n },\n \"pdf.markdown-residue\": {\n \"scope\": \"pdf\", \"severity\": \"error\", \"required_engine\": \"pypdf\",\n \"explanation\": \"Extracted PDF text must not expose raw Markdown syntax.\",\n },\n \"pdf.fonts\": {\n \"scope\": \"pdf\", \"severity\": \"error\", \"required_engine\": \"pymupdf\",\n \"explanation\": \"CJK text must be drawn by a serif from the template stack, not a fallback face.\",\n },\n \"pdf.orphans\": {\n \"scope\": \"pdf\", \"severity\": \"warning\", \"required_engine\": \"pymupdf\",\n \"explanation\": \"Rendered text blocks must not end in short orphan lines.\",\n },\n \"pdf.density\": {\n \"scope\": \"pdf\", \"severity\": \"warning\", \"required_engine\": \"pymupdf\",\n \"explanation\": \"Rendered pages must not carry excessive trailing whitespace.\",\n },\n}\n\n# Every gate `--deliver` runs, and the kami_check rule that covers it on its\n# own. Gates that need the fresh render or the detected template family live\n# only in kami_deliver. test_mcp asserts this map names every deliver gate, so\n# a new gate cannot silently skip the MCP surface.\nDELIVER_GATE_RULES = {\n \"placeholders\": \"html.placeholders\",\n \"math\": \"html.math\",\n \"markdown residue (html)\": \"html.markdown-residue\",\n \"data charts\": \"html.charts\",\n \"template style\": \"html.style\",\n \"content coverage\": \"content.coverage\",\n \"fonts\": \"pdf.fonts\",\n \"markdown residue (pdf)\": \"pdf.markdown-residue\",\n \"density\": \"pdf.density\",\n \"orphans\": \"pdf.orphans\",\n}\nDELIVER_ONLY_GATES = {\"render\", \"page contract\", \"page target\", \"resume balance\", \"page images\",\n \"screen template\"}\n\nTOOLS = [\n {\n \"name\": \"kami_templates\",\n \"description\": (\n \"List Kami document templates, browser-only templates, the diagram \"\n \"library, and content schema types, with the reference docs to read \"\n \"before filling.\"\n ),\n \"inputSchema\": {\"type\": \"object\", \"properties\": {}},\n },\n {\n \"name\": \"kami_doctor\",\n \"description\": (\n \"Report whether this installed Kami runtime can render PDFs, run \"\n \"visual checks, build editable PPTX fallback decks, and resolve \"\n \"the expected font families. Read-only; missing capabilities are \"\n \"reported explicitly rather than treated as clean checks.\"\n ),\n \"inputSchema\": {\"type\": \"object\", \"properties\": {}},\n },\n {\n \"name\": \"kami_render\",\n \"description\": (\n \"Render trusted local Kami HTML to PDF via WeasyPrint, with \"\n \"build-time code highlighting. Referenced file, HTTP, and HTTPS \"\n \"resources load with this process's permissions. Returns the PDF \"\n \"path and page count.\"\n ),\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"html\"],\n \"properties\": {\n \"html\": {\"type\": \"string\", \"description\": \"Path to the filled HTML file\"},\n \"out\": {\"type\": \"string\", \"description\": \"Output PDF path (default: same stem .pdf)\"},\n },\n },\n },\n {\n \"name\": \"kami_check\",\n \"description\": (\n \"Run Kami's deterministic checks on one existing file without \"\n \"rendering. HTML: placeholders + strict mathematics + markdown \"\n \"residue + data charts + template style (+ content coverage when a \"\n \"content IR JSON is given). PDF: markdown residue + CJK fonts + \"\n \"orphans + density. JSON: content IR schema validation. Returns the \"\n \"legacy report plus stable rule IDs, findings, coverage status, and \"\n \"explicit degraded checks. To finish a document use kami_deliver, \"\n \"which also renders and enforces the page contract.\"\n ),\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"path\"],\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"File to check (.html, .pdf, or content .json)\"},\n \"content\": {\"type\": \"string\", \"description\": \"Optional content IR JSON to verify coverage against an HTML file\"},\n },\n },\n },\n {\n \"name\": \"kami_deliver\",\n \"description\": (\n \"Finish a filled Kami document in one call, the same gate as \"\n \"`build.py --deliver`: HTML checks, strict mathematics rendered in \"\n \"place, data charts, template style, content coverage, then the PDF \"\n \"render, page contract, resume balance, CJK fonts, residue, \"\n \"density, orphans, and page images. Writes the PDF and page PNGs \"\n \"next to the HTML. Returns ready, the verdict, every check with \"\n \"its findings, data gaps to report, and the review checklist; view \"\n \"every page image against it before shipping.\"\n ),\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"html\"],\n \"properties\": {\n \"html\": {\"type\": \"string\", \"description\": \"Path to the filled HTML file\"},\n \"content\": {\"type\": \"string\", \"description\": \"Optional content IR JSON (enables coverage and the page target)\"},\n },\n },\n },\n {\n \"name\": \"kami_screenshot\",\n \"description\": (\n \"Rasterize every PDF page to PNG for a perceptual review pass. \"\n \"Writes or replaces \u003cpdf-stem>-visual/page-*.png, then returns the \"\n \"image paths, deterministic CJK font verdict, and fixed review \"\n \"checklist; view every image against the checklist before shipping.\"\n ),\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"pdf\"],\n \"properties\": {\n \"pdf\": {\"type\": \"string\", \"description\": \"Path to the rendered PDF\"},\n \"dpi\": {\n \"type\": \"integer\",\n \"minimum\": MIN_DPI,\n \"maximum\": MAX_DPI,\n \"description\": \"Render DPI (default from checks thresholds)\",\n },\n },\n },\n },\n]\n\n\ndef _resolve(raw: str) -> Path:\n path = Path(raw).expanduser()\n return path if path.is_absolute() else (Path.cwd() / path)\n\n\ndef tool_templates(_args: dict) -> dict:\n return {\n \"version\": kami_version(),\n \"document_templates\": {name: spec.source for name, spec in HTML_TEMPLATES.items()},\n \"screen_templates\": dict(SCREEN_TEMPLATES),\n \"pptx_templates\": dict(PPTX_TEMPLATES),\n \"marp_templates\": dict(MARP_TEMPLATES),\n \"diagram_templates\": dict(DIAGRAM_TEMPLATES),\n \"content_schema_types\": content_schema_types(),\n \"templates_dir\": str(ROOT / \"assets\" / \"templates\"),\n \"diagrams_dir\": str(ROOT / \"assets\" / \"diagrams\"),\n \"schemas_dir\": str(ROOT / \"references\" / \"schemas\"),\n # The SKILL.md \"New document\" tier: templates carry every token, so\n # design.md is opened per component, not read whole before filling.\n \"read_before_filling\": [\n str(ROOT / \"SKILL.md\"),\n str(ROOT / \"CHEATSHEET.md\"),\n str(ROOT / \"references\" / \"writing.md\"),\n ],\n }\n\n\ndef tool_doctor(_args: dict) -> dict:\n return doctor_report()\n\n\ndef tool_render(args: dict) -> dict:\n html_path = _resolve(args[\"html\"])\n if not html_path.exists():\n raise FileNotFoundError(f\"HTML file not found: {html_path}\")\n if html_path.suffix.lower() not in {\".html\", \".htm\"}:\n raise ValueError(f\"input must be an HTML file: {html_path.name}\")\n out = _resolve(args[\"out\"]) if args.get(\"out\") else html_path.with_suffix(\".pdf\")\n if out.suffix.lower() != \".pdf\":\n raise ValueError(f\"output must use a .pdf suffix: {out.name}\")\n if out.is_symlink():\n raise ValueError(\"output PDF must not be a symbolic link\")\n same_file = out.resolve(strict=False) == html_path.resolve(strict=False)\n if out.exists():\n try:\n same_file = same_file or os.path.samefile(html_path, out)\n except OSError:\n pass\n if same_file:\n raise ValueError(\"output PDF must not overwrite the source HTML\")\n # render_pdf is the same pipeline build.py and verify.py use: highlight,\n # WeasyPrint, Kami metadata, page count.\n pages = render_pdf(html_path, out)\n return {\"pdf\": str(out), \"pages\": pages}\n\n\ndef _run_check(fn, argv: list[str], **kwargs) -> tuple[int, str]:\n buffer = io.StringIO()\n with contextlib.redirect_stdout(buffer):\n code = fn(argv, **kwargs)\n return code, buffer.getvalue().rstrip()\n\n\ndef _check_plan(path: Path, content: str | None) -> list[tuple[str, object, list[str]]]:\n suffix = path.suffix.lower()\n if suffix in {\".html\", \".htm\"}:\n checks: list[tuple[str, object, list[str]]] = [\n (\"html.placeholders\", check_placeholders, [str(path)]),\n (\"html.math\", check_latex_file, [str(path)]),\n (\"html.markdown-residue\", check_markdown_residue, [str(path)]),\n (\"html.charts\", check_charts, [str(path)]),\n (\"html.style\", check_style, [str(path)]),\n ]\n if content:\n content_path = str(_resolve(content))\n checks.append((\n \"content.contract\",\n check_content,\n [content_path, str(path)],\n ))\n return checks\n if suffix == \".pdf\":\n return [\n (\"pdf.markdown-residue\", check_markdown_residue, [str(path)]),\n (\"pdf.fonts\", check_fonts, [str(path)]),\n (\"pdf.orphans\", check_orphans, [str(path)]),\n (\"pdf.density\", check_density, [str(path)]),\n ]\n if suffix == \".json\":\n return [(\"content.contract\", check_content, [str(path)])]\n raise ValueError(f\"unsupported file type: {path.name} (expected .html, .pdf, or .json)\")\n\n\ndef tool_check(args: dict) -> dict:\n path = _resolve(args[\"path\"])\n if not path.exists():\n raise FileNotFoundError(f\"file not found: {path}\")\n\n reports: list[str] = []\n coverage: list[dict] = []\n findings: list[dict] = []\n worst = 0\n def record(rule_id: str, code: int, report: str) -> None:\n nonlocal worst\n worst = max(worst, code)\n status = \"passed\" if code == 0 else (\"failed\" if code == 1 else \"degraded\")\n rule = {\"id\": rule_id, **CHECK_REGISTRY[rule_id]}\n coverage.append({**rule, \"status\": status, \"exit_code\": code})\n if status != \"passed\":\n findings.append({**rule, \"status\": status, \"evidence\": report})\n\n for rule_id, fn, argv in _check_plan(path, args.get(\"content\")):\n if rule_id == \"content.contract\" and len(argv) == 2:\n phase_codes: dict[str, int] = {}\n _, report = _run_check(fn, argv, phase_codes=phase_codes)\n reports.append(report)\n contract_code = phase_codes.get(\"contract\", 2)\n record(\"content.contract\", contract_code, report)\n if contract_code == 0 and \"coverage\" in phase_codes:\n record(\"content.coverage\", phase_codes[\"coverage\"], report)\n else:\n rule = {\"id\": \"content.coverage\", **CHECK_REGISTRY[\"content.coverage\"]}\n coverage.append({\n **rule,\n \"status\": \"not_run\",\n \"exit_code\": None,\n \"blocked_by\": \"content.contract\",\n })\n continue\n code, report = _run_check(fn, argv)\n reports.append(report)\n record(rule_id, code, report)\n return {\n \"ruleset_version\": CHECK_RULESET_VERSION,\n \"exit_code\": worst,\n \"ok\": worst == 0,\n \"degraded\": any(item[\"status\"] == \"degraded\" for item in coverage),\n \"findings\": findings,\n \"coverage\": coverage,\n \"report\": \"\\n\".join(reports),\n }\n\n\ndef tool_deliver(args: dict) -> dict:\n html_path = _resolve(args[\"html\"])\n if not html_path.exists():\n raise FileNotFoundError(f\"HTML file not found: {html_path}\")\n argv = [str(html_path)]\n if args.get(\"content\"):\n argv.append(str(_resolve(args[\"content\"])))\n result: dict = {}\n code, report = _run_check(deliver, argv, result=result)\n if \"verdict\" not in result:\n # Usage errors and missing dependencies stop before a verdict.\n raise ValueError(report or \"deliver stopped before a verdict\")\n return {**result, \"exit_code\": code, \"report\": report}\n\n\ndef tool_screenshot(args: dict) -> dict:\n pdf = _resolve(args[\"pdf\"])\n if not pdf.exists():\n raise FileNotFoundError(f\"PDF not found: {pdf}\")\n dpi = args.get(\"dpi\")\n if dpi is not None and (isinstance(dpi, bool) or not isinstance(dpi, int)):\n raise ValueError(\"dpi must be an integer\")\n pages = render_pages(pdf, dpi=dpi)\n font_code, font_report = _run_check(check_fonts, [str(pdf)])\n return {\n \"rasterized\": True,\n \"review_pending\": True,\n \"pages\": [str(p) for p in pages],\n \"font_check\": {\n \"exit_code\": font_code,\n \"ok\": font_code == 0,\n \"report\": font_report,\n },\n \"review_checklist\": list(REVIEW_CHECKLIST),\n \"instruction\": (\n \"Require font_check.ok, then view every page image against the \"\n \"checklist. Settle the perceptual review outside this tool before shipping.\"\n ),\n }\n\n\nTOOL_HANDLERS = {\n \"kami_templates\": tool_templates,\n \"kami_doctor\": tool_doctor,\n \"kami_render\": tool_render,\n \"kami_check\": tool_check,\n \"kami_deliver\": tool_deliver,\n \"kami_screenshot\": tool_screenshot,\n}\n\n\ndef _send(payload: dict) -> None:\n sys.stdout.write(json.dumps(payload, ensure_ascii=False) + \"\\n\")\n sys.stdout.flush()\n\n\ndef _reply(msg_id, result: dict) -> None:\n _send({\"jsonrpc\": \"2.0\", \"id\": msg_id, \"result\": result})\n\n\ndef _reply_error(msg_id, code: int, message: str) -> None:\n _send({\"jsonrpc\": \"2.0\", \"id\": msg_id, \"error\": {\"code\": code, \"message\": message}})\n\n\ndef _tool_result(msg_id, payload: dict, *, is_error: bool = False) -> None:\n text = json.dumps(payload, ensure_ascii=False, indent=2)\n _reply(msg_id, {\"content\": [{\"type\": \"text\", \"text\": text}], \"isError\": is_error})\n\n\ndef handle(msg: dict) -> None:\n method = msg.get(\"method\")\n has_id = \"id\" in msg\n msg_id = msg.get(\"id\")\n if msg.get(\"jsonrpc\") != \"2.0\" or not isinstance(method, str):\n if has_id:\n _reply_error(msg_id, -32600, \"invalid request\")\n return\n raw_params = msg.get(\"params\")\n if raw_params is None:\n params = {}\n elif isinstance(raw_params, dict):\n params = raw_params\n else:\n if has_id:\n _reply_error(msg_id, -32602, \"params must be an object\")\n return\n\n if method == \"initialize\":\n if not has_id:\n return\n client_version = params.get(\"protocolVersion\")\n agreed = client_version if client_version in SUPPORTED_PROTOCOL_VERSIONS else PROTOCOL_VERSION\n _reply(msg_id, {\n \"protocolVersion\": agreed,\n \"capabilities\": {\"tools\": {}},\n \"serverInfo\": {\"name\": \"kami\", \"version\": kami_version()},\n })\n return\n if not has_id:\n return # notifications (initialized, cancelled, ...) need no reply\n if method == \"ping\":\n _reply(msg_id, {})\n return\n if method == \"tools/list\":\n _reply(msg_id, {\"tools\": TOOLS})\n return\n if method == \"tools/call\":\n name = params.get(\"name\")\n arguments = params.get(\"arguments\")\n if not isinstance(name, str) or (arguments is not None and not isinstance(arguments, dict)):\n _reply_error(msg_id, -32602, \"tool name must be a string and arguments must be an object\")\n return\n handler = TOOL_HANDLERS.get(name)\n if handler is None:\n _reply_error(msg_id, -32602, f\"unknown tool: {name}\")\n return\n try:\n # Capture stray prints so nothing corrupts the protocol stream.\n with contextlib.redirect_stdout(io.StringIO()):\n result = handler(arguments or {})\n _tool_result(msg_id, result)\n except MissingDepError as exc:\n _tool_result(msg_id, {\"error\": str(exc)}, is_error=True)\n except Exception as exc:\n _tool_result(msg_id, {\"error\": f\"{type(exc).__name__}: {exc}\"}, is_error=True)\n return\n _reply_error(msg_id, -32601, f\"method not found: {method}\")\n\n\ndef main() -> int:\n # MCP stdio frames are UTF-8 regardless of the host locale (a Windows pipe\n # defaults to the ANSI code page and cannot encode CJK results).\n sys.stdin.reconfigure(encoding=\"utf-8\")\n sys.stdout.reconfigure(encoding=\"utf-8\", newline=\"\\n\")\n for line in sys.stdin:\n line = line.strip()\n if not line:\n continue\n try:\n msg = json.loads(line)\n except json.JSONDecodeError:\n _reply_error(None, -32700, \"parse error\")\n continue\n if not isinstance(msg, dict):\n _reply_error(None, -32600, \"invalid request\")\n continue\n try:\n handle(msg)\n except Exception:\n # A malformed request must never terminate the stdio server or\n # prevent the client from sending the next frame.\n _reply_error(msg.get(\"id\"), -32603, \"internal error\")\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":21132,"content_sha256":"278c1e051392a8a09ec1d7d0c3f45ff98093d8c217e65c0e88f8041201f86437"},{"filename":"scripts/mermaid_normalize.py","content":"#!/usr/bin/env python3\n\"\"\"Re-theme and normalize a beautiful-mermaid SVG into a Kami-styled, WeasyPrint-safe SVG.\n\nbeautiful-mermaid (https://github.com/lukilabs/beautiful-mermaid) hangs its seven\ncolor roles on CSS custom properties on the root ``\u003csvg>`` and derives the rest with\n``color-mix(in srgb, ...)``. WeasyPrint's inline-SVG renderer does not resolve\n``color-mix()``, does not reliably cascade SVG ``\u003cstyle>`` custom properties, and\nshould not fetch a runtime web font. This script:\n\n 1. **Re-themes** the SVG to the Kami palette by overriding the seven root color\n roles with ``references/mermaid-theme.json`` values, so the diagram looks like\n Kami no matter which theme it was generated with.\n 2. **Resolves** every ``var()`` and ``color-mix(in srgb, ...)`` to a static hex, so\n colors land on inline presentation attributes WeasyPrint renders directly.\n 3. **Fixes fonts**: strips beautiful-mermaid's Google-Fonts ``@import`` and rewrites\n the (mis-quoted) ``font-family`` to the Kami serif stack (with CJK fallback).\n\nNo Node, no network, pure stdlib. Generate the SVG anywhere beautiful-mermaid runs\n(e.g. https://agents.craft.do/mermaid or your own one-off script), then run this.\n\nScope: graph-type diagrams (flowchart / state / sequence / class / ER) whose colors\nflow from the seven root roles. xychart-beta styles via ``\u003cstyle>`` class selectors,\nwhich WeasyPrint will not apply to inline SVG, so charts stay browser-only; use\nKami's hand-drawn bar/line/donut/candlestick/waterfall diagrams for PDF. See\nreferences/mermaid.md.\n\nUsage:\n python3 scripts/mermaid_normalize.py raw.svg # cleaned SVG to stdout\n python3 scripts/mermaid_normalize.py raw.svg -o out.svg\n cat raw.svg | python3 scripts/mermaid_normalize.py - # read from stdin\n\"\"\"\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport re\nimport sys\nfrom pathlib import Path\n\n_ROOT = Path(__file__).resolve().parent.parent\n_THEME_FILE = _ROOT / \"references\" / \"mermaid-theme.json\"\n\n# Fallbacks if references/mermaid-theme.json is missing. Mirror that file and\n# references/design.md.\n_DEFAULT_FONT_STACK = (\n '\"TsangerJinKai02\", \"Source Han Serif SC\", \"Source Han Serif CN\", '\n '\"Noto Serif CJK SC\", \"Noto Serif SC\", \"Songti SC\", \"STSong\", \"SimSun\", '\n 'Charter, Georgia, serif'\n)\n_DEFAULT_COLORS = {\n \"--bg\": \"#f5f4ed\", \"--fg\": \"#141413\", \"--line\": \"#504e49\",\n \"--accent\": \"#1B365D\", \"--muted\": \"#6b6a64\", \"--surface\": \"#faf9f5\",\n \"--border\": \"#e8e6dc\",\n}\n\n# A handful of CSS named colors beautiful-mermaid may emit. Hex is preferred.\n_NAMED = {\"white\": (255, 255, 255), \"black\": (0, 0, 0), \"transparent\": None}\n\n\ndef _load_theme() -> tuple[dict[str, str], str]:\n \"\"\"Return (kami color-role overrides, font stack) from the theme file.\"\"\"\n try:\n data = json.loads(_THEME_FILE.read_text(encoding=\"utf-8\"))\n colors = {f\"--{k}\": v for k, v in data.get(\"colors\", {}).items()}\n font = data.get(\"cssFontStack\") or _DEFAULT_FONT_STACK\n if colors:\n return colors, font\n except (OSError, ValueError):\n pass\n return dict(_DEFAULT_COLORS), _DEFAULT_FONT_STACK\n\n\n# ---------------------------------------------------------------------------\n# color helpers\n# ---------------------------------------------------------------------------\n\ndef _parse_hex(value: str) -> tuple[int, int, int]:\n h = value.strip().lstrip(\"#\")\n if len(h) == 3:\n h = \"\".join(c * 2 for c in h)\n if len(h) != 6:\n raise ValueError(f\"not a hex color: {value!r}\")\n return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)\n\n\ndef _to_hex(rgb: tuple[float, float, float]) -> str:\n return \"#\" + \"\".join(f\"{max(0, min(255, round(c))):02x}\" for c in rgb)\n\n\ndef _mix_srgb(c1: tuple[int, int, int], p1: float,\n c2: tuple[int, int, int], p2: float) -> tuple[float, float, float]:\n \"\"\"color-mix(in srgb, c1 p1%, c2 p2%): linear blend in gamma sRGB.\n\n Percentages are normalized to sum to 100 (per CSS Color 4).\n \"\"\"\n total = p1 + p2 or 1.0\n w1, w2 = p1 / total, p2 / total\n return tuple(a * w1 + b * w2 for a, b in zip(c1, c2))\n\n\n# ---------------------------------------------------------------------------\n# balanced-paren parsing\n# ---------------------------------------------------------------------------\n\ndef _match_paren(s: str, open_idx: int) -> int:\n \"\"\"Given index of a '(', return index of its matching ')'.\"\"\"\n depth = 0\n for i in range(open_idx, len(s)):\n if s[i] == \"(\":\n depth += 1\n elif s[i] == \")\":\n depth -= 1\n if depth == 0:\n return i\n raise ValueError(\"unbalanced parentheses\")\n\n\ndef _split_top(s: str, sep: str = \",\") -> list[str]:\n \"\"\"Split on `sep` at paren depth 0 only.\"\"\"\n parts, depth, start = [], 0, 0\n for i, ch in enumerate(s):\n if ch == \"(\":\n depth += 1\n elif ch == \")\":\n depth -= 1\n elif ch == sep and depth == 0:\n parts.append(s[start:i])\n start = i + 1\n parts.append(s[start:])\n return [p.strip() for p in parts]\n\n\nclass _Resolver:\n \"\"\"Resolves a CSS color value to a static hex using a custom-property map.\"\"\"\n\n def __init__(self, raw_defs: dict[str, str]):\n self._raw = raw_defs\n\n def hex_of(self, value: str) -> str | None:\n rgb = self._rgb(value)\n return None if rgb is None else _to_hex(rgb)\n\n def var_map(self) -> dict[str, str]:\n out: dict[str, str] = {}\n for name in self._raw:\n h = self.hex_of(f\"var({name})\")\n if h is not None:\n out[name] = h\n return out\n\n def _rgb(self, value: str, _seen: frozenset[str] = frozenset()) -> tuple[int, int, int] | None:\n value = value.strip().rstrip(\";\").strip()\n if not value:\n return None\n if value.startswith(\"#\"):\n return _parse_hex(value)\n low = value.lower()\n if low in _NAMED:\n return _NAMED[low]\n if value.startswith(\"var(\") and value.endswith(\")\"):\n inner = value[4:_match_paren(value, 3)]\n args = _split_top(inner)\n name = args[0].strip()\n fallback = args[1] if len(args) > 1 else None\n if name in _seen:\n return None # cycle guard\n if name in self._raw:\n return self._rgb(self._raw[name], _seen | {name})\n if fallback is not None:\n return self._rgb(fallback, _seen | {name})\n return None\n if value.startswith(\"color-mix(\") and value.endswith(\")\"):\n inner = value[len(\"color-mix(\"):_match_paren(value, len(\"color-mix\"))]\n args = _split_top(inner)\n # args[0] is the color space, e.g. \"in srgb\"\n if not args or \"srgb\" not in args[0]:\n raise ValueError(f\"unsupported color-mix space: {args[0] if args else '?'}\")\n ops = [self._parse_operand(a, _seen) for a in args[1:3]]\n (c1, p1), (c2, p2) = ops[0], ops[1]\n if p1 is None and p2 is None:\n p1 = p2 = 50.0\n elif p1 is None:\n p1 = max(0.0, 100.0 - p2)\n elif p2 is None:\n p2 = max(0.0, 100.0 - p1)\n if c1 is None or c2 is None:\n return None\n return tuple(round(x) for x in _mix_srgb(c1, p1, c2, p2))\n return None\n\n def _parse_operand(self, token: str,\n _seen: frozenset[str]) -> tuple[tuple[int, int, int] | None, float | None]:\n \"\"\"Parse a color-mix operand 'COLOR' or 'COLOR P%'.\"\"\"\n token = token.strip()\n pct = None\n m = re.search(r\"\\s(\\d+(?:\\.\\d+)?)%\\s*$\", \" \" + token)\n if m:\n pct = float(m.group(1))\n token = token[: token.rfind(m.group(1) + \"%\")].strip()\n return self._rgb(token, _seen), pct\n\n\n# ---------------------------------------------------------------------------\n# SVG transforms\n# ---------------------------------------------------------------------------\n\n_STYLE_RE = re.compile(r\"\u003cstyle[^>]*>(.*?)\u003c/style>\", re.DOTALL)\n_CUSTOM_PROP_RE = re.compile(r\"(--[\\w-]+)\\s*:\\s*([^;]*?(?:\\([^)]*\\)[^;]*?)*);\")\n_IMPORT_RE = re.compile(r\"@import\\s+url\\([^)]*\\)\\s*;\", re.IGNORECASE)\n# Stops at \"\u003c\" so a match inside a \u003cstyle> body can never run into markup.\n_FONT_FAMILY_RE = re.compile(r\"font-family\\s*:\\s*[^;}\u003c]+\")\n# Inside a double-quoted style=\"...\" attribute: stops at the closing quote.\n_ATTR_FONT_FAMILY_RE = re.compile(r\"font-family\\s*:\\s*[^;\\\"]+\")\n_STYLE_ATTR_RE = re.compile(r'(\\sstyle=\")([^\"]*)(\")')\n\n\ndef _collect_raw_defs(svg: str, overrides: dict[str, str]) -> dict[str, str]:\n \"\"\"Gather custom-property defs from the root \u003csvg style> and \u003cstyle> svg{} rules.\n\n `overrides` (the Kami color roles) replace any same-named root role, re-theming\n the diagram regardless of the theme it was generated with.\n \"\"\"\n raw: dict[str, str] = {}\n m = re.search(r\"\u003csvg\\b[^>]*\\bstyle=\\\"([^\\\"]*)\\\"\", svg)\n if m:\n for prop in m.group(1).split(\";\"):\n if \":\" in prop:\n k, v = prop.split(\":\", 1)\n k = k.strip()\n if k.startswith(\"--\"):\n raw[k] = v.strip()\n for sm in _STYLE_RE.finditer(svg):\n for pm in _CUSTOM_PROP_RE.finditer(sm.group(1)):\n raw[pm.group(1)] = pm.group(2).strip()\n raw.update(overrides) # Kami palette wins\n return raw\n\n\ndef _resolve_functions(text: str, resolver: _Resolver) -> str:\n \"\"\"Replace every var()/color-mix() in `text` with a static hex, innermost-first.\n\n Raises if any function cannot be resolved to a color: in well-formed\n beautiful-mermaid output every var()/color-mix() resolves, so an unresolved\n one means the input structure changed. Failing loudly beats silently emitting\n an invalid color that renders as a black or invisible diagram.\n \"\"\"\n while True:\n starts = [m.start() for m in re.finditer(r\"\\b(?:var|color-mix)\\(\", text)]\n if not starts:\n break\n # rightmost opening = guaranteed leaf (no nested var/color-mix after it)\n start = max(starts)\n open_paren = text.index(\"(\", start)\n close = _match_paren(text, open_paren)\n expr = text[start:close + 1]\n hexval = resolver.hex_of(expr)\n if hexval is None:\n raise ValueError(\n f\"could not resolve {expr!r} to a color; the input may not be \"\n \"beautiful-mermaid output or uses an unsupported structure\"\n )\n text = text[:start] + hexval + text[close + 1:]\n return text\n\n\ndef _assert_beautiful_mermaid(svg: str) -> None:\n \"\"\"Raise unless the SVG carries beautiful-mermaid's root color-role props.\n\n beautiful-mermaid v1.x defines --bg / --fg (plus the optional roles) as inline\n custom properties on the root \u003csvg>. Their absence means the input came from a\n different renderer whose structure this normalizer does not understand, so we\n fail loudly here instead of silently producing unresolved colors downstream.\n Verified against beautiful-mermaid v1.1.3; see references/mermaid.md.\n \"\"\"\n m = re.search(r\"\u003csvg\\b[^>]*\\bstyle=\\\"([^\\\"]*)\\\"\", svg)\n style = m.group(1) if m else \"\"\n if \"--bg\" not in style or \"--fg\" not in style:\n raise ValueError(\n \"input does not look like beautiful-mermaid output: the root \u003csvg> is \"\n \"missing the --bg/--fg color roles (verified against v1.1.3)\"\n )\n\n\ndef normalize(svg: str, theme: dict[str, str] | None = None,\n font_stack: str | None = None) -> str:\n \"\"\"Return a Kami-re-themed, WeasyPrint-safe copy of a beautiful-mermaid SVG.\"\"\"\n if theme is None or font_stack is None:\n default_colors, default_font = _load_theme()\n theme = theme or default_colors\n font_stack = font_stack or default_font\n\n _assert_beautiful_mermaid(svg)\n raw_defs = _collect_raw_defs(svg, theme)\n resolver = _Resolver(raw_defs)\n\n out = _resolve_functions(svg, resolver)\n out = _IMPORT_RE.sub(\"\", out)\n # Rewrite font-family only inside \u003cstyle> bodies (in _clean_style below) and\n # inside style=\"...\" attributes, where the stack's double quotes would end\n # the attribute early, so family names there are single-quoted.\n attr_stack = f\"font-family: {font_stack.replace(chr(34), chr(39))}\"\n out = _STYLE_ATTR_RE.sub(\n lambda m: m.group(1) + _ATTR_FONT_FAMILY_RE.sub(attr_stack, m.group(2)) + m.group(3),\n out,\n )\n\n # The derived custom props were inlined into presentation attributes, so the\n # \u003cstyle> svg{} rules and the root role decls are now dead. Strip them, keeping\n # only live rules (e.g. the rewritten font-family).\n def _clean_style(m: re.Match[str]) -> str:\n body = _FONT_FAMILY_RE.sub(f\"font-family: {font_stack}\", m.group(1))\n body = re.sub(r\"--[\\w-]+\\s*:\\s*#[0-9a-fA-F]{3,8}\\s*;\", \"\", body)\n body = re.sub(r\"[\\w*.#-]+\\s*\\{\\s*(?:/\\*.*?\\*/\\s*)?\\}\", \"\", body, flags=re.DOTALL)\n body = re.sub(r\"\\n\\s*\\n+\", \"\\n\", body).strip()\n return f\"\u003cstyle>\\n {body}\\n\u003c/style>\" if body else \"\"\n\n out = _STYLE_RE.sub(_clean_style, out)\n\n def _clean_root_style(m: re.Match[str]) -> str:\n kept = [d.strip() for d in m.group(1).split(\";\")\n if d.strip() and not d.strip().startswith(\"--\")]\n return f' style=\"{\";\".join(kept)}\"' if kept else \"\"\n\n out = re.sub(r'\\s+style=\"([^\"]*)\"', _clean_root_style, out, count=1)\n return out\n\n\ndef main(argv: list[str]) -> int:\n if len(argv) == 1:\n print(__doc__)\n return 0\n\n parser = argparse.ArgumentParser(\n description=\"Re-theme a beautiful-mermaid SVG into a Kami, WeasyPrint-safe SVG.\",\n )\n parser.add_argument(\"src\", help=\"Input SVG path, or '-' to read from stdin\")\n parser.add_argument(\"-o\", \"--output\", help=\"Output SVG path (default: stdout)\")\n args = parser.parse_args(argv[1:])\n\n try:\n raw = sys.stdin.read() if args.src == \"-\" else Path(args.src).read_text(encoding=\"utf-8\")\n result = normalize(raw)\n if args.output:\n Path(args.output).write_text(result, encoding=\"utf-8\")\n print(f\"OK: wrote {args.output}\")\n else:\n sys.stdout.write(result)\n except (OSError, ValueError) as exc:\n print(f\"ERROR: {exc}\", file=sys.stderr)\n return 1\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main(sys.argv))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14492,"content_sha256":"81a4f58778d674c921046e1d213c4e8ec0332163804935b49cf06a443cfe329f"},{"filename":"scripts/optional_deps.py","content":"\"\"\"Centralized probes for Python render deps and the locked MathJax runtime.\n\nbuild.py previously had inline `from weasyprint import HTML` try/except blocks\nwith duplicated install hints; this module collapses those into one resolver\neach so the import error message stays consistent and the WeasyPrint runtime\nis configured once at the import call site.\n\"\"\"\nfrom __future__ import annotations\n\nimport importlib\nimport importlib.metadata\nimport platform\nimport shutil\nimport struct\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nfrom math_render import probe_mathjax\nfrom shared import ROOT, configure_weasyprint_runtime, kami_version\n\n# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare\n# `pip install weasyprint` succeeds but then fails to load with a cryptic\n# \"cannot load library 'libgobject-2.0'\" until the native libs are present. Spell\n# them out so the error message is actionable on a fresh Linux box.\n_LINUX_NATIVE_LIBS = (\n \"Linux also needs native libs: \"\n \"sudo apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \"\n \"(Debian/Ubuntu), or `sudo dnf install cairo pango harfbuzz` (Fedora/RHEL)\"\n)\n\nWEASYPRINT_INSTALL_HINT = \"pip install weasyprint pypdf pymupdf --break-system-packages\"\nif sys.platform.startswith(\"linux\"):\n WEASYPRINT_INSTALL_HINT = f\"{WEASYPRINT_INSTALL_HINT}. {_LINUX_NATIVE_LIBS}\"\nPYMUPDF_INSTALL_HINT = \"pip install pymupdf --break-system-packages\"\n\n\nclass MissingDepError(RuntimeError):\n \"\"\"Raised when an optional dependency is requested but not installed.\"\"\"\n\n\ndef require_weasyprint_html():\n \"\"\"Return the weasyprint.HTML class, configuring native libs first.\"\"\"\n configure_weasyprint_runtime()\n try:\n from weasyprint import HTML\n return HTML\n except ImportError as exc:\n raise MissingDepError(\n f\"missing weasyprint. {WEASYPRINT_INSTALL_HINT}\"\n ) from exc\n\n\ndef _require_pypdf_attr(name: str):\n try:\n import pypdf\n except ImportError as exc:\n raise MissingDepError(\n f\"missing pypdf. {WEASYPRINT_INSTALL_HINT}\"\n ) from exc\n return getattr(pypdf, name)\n\n\ndef require_pypdf_reader():\n \"\"\"Return the pypdf.PdfReader class.\"\"\"\n return _require_pypdf_attr(\"PdfReader\")\n\n\ndef require_pypdf_writer():\n \"\"\"Return the pypdf.PdfWriter class.\"\"\"\n return _require_pypdf_attr(\"PdfWriter\")\n\n\ndef require_pymupdf():\n \"\"\"Return the PyMuPDF module (imported as fitz).\"\"\"\n try:\n import fitz\n return fitz\n except ImportError as exc:\n raise MissingDepError(\n f\"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}\"\n ) from exc\n\n\ndef _distribution_version(name: str) -> str | None:\n try:\n return importlib.metadata.version(name)\n except importlib.metadata.PackageNotFoundError:\n return None\n\n\ndef _probe_dependency(\n name: str,\n distribution: str,\n purpose: str,\n loader,\n *,\n required: bool,\n) -> dict:\n try:\n loader()\n except MissingDepError as exc:\n return {\n \"name\": name,\n \"status\": \"missing\",\n \"required\": required,\n \"purpose\": purpose,\n \"version\": _distribution_version(distribution),\n \"detail\": str(exc),\n }\n except Exception as exc:\n return {\n \"name\": name,\n \"status\": \"degraded\",\n \"required\": required,\n \"purpose\": purpose,\n \"version\": _distribution_version(distribution),\n \"detail\": f\"import failed: {type(exc).__name__}: {exc}\",\n }\n return {\n \"name\": name,\n \"status\": \"available\",\n \"required\": required,\n \"purpose\": purpose,\n \"version\": _distribution_version(distribution),\n }\n\n\ndef _probe_module(name: str):\n return importlib.import_module(name)\n\n\ndef _font_file_valid(path: Path) -> bool:\n \"\"\"Validate enough of an SFNT/WOFF header to reject truncated font files.\"\"\"\n try:\n file_size = path.stat().st_size\n with path.open(\"rb\") as stream:\n header = stream.read(48)\n except OSError:\n return False\n if len(header) \u003c 12:\n return False\n\n signature = header[:4]\n if signature in {b\"\\x00\\x01\\x00\\x00\", b\"OTTO\", b\"true\", b\"typ1\"}:\n table_count = struct.unpack(\">H\", header[4:6])[0]\n directory_end = 12 + table_count * 16\n if table_count == 0 or directory_end > file_size:\n return False\n try:\n with path.open(\"rb\") as stream:\n stream.seek(12)\n directory = stream.read(table_count * 16)\n except OSError:\n return False\n if len(directory) != table_count * 16:\n return False\n for offset in range(0, len(directory), 16):\n table_offset, table_length = struct.unpack(\n \">II\", directory[offset + 8:offset + 16]\n )\n if table_offset > file_size or table_length > file_size - table_offset:\n return False\n return True\n\n if signature == b\"wOF2\":\n if len(header) \u003c 48:\n return False\n declared_length = struct.unpack(\">I\", header[8:12])[0]\n table_count = struct.unpack(\">H\", header[12:14])[0]\n sfnt_size = struct.unpack(\">I\", header[16:20])[0]\n return declared_length == file_size and table_count > 0 and sfnt_size > 0\n\n return False\n\n\ndef _probe_font(family: str, bundled_names: tuple[str, ...], purpose: str) -> dict:\n font_dir = ROOT / \"assets\" / \"fonts\"\n present = [name for name in bundled_names if (font_dir / name).is_file()]\n bundled = [name for name in present if _font_file_valid(font_dir / name)]\n invalid_bundled = [name for name in present if name not in bundled]\n matched = None\n matcher = shutil.which(\"fc-match\")\n if matcher:\n try:\n result = subprocess.run(\n [matcher, \"-f\", \"%{family}\\n\", family],\n capture_output=True,\n text=True,\n timeout=5,\n check=False,\n )\n if result.returncode == 0:\n matched = (result.stdout.splitlines() or [\"\"])[0].strip() or None\n except (OSError, subprocess.TimeoutExpired):\n matched = None\n resolved = bool(matched and family.casefold() in matched.casefold())\n status = (\n \"degraded\" if invalid_bundled\n else \"available\" if bundled or resolved\n else \"unconfirmed\"\n )\n detail = None\n if invalid_bundled:\n detail = f\"bundled font file is empty or truncated: {', '.join(invalid_bundled)}\"\n elif status != \"available\":\n detail = \"not bundled and fontconfig did not confirm the requested family\"\n return {\n \"name\": family,\n \"status\": status,\n \"purpose\": purpose,\n \"bundled\": bundled,\n \"fontconfig_match\": matched,\n \"detail\": detail,\n }\n\n\ndef doctor_report() -> dict:\n \"\"\"Return installed render, verification, and font capabilities.\n\n Required dependencies define whether the normal HTML -> PDF -> visual\n verification path is ready. Editable PPTX and language-specific font\n families are reported separately so an unavailable optional path cannot be\n mistaken for a clean check or for a broken core install.\n \"\"\"\n dependencies = [\n _probe_dependency(\n \"weasyprint\", \"weasyprint\", \"HTML to PDF rendering\",\n require_weasyprint_html, required=True,\n ),\n _probe_dependency(\n \"pypdf\", \"pypdf\", \"PDF text, metadata, and page checks\",\n require_pypdf_reader, required=True,\n ),\n _probe_dependency(\n \"pymupdf\", \"PyMuPDF\", \"page screenshots, density, and orphan checks\",\n require_pymupdf, required=True,\n ),\n _probe_dependency(\n \"python-pptx\", \"python-pptx\", \"editable PPTX fallback\",\n lambda: _probe_module(\"pptx\"), required=False,\n ),\n _probe_dependency(\n \"pygments\", \"Pygments\", \"build-time code highlighting\",\n lambda: _probe_module(\"pygments\"), required=False,\n ),\n ]\n mathjax = probe_mathjax()\n dependencies.append({\n \"name\": \"mathjax\",\n \"status\": mathjax[\"status\"],\n \"required\": False,\n \"purpose\": \"strict TeX to SVG rendering when a document contains mathematics\",\n \"version\": mathjax.get(\"version\"),\n **({\"detail\": mathjax[\"detail\"]} if mathjax.get(\"detail\") else {}),\n })\n fonts = [\n _probe_font(\n \"JetBrains Mono\", (\"JetBrainsMono.woff2\",),\n \"code and metadata labels\",\n ),\n _probe_font(\n \"TsangerJinKai02\", (\"TsangerJinKai02-W04.ttf\", \"TsangerJinKai02-W05.ttf\"),\n \"primary Chinese editorial serif\",\n ),\n _probe_font(\n \"Source Han Serif KR\",\n (\"SourceHanSerifKR-Regular.otf\", \"SourceHanSerifKR-Medium.otf\"),\n \"Korean editorial serif fallback\",\n ),\n ]\n required_ready = all(\n item[\"status\"] == \"available\"\n for item in dependencies\n if item[\"required\"]\n )\n return {\n \"version\": kami_version(),\n \"ok\": required_ready,\n \"dependencies\": dependencies,\n \"fonts\": fonts,\n \"capabilities\": {\n \"pdf_render\": all(\n item[\"status\"] == \"available\"\n for item in dependencies\n if item[\"name\"] in {\"weasyprint\", \"pypdf\"}\n ),\n \"pdf_visual_review\": all(\n item[\"status\"] == \"available\"\n for item in dependencies\n if item[\"name\"] in {\"pypdf\", \"pymupdf\"}\n ),\n \"editable_pptx\": next(\n item[\"status\"] == \"available\"\n for item in dependencies\n if item[\"name\"] == \"python-pptx\"\n ),\n \"strict_math\": next(\n item[\"status\"] == \"available\"\n for item in dependencies\n if item[\"name\"] == \"mathjax\"\n ),\n },\n }\n\n\ndef run_doctor() -> int:\n report = doctor_report()\n print(f\"Kami doctor {report['version']}\")\n if platform.system() == \"Windows\":\n print(\n \"NOTE: on Windows, run Kami inside WSL (Ubuntu); the helper scripts are \"\n \"Bash, and native WeasyPrint needs the GTK/Pango runtime installed separately\"\n )\n for item in report[\"dependencies\"]:\n label = \"OK\" if item[\"status\"] == \"available\" else item[\"status\"].upper()\n version = f\" {item['version']}\" if item.get(\"version\") else \"\"\n print(f\"{label}: {item['name']}{version}: {item['purpose']}\")\n if item.get(\"detail\"):\n print(f\" {item['detail']}\")\n for item in report[\"fonts\"]:\n label = \"OK\" if item[\"status\"] == \"available\" else item[\"status\"].upper()\n source = \"bundled\" if item[\"bundled\"] else (item.get(\"fontconfig_match\") or \"not confirmed\")\n print(f\"{label}: font {item['name']}: {source}\")\n print(\"OK: core render and visual verification ready\" if report[\"ok\"] else \"ERROR: core capability missing or degraded\")\n return 0 if report[\"ok\"] else 1\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11207,"content_sha256":"bc651334ab38bf53205d05213dd079bb6f3b55c9fa8ad85398af6c80199b1e3b"},{"filename":"scripts/package.json","content":"{\n \"type\": \"commonjs\"\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":25,"content_sha256":"8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a"},{"filename":"scripts/render.py","content":"\"\"\"Artifact production primitives shared by build, verify, and the MCP server.\n\nOne home for the render pipeline (read HTML, strictly render LaTeX to SVG, highlight code blocks, WeasyPrint\nto PDF, stamp Kami metadata, count pages) and the PPTX fallback build. Before\nthis module existed the pipeline lived in build.py and was duplicated by\nverify.py (through injected callbacks, to dodge a circular import) and the MCP\nserver (through a late import). Keeping it here, below build.py in the import\ngraph, dissolves both workarounds.\n\"\"\"\nfrom __future__ import annotations\n\nimport functools\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport zipfile\nfrom pathlib import Path\n\nfrom font_compat import use_truetype_fonts\nfrom hangul import join_hangul_words\nfrom highlight import highlight_code_blocks\nfrom math_render import MathRenderError, render_latex_in_html\nfrom optional_deps import (\n MissingDepError,\n require_pypdf_reader,\n require_pypdf_writer,\n require_weasyprint_html,\n)\nfrom shared import EXAMPLES, TEMPLATES, pptx_targets\n\n\[email protected]_cache(maxsize=1)\ndef infer_author() -> str:\n \"\"\"Infer author name from git config or environment.\n\n Priority:\n 1. git config user.name\n 2. KAMI_AUTHOR env var\n 3. fallback to \"Kami\"\n\n Cached so a full build doesn't shell out for every PDF target.\n \"\"\"\n try:\n result = subprocess.run(\n [\"git\", \"config\", \"user.name\"],\n capture_output=True,\n text=True,\n check=False,\n )\n if result.returncode == 0 and result.stdout.strip():\n return result.stdout.strip()\n except FileNotFoundError:\n pass\n\n if env_author := os.environ.get(\"KAMI_AUTHOR\"):\n return env_author\n\n return \"Kami\"\n\n\ndef set_pdf_metadata(pdf_path: Path, author: str | None = None) -> None:\n \"\"\"Set PDF metadata using pypdf, only if placeholders are still present.\"\"\"\n try:\n PdfReader = require_pypdf_reader()\n PdfWriter = require_pypdf_writer()\n except MissingDepError:\n return\n\n if not pdf_path.exists():\n return\n\n reader = PdfReader(str(pdf_path))\n\n existing = reader.metadata or {}\n needs_update = False\n metadata = dict(existing)\n\n if author and existing.get(\"/Author\"):\n author_value = str(existing[\"/Author\"])\n if \"{{\" in author_value and \"}}\" in author_value:\n metadata[\"/Author\"] = author\n needs_update = True\n\n if metadata.get(\"/Producer\") != \"Kami\":\n metadata[\"/Producer\"] = \"Kami\"\n needs_update = True\n if metadata.get(\"/Creator\") != \"Kami\":\n metadata[\"/Creator\"] = \"Kami\"\n needs_update = True\n\n if not needs_update:\n return\n\n # Clone the whole document catalog, not only its pages. WeasyPrint writes\n # useful document-level structures such as outlines, named destinations,\n # and /Lang; rebuilding from add_page() silently discards those while the\n # page count and pixels still look correct.\n writer = PdfWriter()\n writer.clone_document_from_reader(reader)\n writer.add_metadata(metadata)\n\n with open(pdf_path, \"wb\") as f:\n writer.write(f)\n\n\ndef render_pdf(src: Path, out: Path) -> int:\n \"\"\"Render an HTML file to PDF and return its page count.\n\n The full pipeline every caller must agree on: strict TeX-to-SVG math rendering,\n build-time code highlighting, Korean word joining, TrueType copies of\n CID-keyed CFF fonts, WeasyPrint with base_url at the source directory, Kami PDF metadata, page\n count via pypdf. Raises MissingDepError when weasyprint/pypdf are absent\n and MathRenderError when formula source or the locked renderer is invalid;\n callers decide how to report either failure.\n \"\"\"\n HTML = require_weasyprint_html()\n PdfReader = require_pypdf_reader()\n\n out.parent.mkdir(parents=True, exist_ok=True)\n html_text = src.read_text(encoding=\"utf-8\")\n html_text = render_latex_in_html(html_text)\n html_text = highlight_code_blocks(html_text)\n html_text = join_hangul_words(html_text)\n html_text = use_truetype_fonts(html_text, src.parent)\n # Build and validate beside the destination, then atomically replace it.\n # Metadata stamping and the final page read can still fail after WeasyPrint\n # succeeds; writing straight to `out` would destroy the last good artifact\n # before the caller learns that the render failed.\n with tempfile.TemporaryDirectory(\n dir=out.parent,\n prefix=f\".{out.name}-\",\n ) as staging_dir:\n candidate = Path(staging_dir) / out.name\n HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(candidate))\n set_pdf_metadata(candidate, author=infer_author())\n page_count = len(PdfReader(str(candidate)).pages)\n os.replace(candidate, out)\n return page_count\n\n\n_PPTX_REQUIRED_ENTRIES = {\n \"[Content_Types].xml\",\n \"_rels/.rels\",\n \"ppt/presentation.xml\",\n}\n\n\ndef _pptx_issue(path: Path) -> str | None:\n \"\"\"Return why ``path`` is not a readable PPTX package, else ``None``.\"\"\"\n if not path.is_file():\n return \"output not produced\"\n try:\n with zipfile.ZipFile(path) as archive:\n bad_entry = archive.testzip()\n if bad_entry is not None:\n return f\"corrupt ZIP entry: {bad_entry}\"\n missing = sorted(_PPTX_REQUIRED_ENTRIES - set(archive.namelist()))\n except (OSError, zipfile.BadZipFile) as exc:\n return f\"invalid PPTX package: {exc}\"\n if missing:\n return f\"missing PPTX package entry: {', '.join(missing)}\"\n return None\n\n\ndef build_slides(name: str = \"slides\") -> bool:\n \"\"\"Run a python-pptx slide script from the shared registry; True on success.\"\"\"\n source = pptx_targets().get(name)\n if source is None:\n print(f\"ERROR: {name}: unknown slides target\")\n return False\n src = TEMPLATES / source\n if not src.exists():\n print(f\"ERROR: {name}: source not found ({src})\")\n return False\n\n EXAMPLES.mkdir(parents=True, exist_ok=True)\n out = EXAMPLES / f\"{name}.pptx\"\n # Build beside the destination and replace only after the new file proves\n # to be a readable PPTX package. A successful script that forgets to write\n # must not let an older output masquerade as this run's artifact.\n with tempfile.TemporaryDirectory(\n dir=out.parent,\n prefix=f\".{out.name}-\",\n ) as staging_dir:\n candidate = Path(staging_dir) / out.name\n result = subprocess.run(\n [sys.executable, str(src), \"--out\", str(candidate)],\n cwd=str(src.parent),\n capture_output=True,\n text=True,\n )\n if result.returncode != 0:\n print(f\"ERROR: {name}: {result.stderr.strip() or 'script failed'}\")\n return False\n issue = _pptx_issue(candidate)\n if issue:\n print(f\"ERROR: {name}: {issue}\")\n return False\n os.replace(candidate, out)\n print(f\"OK: {name}: generated {out.name}\")\n return True\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7037,"content_sha256":"7f6f62c803308b3cb2f071b003803f728542af42b47bb035da35aeba36619f8e"},{"filename":"scripts/shared.py","content":"\"\"\"Shared constants and helpers for kami build scripts.\"\"\"\nfrom __future__ import annotations\n\nimport functools\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Any, NamedTuple\n\n\nclass TemplateSpec(NamedTuple):\n \"\"\"Per-template configuration.\n\n build_max_pages: hard ceiling enforced by `build.py --verify`. 0 = no limit.\n \"\"\"\n source: str\n build_max_pages: int\n\nROOT = Path(__file__).resolve().parent.parent\n# In a repository checkout the skill lives at \u003crepo>/skills/kami and the\n# website at \u003crepo>/site; an installed skill has neither. Use repository tooling\n# as the marker so a missing site cannot silently disable repository checks.\nREPO_ROOT = (\n ROOT.parent.parent\n if ROOT.parent.name == \"skills\"\n and (ROOT.parent.parent / \"scripts\" / \"package-skill.sh\").is_file()\n else None\n)\nSITE_ROOT = REPO_ROOT / \"site\" if REPO_ROOT else None\nTEMPLATES = ROOT / \"assets\" / \"templates\"\nDIAGRAMS = ROOT / \"assets\" / \"diagrams\"\nEXAMPLES = ROOT / \"assets\" / \"examples\"\nTOKENS_FILE = ROOT / \"references\" / \"tokens.json\"\nCHECKS_THRESHOLDS_FILE = ROOT / \"references\" / \"checks_thresholds.json\"\nSCHEMAS_DIR = ROOT / \"references\" / \"schemas\"\n\nPUBLIC_REPO = \"tw93/kami\"\nCLAUDE_CODE_MIN_VERSION = \"2.1.142\"\nCLAUDE_CODE_INSTALL_COMMANDS = (\n f\"/plugin marketplace add {PUBLIC_REPO}\",\n \"/plugin install kami@kami\",\n)\nCODEX_PLUGIN_INSTALL_COMMANDS = (\n f\"codex plugin marketplace add {PUBLIC_REPO}\",\n \"codex plugin add kami@kami\",\n)\nGENERIC_AGENT_INSTALL_COMMAND = f\"npx skills add {PUBLIC_REPO} -a claude-code codex cursor -g -y\"\nCLAUDE_DESKTOP_PACKAGE_URL = \"https://github.com/tw93/kami/releases/latest/download/kami.zip\"\n\n# Canonical parchment background color, kept here so build/density\n# checks share one source of truth instead of redefining the RGB triple.\nPARCHMENT_RGB = (0xF5, 0xF4, 0xED)\n\n_HOMEBREW_PREFIXES = (Path(\"/opt/homebrew\"), Path(\"/usr/local\"))\n\n\n# macOS-only fontconfig cache base; Linux keeps the caller's XDG_CACHE_HOME.\n_DARWIN_CACHE_DIR = Path(\"/private/tmp/kami-fontconfig-cache\")\n\n\ndef configure_weasyprint_runtime() -> None:\n \"\"\"Make platform-native libraries discoverable before importing WeasyPrint.\n\n On macOS, also surface Homebrew's gobject lib so cairo/pango can load.\n Linux and other platforms keep the caller's XDG cache base unchanged; the\n system loader and fontconfig already follow the platform default.\n \"\"\"\n if sys.platform != \"darwin\":\n return\n\n os.environ.setdefault(\"XDG_CACHE_HOME\", str(_DARWIN_CACHE_DIR))\n\n brew_lib = next(\n (p / \"lib\" for p in _HOMEBREW_PREFIXES if (p / \"lib\" / \"libgobject-2.0.dylib\").exists()),\n None,\n )\n if brew_lib is None:\n return\n\n existing = os.environ.get(\"DYLD_FALLBACK_LIBRARY_PATH\", \"\")\n paths = [path for path in existing.split(\":\") if path]\n brew_lib_str = str(brew_lib)\n if brew_lib_str in paths:\n return\n\n os.environ[\"DYLD_FALLBACK_LIBRARY_PATH\"] = \":\".join([brew_lib_str, *paths])\n\n# Cool / neutral gray hex values that violate the \"warm undertone only\" rule.\nCOOL_GRAY_BLOCKLIST = {\n \"#888\", \"#888888\", \"#666\", \"#666666\", \"#999\", \"#999999\",\n \"#ccc\", \"#cccccc\", \"#ddd\", \"#dddddd\", \"#eee\", \"#eeeeee\",\n \"#111\", \"#111111\", \"#222\", \"#222222\", \"#333\", \"#333333\",\n \"#444\", \"#444444\", \"#555\", \"#555555\", \"#777\", \"#777777\",\n \"#aaa\", \"#aaaaaa\", \"#bbb\", \"#bbbbbb\",\n # Tailwind cool grays\n \"#6b7280\", \"#9ca3af\", \"#d1d5db\", \"#e5e7eb\", \"#f3f4f6\",\n \"#4b5563\", \"#374151\", \"#1f2937\", \"#111827\",\n # Bootstrap-like neutrals\n \"#f8f9fa\", \"#e9ecef\", \"#dee2e6\", \"#ced4da\", \"#adb5bd\",\n \"#6c757d\", \"#495057\", \"#343a40\", \"#212529\",\n}\n\n\n# ---------------------------------------------------------------------------\n# Template registry\n#\n# Single source of truth for HTML targets used by build.py.\n# See TemplateSpec for field meanings.\n# ---------------------------------------------------------------------------\nHTML_TEMPLATES: dict[str, TemplateSpec] = {\n # Core six\n \"one-pager\": TemplateSpec(\"one-pager.html\", 1),\n \"letter\": TemplateSpec(\"letter.html\", 1),\n \"long-doc\": TemplateSpec(\"long-doc.html\", 0),\n \"portfolio\": TemplateSpec(\"portfolio.html\", 0),\n \"resume\": TemplateSpec(\"resume.html\", 2),\n \"one-pager-en\": TemplateSpec(\"one-pager-en.html\", 1),\n \"letter-en\": TemplateSpec(\"letter-en.html\", 1),\n \"long-doc-en\": TemplateSpec(\"long-doc-en.html\", 0),\n \"portfolio-en\": TemplateSpec(\"portfolio-en.html\", 0),\n \"resume-en\": TemplateSpec(\"resume-en.html\", 2),\n # Korean\n \"one-pager-ko\": TemplateSpec(\"one-pager-ko.html\", 1),\n \"letter-ko\": TemplateSpec(\"letter-ko.html\", 1),\n \"long-doc-ko\": TemplateSpec(\"long-doc-ko.html\", 0),\n \"portfolio-ko\": TemplateSpec(\"portfolio-ko.html\", 0),\n \"resume-ko\": TemplateSpec(\"resume-ko.html\", 2),\n \"equity-report-ko\": TemplateSpec(\"equity-report-ko.html\", 3),\n \"changelog-ko\": TemplateSpec(\"changelog-ko.html\", 2),\n \"slides-weasy-ko\": TemplateSpec(\"slides-weasy-ko.html\", 0),\n # Equity report\n \"equity-report\": TemplateSpec(\"equity-report.html\", 3),\n \"equity-report-en\": TemplateSpec(\"equity-report-en.html\", 3),\n # Changelog\n \"changelog\": TemplateSpec(\"changelog.html\", 2),\n \"changelog-en\": TemplateSpec(\"changelog-en.html\", 2),\n # Slides (WeasyPrint default)\n \"slides-weasy\": TemplateSpec(\"slides-weasy.html\", 0),\n \"slides-weasy-en\": TemplateSpec(\"slides-weasy-en.html\", 0),\n}\n\nSCREEN_TEMPLATES: dict[str, str] = {\n \"landing-page\": \"landing-page.html\",\n \"landing-page-en\": \"landing-page-en.html\",\n \"landing-page-ko\": \"landing-page-ko.html\",\n}\n\n# Editable PPTX fallback decks, generated by python-pptx scripts.\nPPTX_TEMPLATES: dict[str, str] = {\n \"slides\": \"slides.py\",\n \"slides-en\": \"slides-en.py\",\n}\n\n# Markdown-first Marp slide variants. The Markdown files are the authoring\n# entry points; their sibling CSS themes stay self-contained for the Marp CLI.\nMARP_TEMPLATES: dict[str, str] = {\n \"slides-marp\": \"marp/slides-marp.md\",\n \"slides-marp-en\": \"marp/slides-marp-en.md\",\n}\n\n# Diagram HTMLs live in assets/diagrams and have no page-count contract.\n# Registered here (not in build.py) so all template registries share one home.\n# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py.\nDIAGRAM_TEMPLATES: dict[str, str] = {\n \"diagram-architecture\": \"architecture.html\",\n \"diagram-architecture-board\": \"architecture-board.html\",\n \"diagram-flowchart\": \"flowchart.html\",\n \"diagram-quadrant\": \"quadrant.html\",\n \"diagram-bar-chart\": \"bar-chart.html\",\n \"diagram-line-chart\": \"line-chart.html\",\n \"diagram-donut-chart\": \"donut-chart.html\",\n \"diagram-state-machine\": \"state-machine.html\",\n \"diagram-timeline\": \"timeline.html\",\n \"diagram-swimlane\": \"swimlane.html\",\n \"diagram-tree\": \"tree.html\",\n \"diagram-layer-stack\": \"layer-stack.html\",\n \"diagram-venn\": \"venn.html\",\n \"diagram-candlestick\": \"candlestick.html\",\n \"diagram-waterfall\": \"waterfall.html\",\n # Mermaid-sourced (beautiful-mermaid + scripts/mermaid_normalize.py)\n \"diagram-sequence\": \"sequence.html\",\n \"diagram-class\": \"class.html\",\n \"diagram-er\": \"er.html\",\n}\n\n\nPUBLIC_DOCUMENT_TEMPLATE_KINDS = {\n \"one-pager\",\n \"letter\",\n \"long-doc\",\n \"portfolio\",\n \"resume\",\n \"slides\",\n \"equity-report\",\n \"changelog\",\n}\n\n\ndef public_template_kind(name: str) -> str:\n \"\"\"Map a registry template name to the public kind it belongs to.\"\"\"\n for suffix in (\"-en\", \"-ko\"):\n if name.endswith(suffix):\n name = name[: -len(suffix)]\n break\n if name.startswith(\"slides-weasy\"):\n return \"slides\"\n return name\n\n\ndef public_document_template_kinds() -> set[str]:\n \"\"\"Return every normalized document kind represented by HTML_TEMPLATES.\n\n Do not filter through ``PUBLIC_DOCUMENT_TEMPLATE_KINDS`` here. Callers use\n this derived set to detect a newly registered document kind whose public\n facts or content schema have not been added yet.\n \"\"\"\n return {\n public_template_kind(name)\n for name in HTML_TEMPLATES\n }\n\n\ndef public_document_template_count() -> int:\n return len(public_document_template_kinds())\n\n\ndef rel_to_root(path: Path) -> Path:\n \"\"\"Return `path` relative to ROOT when possible, else the path unchanged.\"\"\"\n return path.relative_to(ROOT) if path.is_relative_to(ROOT) else path\n\n\ndef resolve_input(raw: str | Path) -> Path:\n \"\"\"Resolve a user-supplied file argument.\n\n Agents call the checks from the directory holding their document, so a\n relative path means the caller's working directory first. Skill-relative\n paths (`assets/examples/resume.pdf`) keep working as the fallback.\n \"\"\"\n path = Path(raw).expanduser()\n if path.is_absolute():\n return path\n from_cwd = Path.cwd() / path\n if from_cwd.exists():\n return from_cwd\n return ROOT / path\n\n\ndef default_example_pdfs() -> list[str]:\n \"\"\"Return every rendered example PDF, the default scan set for PDF checks.\"\"\"\n return [str(p) for p in sorted(EXAMPLES.glob(\"*.pdf\"))]\n\n\ndef iter_template_files(\n *,\n include_py: bool = False,\n include_diagrams: bool = False,\n include_marp_css: bool = False,\n) -> list[Path]:\n \"\"\"Collect template-family files for scanning.\n\n One shared walker so lint, token-sync, and future checks cannot silently\n diverge in coverage (a divergence is how the Marp CSS family once slipped\n out of the lint scan while staying in the token scan).\n \"\"\"\n targets: list[Path] = list(TEMPLATES.glob(\"*.html\"))\n if include_py:\n targets.extend(TEMPLATES.glob(\"*.py\"))\n if include_diagrams and DIAGRAMS.exists():\n targets.extend(DIAGRAMS.glob(\"*.html\"))\n if include_marp_css:\n marp_dir = TEMPLATES / \"marp\"\n if marp_dir.exists():\n targets.extend(marp_dir.glob(\"*.css\"))\n return sorted(targets)\n\n\[email protected]_cache(maxsize=1)\ndef kami_version() -> str:\n \"\"\"Return the canonical Kami version from the tracked VERSION file.\"\"\"\n return (ROOT / \"VERSION\").read_text(encoding=\"utf-8\").strip()\n\n\[email protected]_cache(maxsize=1)\ndef load_tokens() -> dict[str, str]:\n return json.loads(TOKENS_FILE.read_text(encoding=\"utf-8\"))\n\n\ndef token_value(name: str) -> str:\n key = name if name.startswith(\"--\") else f\"--{name}\"\n return load_tokens()[key]\n\n\ndef build_targets() -> dict[str, tuple[str, int]]:\n \"\"\"Return target -> (source, max_pages) mapping for build.py.\"\"\"\n return {name: (spec.source, spec.build_max_pages) for name, spec in HTML_TEMPLATES.items()}\n\n\ndef screen_targets() -> dict[str, str]:\n \"\"\"Return target -> source mapping for browser-only HTML templates.\"\"\"\n return dict(SCREEN_TEMPLATES)\n\n\ndef pptx_targets() -> dict[str, str]:\n \"\"\"Return target -> source mapping for python-pptx slide scripts.\"\"\"\n return dict(PPTX_TEMPLATES)\n\n\ndef marp_targets() -> dict[str, str]:\n \"\"\"Return target -> source mapping for Markdown-first Marp decks.\"\"\"\n return dict(MARP_TEMPLATES)\n\n\ndef diagram_targets() -> dict[str, str]:\n \"\"\"Return target -> source mapping for assets/diagrams HTML templates.\"\"\"\n return dict(DIAGRAM_TEMPLATES)\n\n\[email protected]_cache(maxsize=1)\ndef load_checks_thresholds() -> dict[str, Any]:\n \"\"\"Return rhythm / density / orphan thresholds.\n\n Falls back to baked-in defaults if the JSON is missing so build.py works\n on a half-installed checkout.\n \"\"\"\n if CHECKS_THRESHOLDS_FILE.exists():\n return json.loads(CHECKS_THRESHOLDS_FILE.read_text(encoding=\"utf-8\"))\n return {\n \"rhythm\": {\"max_content_run\": 5, \"divider_min_deck_size\": 12},\n \"density\": {\"warn_pct\": 0.25, \"sparse_pct\": 0.50, \"last_page_pct\": 0.60, \"dpi\": 36},\n \"resume_balance\": {\n \"min_fill_pct\": 0.83,\n \"max_fill_pct\": 0.95,\n \"max_gap_pct\": 0.12,\n \"dpi\": 36,\n },\n \"orphan\": {\"max_words\": 2, \"max_chars\": 15},\n \"visual\": {\"dpi\": 110},\n }\n\n\ndef content_schema_types() -> list[str]:\n \"\"\"Return doc types that have a content schema in references/schemas.\"\"\"\n if not SCHEMAS_DIR.exists():\n return []\n return sorted(p.stem for p in SCHEMAS_DIR.glob(\"*.json\"))\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12433,"content_sha256":"52abb7070b6c40577619c7d1ead0b4e65965fe556829ebcea5b49e82a5e34389"},{"filename":"scripts/site_facts.py","content":"\"\"\"Public-site fact drift checks for Kami.\n\nThe hosted pages, README, and llms.txt intentionally repeat install and product\nfacts in multiple languages. This module keeps those facts tied to the shared\nregistry and public constants so `build.py --check` catches drift before CI.\n\"\"\"\nfrom __future__ import annotations\n\nimport difflib\nimport html\nimport re\nfrom collections.abc import Mapping\nfrom html.parser import HTMLParser\nfrom pathlib import Path\n\nfrom shared import (\n CLAUDE_CODE_INSTALL_COMMANDS,\n CLAUDE_CODE_MIN_VERSION,\n CLAUDE_DESKTOP_PACKAGE_URL,\n CODEX_PLUGIN_INSTALL_COMMANDS,\n DIAGRAM_TEMPLATES,\n GENERIC_AGENT_INSTALL_COMMAND,\n PUBLIC_DOCUMENT_TEMPLATE_KINDS,\n REPO_ROOT,\n ROOT,\n SITE_ROOT,\n kami_version,\n public_document_template_count,\n public_document_template_kinds,\n)\n\n# Locale pages are hand-maintained forks of index.html. Their DOM skeletons\n# must stay identical; the only allowed divergence is the language-redirect\n# \u003cscript> that exists solely on the default page.\nSITE_BASE_PAGE = \"index.html\"\nSITE_LOCALE_PAGES = (\n \"index-zh.html\",\n \"index-ja.html\",\n \"index-ko.html\",\n \"index-tw.html\",\n)\n\n# Every surface that must carry the full public fact set. Derived from the\n# locale-page tuple so adding a locale automatically joins both checks.\n# index.md is the Markdown twin agents read instead of the homepage, so it\n# carries the same install and product facts.\nDEVELOPER_FACT_FILES = (\n \"developers.html\",\n \"developers.md\",\n)\nFULL_PUBLIC_FACT_FILES = (\n \"README.md\",\n \"llms.txt\",\n \"index.md\",\n SITE_BASE_PAGE,\n *SITE_LOCALE_PAGES,\n *DEVELOPER_FACT_FILES,\n)\nSITE_VERSION_BADGE_FILES = (SITE_BASE_PAGE, *SITE_LOCALE_PAGES)\n# Anchored on the badge's own copy rather than a bare version pattern: these\n# pages also print the Claude Code minimum as \"v2.1.142+\", which a generic\n# `v\\d+\\.\\d+\\.\\d+` judge would read as a stale Kami badge on every page.\n# The label is translated on each locale page, so the pattern lists every copy.\nSITE_VERSION_BADGE = re.compile(r\"(?:Design System|设计系统|設計系統|デザインシステム|디자인 시스템)\\s*·\\s*v(\\d+\\.\\d+\\.\\d+)\")\nREDIRECT_SITE_FILE = \"index-en.html\"\nSITE_SURFACE_ABSENT = \"__site_surface_absent__\"\n\n_SKELETON_TAGS = frozenset({\n \"article\", \"aside\", \"dl\", \"figure\", \"footer\", \"form\", \"header\",\n \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"main\", \"nav\", \"ol\", \"section\", \"script\", \"svg\", \"table\", \"ul\",\n})\n\n# Spelled-out numerals per template count. Digit patterns below are derived\n# from the live registry count, so a registry change keeps the check honest;\n# only the localized number words need a new entry here when the count moves.\n_TEMPLATE_COUNT_WORDS = {\n 8: (\n r\"\\bEight document template\",\n r\"八种文档模板\",\n r\"八種文件範本\",\n ),\n}\n\ndef _normalize(text: str) -> str:\n return html.unescape(text)\n\n\ndef _contains_template_count(text: str, expected: int) -> bool:\n patterns = [\n rf\"\\b{expected} document template\",\n rf\"{expected}种文档模板\",\n rf\"{expected}種文件範本\",\n rf\"{expected}種類のドキュメントテンプレート\",\n rf\"{expected}가지 문서 템플릿\",\n *_TEMPLATE_COUNT_WORDS.get(expected, ()),\n ]\n return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)\n\n\ndef _contains_diagram_count(text: str, expected: int) -> bool:\n patterns = [\n rf\"\\b{expected}\\s+(?:inline\\s+SVG\\s+)?diagram\",\n rf\"{expected}\\s*(?:种|種).*?(?:图表|圖表)\",\n rf\"{expected}種.*?図表\",\n rf\"{expected}가지.*?다이어그램\",\n ]\n if expected == 18:\n patterns.append(r\"\\bEighteen\\s+inline\\s+SVG\")\n return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)\n\n\ndef public_path(rel: str) -> Path:\n \"\"\"Where a public fact file lives: README at the repo root, pages under site/.\"\"\"\n if SITE_ROOT is None:\n return ROOT / rel\n if rel == \"README.md\":\n return REPO_ROOT / rel\n return SITE_ROOT / rel\n\n\ndef _file_texts(files: Mapping[str, str] | None) -> tuple[dict[str, str], list[str]]:\n if files is not None:\n return dict(files), []\n\n texts: dict[str, str] = {}\n issues: list[str] = []\n site_files = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)\n if not any(public_path(rel).exists() for rel in site_files):\n return {SITE_SURFACE_ABSENT: \"\"}, []\n for rel in site_files:\n path = public_path(rel)\n if not path.exists():\n issues.append(f\"{rel}: missing public fact file\")\n continue\n texts[rel] = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n return texts, issues\n\n\nclass _SkeletonParser(HTMLParser):\n \"\"\"Collect the structural tag sequence of a page, annotated enough to\n catch real drift (class identity, script type) without tracking text.\"\"\"\n\n def __init__(self) -> None:\n super().__init__(convert_charrefs=True)\n self.rows: list[str] = []\n\n def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:\n if tag not in _SKELETON_TAGS:\n return\n attr_map = dict(attrs)\n row = tag\n css_class = (attr_map.get(\"class\") or \"\").strip()\n if css_class:\n row += f\".{css_class}\"\n if tag == \"script\":\n script_type = (attr_map.get(\"type\") or \"\").strip()\n if script_type:\n row += f\"[{script_type}]\"\n self.rows.append(row)\n\n\ndef _page_skeleton(text: str) -> list[str]:\n parser = _SkeletonParser()\n parser.feed(text)\n return parser.rows\n\n\ndef _drop_language_redirect_script(rows: list[str]) -> list[str]:\n \"\"\"Return rows minus the first bare \u003cscript> (the default page's\n language redirect), which locale pages intentionally omit.\"\"\"\n for index, row in enumerate(rows):\n if row == \"script\":\n return rows[:index] + rows[index + 1:]\n return rows\n\n\ndef site_structure_issues(files: Mapping[str, str] | None = None) -> list[str]:\n \"\"\"Compare each locale page's DOM skeleton against index.html.\"\"\"\n texts, issues = _file_texts(files)\n if SITE_SURFACE_ABSENT in texts:\n return []\n\n base_raw = texts.get(SITE_BASE_PAGE)\n if base_raw is None:\n return issues\n expected = _drop_language_redirect_script(_page_skeleton(base_raw))\n\n for rel in SITE_LOCALE_PAGES:\n raw = texts.get(rel)\n if raw is None:\n continue\n rows = _page_skeleton(raw)\n if rows == expected:\n continue\n delta = [\n line for line in difflib.unified_diff(expected, rows, lineterm=\"\", n=0)\n if line[:1] in \"+-\" and line[:3] not in (\"+++\", \"---\")\n ]\n preview = \"; \".join(delta[:6]) + (\" ...\" if len(delta) > 6 else \"\")\n issues.append(\n f\"{rel}: DOM skeleton drifted from {SITE_BASE_PAGE} \"\n f\"({len(delta)} row(s): {preview})\"\n )\n\n return issues\n\n\ndef site_fact_issues(files: Mapping[str, str] | None = None) -> list[str]:\n texts, issues = _file_texts(files)\n if SITE_SURFACE_ABSENT in texts:\n return []\n\n kinds = public_document_template_kinds()\n if kinds != PUBLIC_DOCUMENT_TEMPLATE_KINDS:\n missing = sorted(PUBLIC_DOCUMENT_TEMPLATE_KINDS - kinds)\n extra = sorted(kinds - PUBLIC_DOCUMENT_TEMPLATE_KINDS)\n detail = []\n if missing:\n detail.append(f\"missing public kinds: {', '.join(missing)}\")\n if extra:\n detail.append(f\"extra public kinds: {', '.join(extra)}\")\n issues.append(\"registry: public document template kinds drifted\" + (f\" ({'; '.join(detail)})\" if detail else \"\"))\n\n template_count = public_document_template_count()\n diagram_count = len(DIAGRAM_TEMPLATES)\n\n for rel in FULL_PUBLIC_FACT_FILES:\n raw = texts.get(rel)\n if raw is None:\n if files is not None:\n issues.append(f\"{rel}: missing public fact file\")\n continue\n text = _normalize(raw)\n\n if CLAUDE_CODE_MIN_VERSION not in text:\n issues.append(f\"{rel}: missing Claude Code minimum version {CLAUDE_CODE_MIN_VERSION}\")\n for command in CLAUDE_CODE_INSTALL_COMMANDS:\n if command not in text:\n issues.append(f\"{rel}: missing Claude Code install command `{command}`\")\n for command in CODEX_PLUGIN_INSTALL_COMMANDS:\n if command not in text:\n issues.append(f\"{rel}: missing Codex install command `{command}`\")\n if GENERIC_AGENT_INSTALL_COMMAND not in text:\n issues.append(f\"{rel}: missing generic agent install command `{GENERIC_AGENT_INSTALL_COMMAND}`\")\n\n if \"kami.zip\" not in text:\n issues.append(f\"{rel}: missing Claude Desktop package name kami.zip\")\n if rel != \"llms.txt\" and CLAUDE_DESKTOP_PACKAGE_URL not in text:\n issues.append(f\"{rel}: missing Claude Desktop package URL {CLAUDE_DESKTOP_PACKAGE_URL}\")\n\n # The homepage HTML pages carry a hand-written Kami version badge; tie\n # those pages to VERSION without forcing the prose/developer files to\n # repeat a badge they do not display.\n # Each page writes the badge twice, in the eyebrow and in the type\n # sample. Check every occurrence: a presence-only test passed while a\n # stale second badge sat next to a freshly bumped first one.\n if rel in SITE_VERSION_BADGE_FILES:\n badges = SITE_VERSION_BADGE.findall(text)\n stale = sorted({badge for badge in badges if badge != kami_version()})\n if not badges:\n issues.append(f\"{rel}: missing Kami version badge v{kami_version()}\")\n elif stale:\n found = \", \".join(f\"v{badge}\" for badge in stale)\n issues.append(\n f\"{rel}: stale Kami version badge {found} (VERSION is {kami_version()})\"\n )\n\n if not _contains_template_count(text, template_count):\n issues.append(f\"{rel}: missing public document template count {template_count}\")\n if not _contains_diagram_count(text, diagram_count):\n issues.append(f\"{rel}: missing diagram count {diagram_count}\")\n\n redirect = texts.get(REDIRECT_SITE_FILE)\n if redirect is None:\n if files is not None:\n issues.append(f\"{REDIRECT_SITE_FILE}: missing redirect page\")\n else:\n text = _normalize(redirect)\n for required in ('http-equiv=\"refresh\"', \"url=./\", 'content=\"noindex\"', 'rel=\"canonical\"'):\n if required not in text:\n issues.append(f\"{REDIRECT_SITE_FILE}: missing redirect marker `{required}`\")\n if CLAUDE_CODE_MIN_VERSION in text or \"kami.zip\" in text:\n issues.append(f\"{REDIRECT_SITE_FILE}: redirect page should not carry product fact copy\")\n\n return issues\n\n\n# The site teaches CSS recipes in prose, outside any code fence, so\n# `--check-docs` (which reads Markdown fences) cannot see them. That is how the\n# homepage kept advertising a 0.5pt closed border with a radius long after the\n# linter started failing templates for it, in five locales at once. This guard\n# reads the rendered copy of every locale page and fails on the combinations the\n# design system forbids outright, so the public surface cannot drift past the\n# rules the repository enforces on itself.\nFORBIDDEN_SITE_RECIPES = (\n (\n re.compile(r\"0\\.5pt\\s*(?:border|枠線|邊框|边框)[^.。]{0,40}(?:radius|圆角|圓角|角丸)\", re.I),\n \"sub-1pt closed border paired with a radius (production.md pitfall #2)\",\n ),\n (\n re.compile(r\"border:\\s*0\\.5pt\\s+solid\\s+var\\(--brand\\)\", re.I),\n \"closed 0.5pt brand border; mark one edge with border-left instead\",\n ),\n)\n\n\ndef site_recipe_issues(files: Mapping[str, str] | None = None) -> list[str]:\n \"\"\"Flag CSS recipes on the public pages that the design system forbids.\"\"\"\n texts, issues = _file_texts(files)\n if SITE_SURFACE_ABSENT in texts:\n return []\n for rel in (SITE_BASE_PAGE, *SITE_LOCALE_PAGES):\n raw = texts.get(rel)\n if raw is None:\n continue\n visible = re.sub(r\"\\s+\", \" \", re.sub(r\"\u003c[^>]+>\", \" \", raw))\n for pattern, why in FORBIDDEN_SITE_RECIPES:\n if pattern.search(visible):\n issues.append(f\"{rel}: teaches {why}\")\n return issues\n\n\ndef check_site_facts(verbose: bool = False) -> int:\n if not any(public_path(rel).exists() for rel in (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)):\n print(\"OK: public site facts skipped (site files absent)\")\n return 0\n\n result = 0\n\n issues = site_fact_issues()\n if not issues:\n scanned = len(FULL_PUBLIC_FACT_FILES) + 1\n print(f\"OK: public site facts in sync across {scanned} file(s)\")\n else:\n print(f\"\\nERROR: [site-fact-drift] {len(issues)}\")\n for issue in issues:\n print(f\" {issue}\")\n if verbose:\n print(\" source: shared public constants and template registries\")\n result = 1\n\n recipe_issues = site_recipe_issues()\n if not recipe_issues:\n print(f\"OK: no forbidden CSS recipes across {len(SITE_LOCALE_PAGES) + 1} public page(s)\")\n else:\n print(f\"\\nERROR: [site-recipe-drift] {len(recipe_issues)}\")\n for issue in recipe_issues:\n print(f\" {issue}\")\n if verbose:\n print(\" source: references/design.md and production.md pitfall #2\")\n result = 1\n\n structure_issues = site_structure_issues()\n if not structure_issues:\n print(f\"OK: locale page structure matches {SITE_BASE_PAGE} across {len(SITE_LOCALE_PAGES)} page(s)\")\n else:\n print(f\"\\nERROR: [site-structure-drift] {len(structure_issues)}\")\n for issue in structure_issues:\n print(f\" {issue}\")\n if verbose:\n print(f\" source: DOM skeleton comparison against {SITE_BASE_PAGE}\")\n result = 1\n\n return result\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14009,"content_sha256":"9e9514cbe33097d901aa46f67c0054142fd49d0bafca6345b46eba483abd0d92"},{"filename":"scripts/tokens.py","content":"\"\"\"Token-sync checker for kami templates.\n\nSplits out from build.py: scans `:root { ... }` blocks across HTML templates\nand `RGBColor(0xXX, 0xXX, 0xXX)` constants in the PPTX slide scripts, and\nreports drift from `references/tokens.json` (the canonical color tokens).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom pathlib import Path\n\nfrom shared import ROOT, TEMPLATES, TOKENS_FILE, iter_template_files\n\nROOT_BLOCK = re.compile(r\":root\\s*\\{([^}]*)\\}\", re.DOTALL)\nCSS_VAR = re.compile(r\"--([\\w-]+)\\s*:\\s*([^;}]+)(?:;|$)\")\nCSS_COMMENT = re.compile(r\"/\\*.*?\\*/\", re.DOTALL)\n\n\ndef parse_root_vars(text: str) -> dict[str, str]:\n \"\"\"Return {'--name': value} merged across every `:root { ... }` block.\n\n Scanning all blocks (not just the first) keeps a future dark-mode or print\n override inside a second `:root` from silently escaping the drift checks.\n \"\"\"\n found: dict[str, str] = {}\n for block in ROOT_BLOCK.finditer(text):\n for m in CSS_VAR.finditer(CSS_COMMENT.sub(\"\", block.group(1))):\n found[f\"--{m.group(1)}\"] = m.group(2).strip()\n return found\nPY_RGB = re.compile(\n r\"^([A-Z][A-Z_]+)\\s*=\\s*RGBColor\\(\\s*0x([0-9a-fA-F]{2})\\s*,\"\n r\"\\s*0x([0-9a-fA-F]{2})\\s*,\\s*0x([0-9a-fA-F]{2})\\s*\\)\",\n re.MULTILINE,\n)\n# Python const name -> tokens.json key. Only constants that mirror a CSS token.\nPY_TOKEN_MAP = {\n \"PARCHMENT\": \"--parchment\",\n \"IVORY\": \"--ivory\",\n \"BRAND\": \"--brand\",\n \"NEAR_BLACK\": \"--near-black\",\n \"DARK_WARM\": \"--dark-warm\",\n \"CHARCOAL\": \"--charcoal\",\n \"OLIVE\": \"--olive\",\n \"STONE\": \"--stone\",\n}\nMERMAID_THEME_FILE = ROOT / \"references\" / \"mermaid-theme.json\"\nMERMAID_THEME_TOKEN_MAP = {\n \"bg\": \"--parchment\",\n \"fg\": \"--near-black\",\n \"line\": \"--olive\",\n \"accent\": \"--brand\",\n \"muted\": \"--stone\",\n \"surface\": \"--ivory\",\n \"border\": \"--border\",\n}\n\n\ndef _mermaid_theme_drift(canonical: dict[str, str]) -> list[str]:\n if not MERMAID_THEME_FILE.exists():\n return [f\"mermaid-theme.json not found at {MERMAID_THEME_FILE.relative_to(ROOT)}\"]\n\n try:\n theme = json.loads(MERMAID_THEME_FILE.read_text(encoding=\"utf-8\"))\n except json.JSONDecodeError as exc:\n return [f\"mermaid-theme.json is malformed: {exc}\"]\n\n colors = theme.get(\"colors\", {})\n roles = theme.get(\"roles\", {})\n drift: list[str] = []\n for role, token in MERMAID_THEME_TOKEN_MAP.items():\n expected = canonical.get(token)\n actual = colors.get(role)\n if expected is None:\n drift.append(f\"{role}: canonical token {token} missing\")\n continue\n if actual is None:\n drift.append(f\"{role}: color missing, expected {expected} from {token}\")\n elif actual.lower() != expected.lower():\n drift.append(f\"{role}: expected {expected} from {token}, got {actual}\")\n\n role_doc = str(roles.get(role, \"\"))\n if token not in role_doc:\n drift.append(f\"{role}: role doc should mention {token}\")\n\n return drift\n\n\ndef sync_check(verbose: bool = False) -> int:\n if not TOKENS_FILE.exists():\n print(f\"ERROR: tokens.json not found at {TOKENS_FILE.relative_to(ROOT)}\")\n return 2\n\n try:\n canonical: dict[str, str] = json.loads(TOKENS_FILE.read_text())\n except json.JSONDecodeError as exc:\n print(f\"ERROR: tokens.json is malformed: {exc}\")\n return 2\n\n targets = iter_template_files(include_diagrams=True, include_marp_css=True)\n py_targets: list[Path] = list(TEMPLATES.glob(\"*.py\"))\n if not targets and not py_targets:\n print(\"ERROR: no templates found to token-check (bad checkout?)\")\n return 2\n\n drift: list[tuple[str, str, str, str]] = [] # (file, token, expected, actual)\n\n for path in targets:\n text = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n found = parse_root_vars(text)\n if not found:\n if verbose:\n print(f\" (skip {path.name}: no :root block)\")\n continue\n rel = path.relative_to(ROOT)\n for token, expected in canonical.items():\n actual = found.get(token)\n # Only flag if the template defines the token but with a wrong value.\n # Templates that don't use a token don't need to define it.\n if actual is not None and actual.lower() != expected.lower():\n drift.append((str(rel), token, expected, actual))\n\n for path in sorted(py_targets):\n text = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n rel = path.relative_to(ROOT)\n for m in PY_RGB.finditer(text):\n name = m.group(1)\n token = PY_TOKEN_MAP.get(name)\n if token is None:\n continue\n expected = canonical.get(token)\n if expected is None:\n continue\n actual = f\"#{m.group(2)}{m.group(3)}{m.group(4)}\"\n if actual.lower() != expected.lower():\n drift.append((str(rel), token, expected, actual))\n\n theme_drift = _mermaid_theme_drift(canonical)\n\n if not drift and not theme_drift:\n scanned = len(targets) + len(py_targets)\n print(f\"OK: tokens in sync across {scanned} template(s) and mermaid-theme.json\")\n return 0\n\n if drift:\n print(f\"\\nERROR: [token-drift] {len(drift)}\")\n for file, token, expected, actual in drift:\n print(f\" {file}: {token} expected {expected}, got {actual}\")\n if theme_drift:\n print(f\"\\nERROR: [mermaid-theme-drift] {len(theme_drift)}\")\n for issue in theme_drift:\n print(f\" {issue}\")\n\n return 1\n\n\ndef dtcg_tokens(canonical: dict[str, str] | None = None) -> dict:\n \"\"\"Return tokens.json as W3C Design Tokens (DTCG) JSON under a `color` group.\"\"\"\n if canonical is None:\n canonical = json.loads(TOKENS_FILE.read_text(encoding=\"utf-8\"))\n return {\n \"color\": {\n name.removeprefix(\"--\"): {\"$type\": \"color\", \"$value\": value.lower()}\n for name, value in canonical.items()\n }\n }\n\n\ndef main(argv: list[str] | None = None) -> int:\n import argparse\n\n parser = argparse.ArgumentParser(\n description=\"Check token drift, or export references/tokens.json as DTCG JSON.\"\n )\n parser.add_argument(\"--dtcg\", action=\"store_true\", help=\"print W3C Design Tokens JSON\")\n parser.add_argument(\"-o\", \"--output\", type=Path, help=\"write the DTCG JSON to this file\")\n parser.add_argument(\"-v\", \"--verbose\", action=\"store_true\")\n args = parser.parse_args(argv)\n if not args.dtcg:\n if args.output:\n parser.error(\"-o requires --dtcg\")\n return sync_check(args.verbose)\n try:\n text = json.dumps(dtcg_tokens(), indent=2) + \"\\n\"\n except (OSError, json.JSONDecodeError) as exc:\n print(f\"ERROR: cannot read tokens.json: {exc}\")\n return 2\n if args.output:\n args.output.write_text(text, encoding=\"utf-8\")\n print(f\"OK: wrote {len(json.loads(text)['color'])} color tokens to {args.output}\")\n else:\n print(text, end=\"\")\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7129,"content_sha256":"47df3e83296069ab6b2e549c7c0531042d2bdf9a5759bc54a9476f05ce781c6e"},{"filename":"scripts/verify.py","content":"\"\"\"End-to-end render verification for kami templates.\n\nRenders each template through render.render_pdf, validates page-count against\nper-template ceilings, inspects embedded PDF fonts to warn when only a\nfallback is used, and runs an advisory density scan over the rendered\nexamples when invoked for the full suite. Self-sufficient: registries come\nfrom shared, the render pipeline from render, the density scan from checks.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport re\nimport subprocess\nimport unicodedata\nfrom html.parser import HTMLParser\nfrom pathlib import Path\nfrom urllib.parse import unquote\n\nfrom checks import _resume_balance_issues, scan_density\nfrom lint import scan_file\nfrom math_render import MathRenderError\nfrom optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader\nfrom render import build_slides, render_pdf\nfrom shared import (\n DIAGRAMS,\n EXAMPLES,\n TEMPLATES,\n build_targets,\n default_example_pdfs,\n diagram_targets,\n load_checks_thresholds,\n pptx_targets,\n rel_to_root,\n resolve_input,\n screen_targets,\n)\n\n# Primary fonts expected in embedded PDF font names\nCN_PRIMARY_FONTS = {\"TsangerJinKai02\"}\nEN_PRIMARY_FONTS = {\"Charter\"}\nKO_PRIMARY_FONTS = {\"Source-Han-Serif-K\", \"SourceHanSerifK\"}\n\n# Serif families that keep a CJK page readable when the primary is unavailable.\n# Falling through to one of these is a degradation, not a defect: the page still\n# carries the serif texture every size, tracking and leading value was tuned for.\nCJK_SERIF_MARKERS = (\n \"TsangerJinKai\",\n \"SourceHanSerif\",\n \"NotoSerifCJK\",\n \"NotoSerifSC\",\n \"NotoSerifTC\",\n \"NotoSerifJP\",\n \"NotoSerifKR\",\n \"Songti\",\n \"STSong\",\n \"SimSun\",\n \"STZhongsong\",\n \"STKaiti\",\n \"KaiTi\",\n \"MSung\",\n \"MingLiU\",\n \"HiraginoMincho\",\n \"HiraMinPro\",\n \"YuMincho\",\n \"Myeongjo\",\n \"Myungjo\",\n \"Batang\",\n)\n# A CJK run needs at least this many ideographs before its dominant font is\n# worth judging: a stray glyph in an otherwise Latin document proves nothing.\nMIN_CJK_CHARS_TO_JUDGE = 20\nRECOGNIZABLE_FALLBACK_FONT_MARKERS = (\n \"Georgia\",\n \"Palatino\",\n \"PT-Serif\",\n \"PTSerif\",\n \"TsangerJinKai\",\n \"YuMincho\",\n \"Hiragino\",\n \"SourceHan\",\n \"Noto\",\n \"Charter\",\n \"Songti\",\n \"DejaVu\",\n \"Liberation\",\n)\n\n\ndef show_fonts(pdf: Path) -> None:\n if not pdf.exists():\n return\n try:\n out = subprocess.run([\"pdffonts\", str(pdf)], capture_output=True, text=True, check=False)\n if out.returncode == 0:\n print(\"--- pdffonts ---\")\n print(out.stdout.rstrip())\n except FileNotFoundError:\n pass # pdffonts not installed; silent\n\n\ndef _pdf_font_names(pdf_path: Path) -> set[str]:\n def _resolve_pdf_obj(obj):\n if obj is None:\n return None\n try:\n return obj.get_object() if hasattr(obj, \"get_object\") else obj\n except Exception:\n return obj\n\n try:\n PdfReader = require_pypdf_reader()\n reader = PdfReader(str(pdf_path))\n fonts: set[str] = set()\n for page in reader.pages:\n resources = _resolve_pdf_obj(page.get(\"/Resources\"))\n if resources is None or not hasattr(resources, \"get\"):\n continue\n font_dict = _resolve_pdf_obj(resources.get(\"/Font\"))\n if font_dict is None or not hasattr(font_dict, \"values\"):\n continue\n for obj in font_dict.values():\n resolved = _resolve_pdf_obj(obj)\n if resolved is None or not hasattr(resolved, \"get\"):\n continue\n base = resolved.get(\"/BaseFont\")\n if base:\n fonts.add(str(base).lstrip(\"/\"))\n return fonts\n except Exception as exc:\n print(f\" WARN: could not read font names from PDF: {exc}\")\n return set()\n\n\ndef _normalize_font_name(name: str) -> str:\n \"\"\"Reduce a PDF BaseFont entry to comparable letters and digits.\n\n Embedded names arrive subset-prefixed and punctuated in every combination\n ('ABCDEF+NotoSerifCJKsc-Regular', 'Noto Serif CJK SC'), so markers are\n matched against a stripped, lowercased form instead of the raw string.\n \"\"\"\n return re.sub(r\"[^a-z0-9]\", \"\", name.lower())\n\n\ndef _classify_cjk_font(font_name: str) -> str:\n \"\"\"Return 'primary', 'serif' or 'other' for one font name.\n\n 'other' covers both a system sans substitution and a family nobody here\n recognizes. Naming which of the two it is would need a second marker table\n to maintain, and would change nothing: both fail, both take the same fix,\n and the message already prints the family that drew the text.\n \"\"\"\n normalized = _normalize_font_name(font_name)\n if any(_normalize_font_name(m) in normalized for m in CN_PRIMARY_FONTS | KO_PRIMARY_FONTS):\n return \"primary\"\n if any(_normalize_font_name(m) in normalized for m in CJK_SERIF_MARKERS):\n return \"serif\"\n return \"other\"\n\n\nclass EmptyPdfError(ValueError):\n pass\n\n\ndef _cjk_font_usage(pdf_path: Path) -> dict[str, int]:\n \"\"\"Return {font name: ideographs it drew} across the whole document.\n\n Judging the font table alone cannot tell which family actually set the body\n text: a document can embed a serif for two glyphs and a sans for the other\n three thousand. Walking spans attributes every ideograph to the font that\n drew it, so the verdict is about the text a reader sees.\n \"\"\"\n fitz = require_pymupdf()\n per_font: dict[str, int] = {}\n with fitz.open(str(pdf_path)) as doc:\n if doc.page_count == 0:\n raise EmptyPdfError(\"PDF has no pages\")\n for page in doc:\n for block in page.get_text(\"dict\").get(\"blocks\", []):\n for line in block.get(\"lines\", []):\n for span in line.get(\"spans\", []):\n ideographs = sum(\n 1 for ch in span.get(\"text\", \"\")\n if \"㐀\" \u003c= ch \u003c= \"鿿\" or \"가\" \u003c= ch \u003c= \"힯\"\n )\n if ideographs:\n font = span.get(\"font\", \"\") or \"(unnamed)\"\n per_font[font] = per_font.get(font, 0) + ideographs\n return per_font\n\n\n# A second CJK family this far into the text is per-glyph fontconfig fallback,\n# not a design decision: it splits single words down the middle. Below it, a\n# stray symbol picked up elsewhere is not worth a failure.\nMIXED_FAMILY_MIN_SHARE = 0.05\nMIXED_FAMILY_MIN_CHARS = 3\n\n# Weight and style words a PDF appends to the family it subsets. Bold body text\n# is a second BaseFont entry off one family (TsangerJinKai02 plus\n# TsangerJinKai02-Medium), which the mixed-family rule must not read as two\n# typefaces. Longest first so 'semibold' strips before 'bold'.\n# Two-letter abbreviations (Md, Rg, Bd) are deliberately absent: no font seen\n# here uses them, and they are short enough to bite a real family name.\n_FONT_STYLE_SUFFIXES = (\n \"extralight\", \"semibold\", \"demibold\", \"oblique\", \"regular\", \"medium\",\n \"italic\", \"mediu\", \"light\", \"black\", \"heavy\", \"roman\", \"book\", \"bold\",\n \"thin\", \"lig\",\n)\n\n\n# Numeric weight markers: TsangerJinKai ships W04/W05, other foundries use a\n# three-digit CSS weight. Both name one family at two weights.\n_FONT_WEIGHT_CODE = re.compile(r\"(?:w\\d{2}|\\d{3})$\")\n\n\ndef _font_family_key(name: str) -> str:\n \"\"\"Reduce a font name to its family, dropping trailing weight/style words.\"\"\"\n key = _normalize_font_name(name)\n changed = True\n while changed:\n changed = False\n for suffix in _FONT_STYLE_SUFFIXES:\n if key.endswith(suffix) and len(key) > len(suffix):\n key = key[: -len(suffix)]\n changed = True\n break\n stripped = _FONT_WEIGHT_CODE.sub(\"\", key)\n if stripped != key and stripped:\n key = stripped\n changed = True\n return key\n\n\nFONT_RECOVERY_HINT = (\n \" fix: bash scripts/ensure-fonts.sh (installs a CJK serif into the user font dir), \"\n \"or `brew install --cask font-source-han-serif-sc` on macOS, \"\n \"or `apt-get install fonts-noto-cjk` on Linux, then render again\"\n)\n\n\ndef check_fonts(paths: list[str]) -> int:\n \"\"\"CLI: --check-fonts doc.pdf [more.pdf ...]\n\n Deterministic gate on the one failure a perceptual pass reliably misses.\n A missing CJK serif does not produce fallback boxes; it silently swaps in a\n sans that still reads, so both the agent and the author sign off on a page\n whose typography is no longer the system's. The rendered PDF's own span\n table settles it: whichever font drew the body ideographs is the verdict.\n \"\"\"\n files = [p for p in paths if not p.startswith(\"-\")]\n if not files:\n print(\"ERROR: usage: --check-fonts path/to/doc.pdf [more.pdf ...]\")\n return 2\n\n failures = 0\n empty = 0\n for raw in files:\n pdf = resolve_input(raw)\n rel = rel_to_root(pdf)\n if not pdf.exists():\n print(f\"ERROR: {raw}: file not found\")\n failures += 1\n continue\n try:\n usage = _cjk_font_usage(pdf)\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n except EmptyPdfError:\n # Same verdict as the other PDF gates: nothing was checked.\n print(f\"ERROR: {raw}: PDF has no pages\")\n empty += 1\n continue\n except Exception as exc:\n print(f\"ERROR: {rel}: could not read text spans: {exc}\")\n failures += 1\n continue\n\n count = sum(usage.values())\n if not usage or count \u003c MIN_CJK_CHARS_TO_JUDGE:\n print(f\"OK: {rel}: no CJK body text to judge ({count} ideograph(s))\")\n continue\n\n # Collapse weight variants into their family before counting families.\n families: dict[str, tuple[str, int]] = {}\n for name, chars in usage.items():\n key = _font_family_key(name)\n label, total = families.get(key, (name, 0))\n if chars > usage.get(label, 0):\n label = name\n families[key] = (label, total + chars)\n\n dominant = max(families, key=lambda k: families[k][1])\n font, font_chars = families[dominant]\n others = [\n (label, chars) for key, (label, chars) in families.items()\n if key != dominant\n and chars >= MIXED_FAMILY_MIN_CHARS\n and chars / count >= MIXED_FAMILY_MIN_SHARE\n ]\n if others:\n detail = \", \".join(f\"{name} ({chars})\" for name, chars in sorted(others))\n print(f\"ERROR: {rel}: CJK text split across families: {font} ({font_chars}), {detail}\")\n print(\" per-glyph fallback breaks single words across two typefaces; \"\n \"one page carries one serif\")\n print(FONT_RECOVERY_HINT)\n failures += 1\n continue\n\n verdict = _classify_cjk_font(font)\n if verdict == \"primary\":\n print(f\"OK: {rel}: CJK body text in {font} ({count} ideographs)\")\n elif verdict == \"serif\":\n print(f\"WARN: {rel}: CJK body text in {font}, a serif fallback, not the kami primary\")\n print(\" acceptable, but the page is not the reference rendering\")\n else:\n print(f\"ERROR: {rel}: CJK body text in {font}, not a CJK serif\")\n print(\" the parchment metrics are tuned for serif stroke density; a sans \"\n \"substitution reads heavier and flatter at the same size, with no \"\n \"fallback box to make it obvious\")\n print(FONT_RECOVERY_HINT)\n failures += 1\n\n if empty:\n return 2\n return 0 if failures == 0 else 1\n\n\ndef _check_font_sources(html_path: Path) -> list[str]:\n \"\"\"Return list of local @font-face src files that are missing on disk.\"\"\"\n text = html_path.read_text(encoding=\"utf-8\", errors=\"replace\")\n missing: list[str] = []\n for url in re.findall(r\"\"\"url\\([\"']?([^\"')]+)[\"']?\\)\"\"\", text):\n if url.startswith((\"http://\", \"https://\", \"data:\", \"#\")):\n continue\n resolved = (html_path.parent / url).resolve()\n if not resolved.exists():\n missing.append(url)\n return missing\n\n\nTOC_TARGET_COUNTER = \"target-counter(attr(href), page)\"\n\n\nclass _TocTitles(HTMLParser):\n \"\"\"Read the long-doc template's actual TOC entries, including inline markup.\"\"\"\n\n def __init__(self):\n super().__init__(convert_charrefs=True)\n self.rows: list[tuple[str, str]] = []\n self.target: str | None = None\n self.parts: list[str] = []\n\n def handle_starttag(self, tag, attributes):\n attrs = dict(attributes)\n if tag == \"a\" and \"toc-title\" in (attrs.get(\"class\") or \"\").split():\n href = attrs.get(\"href\") or \"\"\n if href.startswith(\"#\"):\n self.target = unquote(href[1:])\n self.parts = []\n\n def handle_data(self, data):\n if self.target is not None:\n self.parts.append(data)\n\n def handle_endtag(self, tag):\n if tag == \"a\" and self.target is not None:\n self.rows.append((self.target, \"\".join(self.parts)))\n self.target = None\n\n\ndef _toc_page_number_issues(pdf_path: Path, source_html: str) -> list[str]:\n \"\"\"Cross-check every rendered TOC page number against the page its row links to.\n\n WeasyPrint resolves `target-counter` at render time, so a failed resolution\n still prints a well-formed numeral and every text-level gate accepts it.\n WeasyPrint 70.0 does exactly that when the TOC anchor is a flex container:\n each row renders `0`. Comparing the glyph against the link destination\n catches both that failure and ordinary page-number drift.\n \"\"\"\n try:\n fitz = require_pymupdf()\n except MissingDepError as exc:\n print(f\" WARN: TOC page-number check skipped: {exc}\")\n return []\n\n parser = _TocTitles()\n parser.feed(source_html)\n parser.close()\n if not parser.rows:\n return []\n\n def normalized(text):\n # An unused discretionary hyphen is present in HTML but absent in PDF text.\n return \"\".join(unicodedata.normalize(\"NFKC\", text).replace(\"\\u00ad\", \"\").split())\n\n candidates: list[tuple[str, str, str, int]] = []\n with fitz.open(str(pdf_path)) as doc:\n for page in doc:\n destinations: dict[str, list] = {}\n for link in page.get_links():\n dest = link.get(\"nameddest\")\n if dest:\n destinations.setdefault(dest, []).append(link)\n for dest, links in sorted(destinations.items()):\n for numeral in links:\n rect = numeral[\"from\"]\n # A block TOC anchor emits a row-sized link and a contained\n # right-floated numeral. Styled inline links can also nest,\n # so geometry alone is not enough: match authored titles below.\n containers = [\n outer for outer in links\n if outer[\"from\"].width > rect.width\n and rect in outer[\"from\"]\n and abs(outer[\"from\"].x1 - rect.x1) \u003c 0.01\n ]\n if not containers:\n continue\n outer = min(containers, key=lambda link: link[\"from\"].get_area())\n candidates.append((\n dest, normalized(page.get_textbox(outer[\"from\"])),\n normalized(page.get_textbox(rect)), numeral[\"page\"] + 1,\n ))\n\n issues: list[str] = []\n for dest, title in parser.rows:\n title = normalized(title)\n match = next((i for i, (target, row, number, _) in enumerate(candidates)\n if target == dest and row in (number + title, title + number)), None)\n if match is None:\n issues.append(f\"TOC row '{dest}' has no rendered page-number link\")\n continue\n _, _, number, expected = candidates.pop(match)\n if not number.isdecimal():\n issues.append(f\"TOC row '{dest}' renders no page number (links to page {expected})\")\n elif int(number) != expected:\n issues.append(f\"TOC row '{dest}' renders page {number} but links to page {expected}\")\n return issues\n\n\ndef verify_target(name: str, source: str, max_pages: int, src_dir: Path) -> list[str]:\n \"\"\"Render `source` to a PDF, then run page-count and font checks.\"\"\"\n issues: list[str] = []\n src = src_dir / source\n if not src.exists():\n issues.append(f\"source not found: {src}\")\n return issues\n\n # Warn about missing local font files before rendering\n missing_fonts = _check_font_sources(src)\n if missing_fonts:\n for mf in missing_fonts:\n print(f\" [FONT MISS] {name}: {mf} not found\")\n print(f\" [FONT MISS] Repo checkout fix: bash scripts/ensure-fonts.sh (copies the tracked fonts from the repository root into assets/fonts)\")\n print(f\" [FONT MISS] Skill recovery (downloads to the user font dir, not the skill): bash scripts/ensure-fonts.sh\")\n print(f\" [FONT MISS] Fallback: brew install --cask font-source-han-serif-sc\")\n\n out = EXAMPLES / f\"{name}.pdf\"\n try:\n n = render_pdf(src, out)\n except (MissingDepError, MathRenderError) as exc:\n issues.append(str(exc))\n return issues\n except Exception as exc: # WeasyPrint / pypdf / OS errors: report, keep the sweep going\n issues.append(f\"render failed: {exc}\")\n return issues\n\n # Resume templates share the balance gate's exact two-page contract. Keep\n # fill and gap checks in the filled-document flow: source templates contain\n # placeholder copy, so their rendered density is not an authoring signal.\n if \"resume\" in name:\n cfg = load_checks_thresholds()[\"resume_balance\"]\n page_issues = _resume_balance_issues(\n [],\n n,\n float(cfg[\"min_fill_pct\"]),\n float(cfg[\"max_fill_pct\"]),\n float(cfg[\"max_gap_pct\"]),\n )\n hint = \"\"\n if n == 3:\n hint = '; add class=\"resume--dense\" to \u003cbody> or tighten .proj-text line-height to 1.38'\n issues.extend(f\"page count: {issue}{hint}\" for issue in page_issues)\n elif max_pages and n > max_pages:\n issues.append(f\"page overflow: {n} pages (limit {max_pages})\")\n\n # Rendered TOC page numbers (long-doc family)\n source_html = src.read_text(encoding=\"utf-8\")\n if TOC_TARGET_COUNTER in source_html:\n issues.extend(_toc_page_number_issues(out, source_html))\n\n # font check\n embedded = _pdf_font_names(out)\n fallback_present = any(\n kw in font for font in embedded\n for kw in RECOGNIZABLE_FALLBACK_FONT_MARKERS\n )\n\n # Diagram templates are language-neutral and often rely on fallback stacks,\n # so only enforce that at least one recognizable serif/sans fallback exists.\n is_diagram = src_dir == DIAGRAMS\n if is_diagram:\n if not fallback_present:\n issues.append(f\"no recognizable font embedded in {out.name}\")\n return issues\n\n is_en = name.endswith(\"-en\")\n is_ko = name.endswith(\"-ko\")\n expected = EN_PRIMARY_FONTS if is_en else (KO_PRIMARY_FONTS if is_ko else CN_PRIMARY_FONTS)\n if not any(exp in font_name for exp in expected for font_name in embedded):\n primary = next(iter(expected))\n if not fallback_present:\n issues.append(f\"no recognizable font embedded in {out.name}\")\n elif os.environ.get(\"KAMI_ALLOW_FALLBACK_ONLY\"):\n # CI / headless boxes never have commercial fonts (TsangerJinKai02,\n # Charter). Treat \"primary missing, fallback present\" as a warning\n # there so CI can still gate page-count regressions.\n print(f\" WARN: {name}: primary font ({primary}) not embedded; using fallback\")\n else:\n issues.append(f\"primary font ({primary}) not embedded; using fallback\")\n\n return issues\n\n\ndef verify_screen_target(name: str, source: str) -> list[str]:\n \"\"\"Lint a browser-only template via lint.scan_file.\"\"\"\n src = TEMPLATES / source\n if not src.exists():\n return [f\"source not found: {src}\"]\n findings = scan_file(src)\n if findings:\n return [f\"{len(findings)} template violation(s)\"]\n return []\n\n\ndef verify_all(target: str | None) -> int:\n \"\"\"Drive verification across the requested target set.\"\"\"\n html_targets = build_targets()\n screen_targets_map = screen_targets()\n diagram_targets_map = diagram_targets()\n pptx_targets_map = pptx_targets()\n\n targets_to_run: dict[str, tuple[str, int, Path] | None] = {}\n screen_targets_to_run: dict[str, str] = {}\n if target:\n if target in html_targets:\n src, mp = html_targets[target]\n targets_to_run[target] = (src, mp, TEMPLATES)\n elif target in screen_targets_map:\n screen_targets_to_run[target] = screen_targets_map[target]\n elif target in diagram_targets_map:\n targets_to_run[target] = (diagram_targets_map[target], 0, DIAGRAMS)\n elif target in pptx_targets_map:\n targets_to_run[target] = None\n else:\n print(f\"ERROR: unknown target: {target}\")\n return 2\n else:\n for name, (src, mp) in html_targets.items():\n targets_to_run[name] = (src, mp, TEMPLATES)\n for name, src in screen_targets_map.items():\n screen_targets_to_run[name] = src\n for name, src in diagram_targets_map.items():\n targets_to_run[name] = (src, 0, DIAGRAMS)\n for name in pptx_targets_map:\n targets_to_run[name] = None\n\n failures = 0\n rows: list[tuple[str, str]] = []\n for name, config in targets_to_run.items():\n if config is None:\n issues = [] if build_slides(name) else [\"slides build failed\"]\n else:\n source, max_pages, src_dir = config\n issues = verify_target(name, source, max_pages, src_dir)\n if issues:\n rows.append((f\"ERROR: {name}\", \"; \".join(issues)))\n failures += 1\n else:\n rows.append((f\"OK: {name}\", \"ok\"))\n\n for name, source in screen_targets_to_run.items():\n issues = verify_screen_target(name, source)\n if issues:\n rows.append((f\"ERROR: {name}\", \"; \".join(issues)))\n failures += 1\n else:\n rows.append((f\"OK: {name}\", \"static HTML template\"))\n\n for status, detail in rows:\n print(f\"{status}: {detail}\")\n\n if target is None:\n pdfs = default_example_pdfs()\n if pdfs:\n print()\n print(\"Density scan (advisory):\")\n scan = scan_density(pdfs)\n if scan is not None:\n sparse, warn, _, scanned = scan\n if sparse + warn == 0:\n print(f\" OK: no density issues across {scanned} PDF(s)\")\n else:\n density_cfg = load_checks_thresholds()[\"density\"]\n sparse_pct_disp = int(round(float(density_cfg[\"sparse_pct\"]) * 100))\n warn_pct_disp = int(round(float(density_cfg[\"warn_pct\"]) * 100))\n if sparse:\n print(f\" {sparse} SPARSE page(s) (>{sparse_pct_disp}% trailing whitespace) across {scanned} PDF(s)\")\n if warn:\n print(f\" {warn} WARN page(s) (>{warn_pct_disp}%) across {scanned} PDF(s)\")\n print(\" (advisory: re-author with the writing.md «Page density» merge rule. Does not fail --verify.)\")\n\n return 0 if failures == 0 else 1\n","content_type":"text/x-python; charset=utf-8","language":"python","size":23824,"content_sha256":"8c631394d4178954aed16ad0459ebf80db892b26ce01d4841a1e0294caa8a1a0"},{"filename":"scripts/visual.py","content":"\"\"\"Perceptual verification: rasterize a rendered PDF for an eyeball pass.\n\nGeometric checks (density, orphans, page count) cannot see a fallback glyph,\nan arrow crossing a label, or a heading stranded at a page bottom. This module\ncloses that gap: it renders every PDF page to PNG and prints the review\nchecklist the agent must walk while viewing each image. The agent (or a\nvision-capable host) is the judge; this script guarantees the evidence exists\nand the criteria are fixed instead of recalled from memory.\n\nUsage:\n python3 scripts/build.py --check-visual path/to/doc.pdf\n\"\"\"\nfrom __future__ import annotations\n\nimport math\nimport tempfile\nfrom pathlib import Path\n\nfrom optional_deps import MissingDepError, require_pymupdf\nfrom shared import load_checks_thresholds, rel_to_root, resolve_input\nfrom verify import check_fonts\n\n# Distilled from references/design.md and references/production.md Part 4.\n# Keep entries stable: hosts learn to walk this list page by page.\nREVIEW_CHECKLIST = (\n \"fonts: no fallback boxes or mixed families; CJK bold is a real weight, not synthetic\",\n \"tags and chips: solid backgrounds, no double rectangle or double ring artifacts\",\n \"figures: no overlapping labels, arrows never touch module edges, arrowheads visible\",\n \"page breaks: no heading stranded at a page bottom, no card or table split mid-body\",\n \"emphasis: brand color only on numbers or distinctive phrases, at most 2 per line\",\n \"density: body pages read 60-80% full (cover and last page exempt), no orphan trailing line\",\n \"wraps: no line one word from wrapping, no line wrapping early before filling its container\",\n \"alignment: columns share baselines, margins are even, nothing crowds the page edge\",\n \"tables: one neutral rule color, hairline dividers, and enough vertical padding for each row to breathe\",\n \"subtraction: no decorative ticks, short rules, or side accents; every visible rule separates data, regions, relationships, or state\",\n)\n\nMIN_DPI = 36\nMAX_DPI = 300\nMAX_PAGES = 200\nMAX_PAGE_PIXELS = 50_000_000\n\n\ndef visual_output_dir(pdf: Path) -> Path:\n return pdf.parent / f\"{pdf.stem}-visual\"\n\n\ndef _clear_page_images(target: Path) -> None:\n \"\"\"Drop page PNGs from a previous run.\n\n A re-render with fewer pages must not leave stale page-NN.png files\n behind, or the reviewer walks pages that no longer exist.\n \"\"\"\n for old in target.glob(\"page-*.png\"):\n old.unlink()\n\n\ndef render_pages(pdf: Path, out_dir: Path | None = None, dpi: int | None = None) -> list[Path]:\n \"\"\"Render every page of `pdf` to PNG; return the image paths.\"\"\"\n fitz = require_pymupdf()\n if dpi is None:\n dpi = int(load_checks_thresholds().get(\"visual\", {}).get(\"dpi\", 110))\n if isinstance(dpi, bool) or not isinstance(dpi, int) or not MIN_DPI \u003c= dpi \u003c= MAX_DPI:\n raise ValueError(f\"dpi must be an integer from {MIN_DPI} to {MAX_DPI}\")\n target = out_dir or visual_output_dir(pdf)\n if target.is_symlink():\n raise ValueError(f\"visual output directory must not be a symbolic link: {target}\")\n target.parent.mkdir(parents=True, exist_ok=True)\n with fitz.open(str(pdf)) as doc:\n if len(doc) == 0:\n raise ValueError(\"PDF has no pages\")\n if len(doc) > MAX_PAGES:\n raise ValueError(f\"PDF has {len(doc)} pages (limit {MAX_PAGES})\")\n scale = dpi / 72\n for index, page in enumerate(doc, start=1):\n width = math.ceil(page.rect.width * scale)\n height = math.ceil(page.rect.height * scale)\n pixels = width * height\n if pixels > MAX_PAGE_PIXELS:\n raise ValueError(\n f\"PDF page {index} would render {width}x{height} pixels \"\n f\"({pixels} pixels, limit {MAX_PAGE_PIXELS})\"\n )\n # Render away from the final evidence directory. A corrupt later page\n # must not erase or partially replace screenshots from the last good run.\n with tempfile.TemporaryDirectory(dir=target.parent, prefix=f\".{target.name}-\") as raw_tmp:\n tmp = Path(raw_tmp)\n staged: list[Path] = []\n for index, page in enumerate(doc, start=1):\n out = tmp / f\"page-{index:02d}.png\"\n page.get_pixmap(dpi=dpi).save(str(out))\n staged.append(out)\n\n target.mkdir(parents=True, exist_ok=True)\n _clear_page_images(target)\n pages: list[Path] = []\n for page in staged:\n final = target / page.name\n page.replace(final)\n pages.append(final)\n return pages\n\n\ndef check_visual(paths: list[str]) -> int:\n \"\"\"CLI: --check-visual doc.pdf [more.pdf ...]\n\n Exports page images and prints the perceptual checklist. Passing this\n check means the images exist; the review itself is the caller's job:\n view every page image against the checklist before shipping.\n \"\"\"\n files = [p for p in paths if not p.startswith(\"-\")]\n if not files:\n print(\"ERROR: usage: --check-visual path/to/doc.pdf [more.pdf ...]\")\n return 2\n\n failures = 0\n rendered: list[tuple[Path, list[Path]]] = []\n for raw in files:\n pdf = resolve_input(raw)\n rel = rel_to_root(pdf)\n if not pdf.exists():\n print(f\"ERROR: {raw}: file not found\")\n failures += 1\n continue\n try:\n pages = render_pages(pdf)\n except MissingDepError as exc:\n print(f\"ERROR: {exc}\")\n return 2\n except Exception as exc:\n print(f\"ERROR: {rel}: could not rasterize: {exc}\")\n failures += 1\n continue\n rendered.append((pdf, pages))\n print(f\"OK: {rel}: {len(pages)} page image(s) -> {rel_to_root(visual_output_dir(pdf))}\")\n for page in pages:\n print(f\" {rel_to_root(page)}\")\n\n if rendered:\n # The first checklist entry (\"no fallback boxes or mixed families\") is the\n # one an eyeball pass fails at: a missing CJK serif substitutes a sans that\n # reads perfectly well, so nothing looks broken enough to catch. Settle it\n # mechanically here rather than leaving it to the reviewer's judgement.\n print(\"Font check (deterministic, settles checklist item 1):\")\n if check_fonts([str(pdf) for pdf, _ in rendered]) != 0:\n failures += 1\n print(\"Review checklist (view every page image before shipping):\")\n for item in REVIEW_CHECKLIST:\n print(f\" - {item}\")\n\n return 0 if failures == 0 else 1\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6591,"content_sha256":"1517fc6126093633e4ff30e6c87b5480abf1380be587bab484512abbdb6f5415"},{"filename":"VERSION","content":"1.20.0\n","content_type":"text/plain; charset=utf-8","language":null,"size":7,"content_sha256":"26ac7beccb7951efb69255974ffc70a13bb2e68ae84dcb5456c3fb46294bdba8"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"kami · 紙","type":"text"}]},{"type":"paragraph","content":[{"text":"紙 · かみ","type":"text","marks":[{"type":"strong"}]},{"text":" means paper in Japanese.","type":"text"}]},{"type":"paragraph","content":[{"text":"Good content deserves good paper. Use Kami’s templates and layout rules to create documents and landing pages with serif fonts, warm backgrounds, and ink-blue accents.","type":"text"}]},{"type":"paragraph","content":[{"text":"Part of ","type":"text"},{"text":"Kaku · Waza · Kami","type":"text","marks":[{"type":"code_inline"}]},{"text":" - Kaku writes code, Waza drills habits, ","type":"text"},{"text":"Kami delivers documents","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Commands and paths below are relative to this skill's directory. Keep the document in the user's working directory and call the scripts from there by their full path (","type":"text"},{"text":"python3 \u003cskill>/scripts/build.py ...","type":"text","marks":[{"type":"code_inline"}]},{"text":"); relative file arguments resolve from your working directory first.","type":"text"}]},{"type":"paragraph","content":[{"text":"Update check (non-blocking).","type":"text","marks":[{"type":"strong"}]},{"text":" At the start of a task, run ","type":"text"},{"text":"bash scripts/check-update.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":". It keeps a local daily marker and, at most once a day, reads Kami's latest published GitHub Release; it uploads no document or task content and stays silent when offline or sandboxed. When it prints a line about a newer Kami, relay it and continue.","type":"text"}]},{"type":"paragraph","content":[{"text":"Brand profile.","type":"text","marks":[{"type":"strong"}]},{"text":" If ","type":"text"},{"text":"~/.config/kami/brand.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or the legacy ","type":"text"},{"text":"~/.kami/brand.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") exists, read ","type":"text"},{"text":"references/brand-profile.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and apply it. Precedence: explicit prompt > editorial judgment > habit notes > frontmatter defaults > built-in defaults. The profile fills gaps silently and never overrides the current conversation. When the user names another project as the visual reference, run the style scan in the same file. When they give only a brand website, ","type":"text"},{"text":"python3 scripts/brand_extract.py \u003curl>","type":"text","marks":[{"type":"code_inline"}]},{"text":" drafts a profile; show the draft and use it for this document only after they accept it.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"1 · Lock the contract","type":"text"}]},{"type":"paragraph","content":[{"text":"Before creating or changing an output, lock six things: language, template, output formats, page or length target, visual acceptance, and the verification command. Infer them from the request, then state the contract to the user in one short line in their language (template, length, formats, the figures you plan, any missing material) and continue without waiting. If they push back, adjust.","type":"text"}]},{"type":"paragraph","content":[{"text":"Question budget.","type":"text","marks":[{"type":"strong"}]},{"text":" Infer first. Ask at most once, in one compact question with at most two parts, and only when two or more of purpose, audience, hard constraints, or success are genuinely unresolvable, or two templates genuinely both fit. Otherwise take the smallest reasonable assumption and report it at the end.","type":"text"}]},{"type":"paragraph","content":[{"text":"Route by the artifact's current state:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Current task","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mode","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contract","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New document or substantial restructuring","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New document","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lock the contract, write ","type":"text"},{"text":"content.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"brief","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"content","type":"text","marks":[{"type":"code_inline"}]},{"text":", then fill and deliver","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Text replacement, translation, or factual correction in an existing artifact","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Content-only","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Preserve CSS and layout unless the new copy proves a fit defect","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User supplies a render or screenshot and rejects how it looks","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Visual repair","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Treat the render as the brief; follow «Feedback protocol»","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Standalone generated illustration, cover, social card, or redraw","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Generated asset","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Lock the semantic brief before pixels (","type":"text"},{"text":"references/diagrams.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" section 11); preserve accepted parts across iterations","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Use the nearest existing template and check. Do not add a template, shared CSS layer, dependency, script flag, or optional mode unless the request cannot be met without it.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Language","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User language","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HTML templates","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides (PDF default)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides (PPTX, only when an editable deck is requested)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides (Marp)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Chinese (primary)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"*.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-marp.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"English","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"*-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-en.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-marp-en.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Japanese (best-effort)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"*.html","type":"text","marks":[{"type":"code_inline"}]},{"text":", JP Mincho first","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-marp.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Korean","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"*-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-en.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" only if PPTX is required","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-marp.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Other (best-effort)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CJK or EN path by script coverage","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"-en","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"slides-en.py","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-marp.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"-en","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Japanese, Korean, and other languages need a visual pass on the page images before shipping. Reference docs are shared English specs.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Document type","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User says","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Document","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CN template","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EN template","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"KO template","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"one-pager / 方案 / 执行摘要 / exec summary\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"One-Pager","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one-pager.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one-pager-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one-pager-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"white paper / 白皮书 / 长文 / 年度总结 / technical report\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Long Doc","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"long-doc.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"long-doc-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"long-doc-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"formal letter / 信件 / 辞职信 / 推荐信 / memo\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Letter","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"letter.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"letter-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"letter-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"portfolio / 作品集 / case studies\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Portfolio","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"portfolio.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"portfolio-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"portfolio-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"resume / CV / 简历 / 履歴書\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resume","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"resume.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"resume-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"resume-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"slides / PPT / deck / 演示\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides-weasy-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"个股研报 / equity report / 估值分析 / investment memo / 股票分析\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Equity Report","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"equity-report.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"equity-report-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"equity-report-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"更新日志 / changelog / release notes / 版本记录\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Changelog","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"changelog.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"changelog-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"changelog-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"landing page / 落地页 / 官网 / product page / 产品页\"","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Landing Page","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"landing-page.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"landing-page-en.html","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"landing-page-ko.html","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"When two cells both fit, decide from the signals before asking:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Signal","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Document","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1 page for investors, recruiters, or an executive summary","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"one-pager","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1 page of formal correspondence (sales, hiring, resignation, memo)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"letter","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exactly 2 pages of career narrative with project bullets","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"resume","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3-6 visual-heavy pages showcasing projects","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"portfolio","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6-15 pages of sustained argument, low visual density","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"long-doc","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Presentation flow, one assertion per slide","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"slides","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Metrics dashboard plus thesis plus price or risk view","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"equity-report","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Version-by-version release facts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"changelog","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Product showcase, pricing, screenshots, FAQ for a browser","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"landing-page","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Ask only in cases like \"1.5 pages of career story, heavy visuals\" (resume or portfolio?) or \"2 pages of exec summary with metric tiles\" (one-pager or equity-report?).","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Changelog vs. release notes","type":"text","marks":[{"type":"strong"}]},{"text":": the changelog template is a styled document. GitHub release notes are a separate deliverable; use ","type":"text"},{"text":"/write","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Landing page","type":"text","marks":[{"type":"strong"}]},{"text":": screen-first HTML, no PDF. Fill the ","type":"text"},{"text":"{{PLACEHOLDER}}","type":"text","marks":[{"type":"code_inline"}]},{"text":" values and comment blocks and save a ready-to-serve file. The accent follows the product: use its own main color, ask the user to pick when it has none, and keep ink-blue only as the fallback (","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Section 11 «Accent on a product site»). Companion files (","type":"text"},{"text":"landing-page-*.example","type":"text","marks":[{"type":"code_inline"}]},{"text":") and the production site mode (docs, help, releases, legal pages, or more than two locales) live in ","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Section 11. Keep project-specific release artifacts, payment providers, appcast rules, and private local paths out of Kami.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Diagrams inside a document","type":"text","marks":[{"type":"strong"}]},{"text":" come from ","type":"text"},{"text":"assets/diagrams/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (eighteen types), not a template. ","type":"text"},{"text":"references/diagrams.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" section 1 picks the type; extract the ","type":"text"},{"text":"\u003csvg>","type":"text","marks":[{"type":"code_inline"}]},{"text":" block into a ","type":"text"},{"text":"\u003cfigure>","type":"text","marks":[{"type":"code_inline"}]},{"text":". The same file owns architecture boards, diagrams maintained in the user's repo, and generated raster illustrations.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Charts from data","type":"text","marks":[{"type":"strong"}]},{"text":" (pick and embed them without asking) are generated, never drawn by hand: write the numbers as a spec and run ","type":"text"},{"text":"python3 scripts/build.py --chart spec.json -o chart.svg","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"references/diagrams.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" section 9).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Slides","type":"text","marks":[{"type":"strong"}]},{"text":": read ","type":"text"},{"text":"references/deck-preflight.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before drafting.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"2 · Sources, materials, and content","type":"text"}]},{"type":"paragraph","content":[{"text":"Source check.","type":"text","marks":[{"type":"strong"}]},{"text":" When the document names a company, product, person, release date, version, funding round, metric, market fact, or technical spec, work from primary sources, note source and date for the facts that drive it, and ask when sources conflict (","type":"text"},{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «5. Sources before phrasing»). Skip it for personal drafts where the user supplied everything.","type":"text"}]},{"type":"paragraph","content":[{"text":"Material check.","type":"text","marks":[{"type":"strong"}]},{"text":" A branded subject needs what makes it recognizable: logo, product image or current UI screenshot, and brand colors when the document is branded (","type":"text"},{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «6. Materials serve recognition»). When the request names no logo but the brand profile has one, fill the commented ","type":"text"},{"text":".brand-logo","type":"text","marks":[{"type":"code_inline"}]},{"text":" slot in ","type":"text"},{"text":"one-pager","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"portfolio","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"slides-weasy","type":"text","marks":[{"type":"code_inline"}]},{"text":" (expand ","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":" to an absolute path; leave the slot commented if the file is missing). An explicit logo in the current request always wins.","type":"text"}]},{"type":"paragraph","content":[{"text":"Gap report.","type":"text","marks":[{"type":"strong"}]},{"text":" Everything the template needs that the input lacks, missing facts and missing materials alike, goes into one compact table shown once, with one question only when a missing item would change the deliverable. Inside the document, mark a gap as ","type":"text"},{"text":"[DATA NEEDED: what]","type":"text","marks":[{"type":"code_inline"}]},{"text":". Never fill a gap with a guess, a stock image, an approximated logo, or an invented value.","type":"text"}]},{"type":"paragraph","content":[{"text":"Distill.","type":"text","marks":[{"type":"strong"}]},{"text":" Unless the content already maps one-to-one onto the template sections or the user said to use it as-is, distill it. Every factual claim, number, date, name, source, and material reference from the input must land in a template section or in the gap report.","type":"text"}]},{"type":"paragraph","content":[{"text":"Content IR (new documents).","type":"text","marks":[{"type":"strong"}]},{"text":" Write the distilled content and the locked contract to ","type":"text"},{"text":"content.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" next to the working HTML before filling. Read ","type":"text"},{"text":"references/schemas/\u003ctype>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" first: its ","type":"text"},{"text":"$comment","type":"text","marks":[{"type":"code_inline"}]},{"text":" notes carry the per-field quality bar. The envelope is strict: only ","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lang","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"cn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"en","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ko","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"zh-TW","type":"text","marks":[{"type":"code_inline"}]},{"text":", ...), ","type":"text"},{"text":"brief","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"content","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"type\": \"resume\",\n \"lang\": \"cn\",\n \"brief\": {\n \"audience\": \"Hiring manager for a senior product role\",\n \"job\": \"Earn an interview by proving scope and outcomes\",\n \"template\": \"resume\",\n \"formats\": [\"html\", \"pdf\"],\n \"page_target\": 2,\n \"narrative\": \"Scope first, then evidence, then fit\",\n \"required_facts\": [\"team size\", \"measured outcomes\"],\n \"required_assets\": [],\n \"acceptance_checks\": [\"two pages\", \"all atomic facts survive\"],\n \"preserve\": [],\n \"explicit_deviations\": []\n },\n \"content\": { ... }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a schema name in ","type":"text"},{"text":"references/schemas/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). For a visual repair, add ","type":"text"},{"text":"target","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"evidence","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"preserve","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"brief","type":"text","marks":[{"type":"code_inline"}]},{"text":". Validate before layout with ","type":"text"},{"text":"python3 scripts/build.py --check-content content.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and fix errors by fixing the content (or asking for the fact), never by loosening structure. Short atomic values (names, metrics, dates, bullets) must survive into the document verbatim; values over 80 characters may be rephrased.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"3 · Read the right amount","type":"text"}]},{"type":"paragraph","content":[{"text":"Read the lowest tier that covers the task. The templates already carry every token and component, so a new document does not need the full design spec.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Task","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Content-only change","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CHEATSHEET.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Layout tweak within spec","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CHEATSHEET.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" + the template","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"New document","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the template + ","type":"text"},{"text":"CHEATSHEET.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"references/schemas/\u003ctype>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" + the ","type":"text"},{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" sections for that type («Per-document strategies», «Quality bars by document type»); open ","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" only for the component or rule you are about to change","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resume","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+ ","type":"text"},{"text":"references/resume-writing.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides, long-doc, portfolio, equity-report, changelog","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+ ","type":"text"},{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «Page density»","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Slides","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"+ ","type":"text"},{"text":"references/deck-preflight.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the ","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" section 8 «Deck Recipe» it points to","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Diagram","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/diagrams.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"references/mermaid.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the source is Mermaid text","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Company, product, market, or branded subject","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" sections 5-6","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rendering bug, font issue, overflow","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/production.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Part 4 (and the design spec if CSS is the cause)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reviewing an AI-generated draft","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/anti-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"4 · Fill the template","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Copy the template into the working directory; never write HTML from scratch. CSS stays untouched during a content fill. A layout change stays within spec, and a real style change moves ","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the sibling templates together, never one file.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Meet the quality bar for the type in ","type":"text"},{"text":"references/writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «Quality bars by document type»: a resume bullet needs action, honest scope, and an observable result; an equity report needs variant perception and quantified catalysts; slides need assertion titles. Structure is necessary, not sufficient.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not invent metrics, financial data, or statistics, and keep any figure you compute from the supplied ones next to its inputs or marked as derived; do not describe stock images as placeholders; do not pad slots (three real projects do not become five). Avoid the patterns in ","type":"text"},{"text":"references/anti-patterns.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tables separate rows with whitespace first and neutral hairlines second; decoration earns its place only by encoding data, state, grouping, or a relationship (","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «Table (kami-table)» and «Subtractive rule»).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Resume: run the recruiter pass in ","type":"text"},{"text":"references/resume-writing.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before building.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Mathematics is strict LaTeX: author formulas only as inline ","type":"text"},{"text":"\\( ... \\)","type":"text","marks":[{"type":"code_inline"}]},{"text":" or display ","type":"text"},{"text":"\\[ ... \\]","type":"text","marks":[{"type":"code_inline"}]},{"text":" in rendered body text, never as Unicode approximations, ASCII fractions, screenshots, or raw TeX on the page. ","type":"text"},{"text":"--deliver","type":"text","marks":[{"type":"code_inline"}]},{"text":" renders them to MathJax SVG in place; it needs Node.js 20 or 22+ and ","type":"text"},{"text":"bash scripts/ensure_mathjax.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" once.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Code blocks with ","type":"text"},{"text":"class=\"language-*\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" are highlighted when optional ","type":"text"},{"text":"Pygments","type":"text","marks":[{"type":"code_inline"}]},{"text":" is installed; without it they render monochrome.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Fill the four metadata placeholders in ","type":"text"},{"text":"\u003chead>","type":"text","marks":[{"type":"code_inline"}]},{"text":"; WeasyPrint writes them into the PDF:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CN","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EN","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rule","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{作者}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{AUTHOR}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resume, letter, portfolio: the person's name from the document. Others: leave it, the build infers it from ","type":"text"},{"text":"git config user.name","type":"text","marks":[{"type":"code_inline"}]},{"text":", then ","type":"text"},{"text":"KAMI_AUTHOR","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{摘要}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{DESCRIPTION}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"One sentence (≤150 chars) from the first two paragraphs","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{关键词}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{KEYWORDS}}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3-5 keywords from the title and section headings","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{文档标题}}","type":"text","marks":[{"type":"code_inline"}]},{"text":" etc.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{{DOC_TITLE}}","type":"text","marks":[{"type":"code_inline"}]},{"text":" etc.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"From the H1 or ","type":"text"},{"text":".header .title","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Keep ","type":"text"},{"text":"\u003cmeta name=\"generator\" content=\"Kami\">","type":"text","marks":[{"type":"code_inline"}]},{"text":" as is.","type":"text"}]},{"type":"paragraph","content":[{"text":"Output formats.","type":"text","marks":[{"type":"strong"}]},{"text":" Do not ask; decide from context. Any document: HTML + PDF (PDF is the deliverable, HTML the source). Slides: HTML + PDF, plus PPTX only when an editable deck is requested. \"分享 / 发朋友圈 / share / post / preview\": add PNG. \"嵌入 / 插图 / embed in another doc\": PNG only. Landing page: the static HTML file. Word or ","type":"text"},{"text":".docx","type":"text","marks":[{"type":"code_inline"}]},{"text":" for long-doc, proposal, or equity-report: the PDF plus a DOCX through ","type":"text"},{"text":"references/production.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Part 5. An explicit format request overrides all of this.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"5 · Deliver","type":"text"}]},{"type":"paragraph","content":[{"text":"One command finishes a document. Run it from the document's directory, fix every ","type":"text"},{"text":"ERROR","type":"text","marks":[{"type":"code_inline"}]},{"text":", and re-run until it reports ","type":"text"},{"text":"READY","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/build.py --deliver filled.html content.json","type":"text"}]},{"type":"paragraph","content":[{"text":"It checks the HTML (placeholders, strict math rendered in place, Markdown residue, generated charts against their data, template style, content coverage), renders the PDF next to it, then checks the PDF (page contract, resume balance, the font that actually drew CJK text, residue, density, orphans) and exports page images. ","type":"text"},{"text":"WARN","type":"text","marks":[{"type":"code_inline"}]},{"text":" lines point at where to look, not at failures. ","type":"text"},{"text":"GAP","type":"text","marks":[{"type":"code_inline"}]},{"text":" lines are the ","type":"text"},{"text":"[DATA NEEDED]","type":"text","marks":[{"type":"code_inline"}]},{"text":" markers you must report.","type":"text"}]},{"type":"paragraph","content":[{"text":"Then do the part no script can: open every page image and walk the printed checklist. One hit means a whole-document sweep for that class of issue. Fix the three most visible defects per round, re-run ","type":"text"},{"text":"--deliver","type":"text","marks":[{"type":"code_inline"}]},{"text":", and stop after three rounds; list what is still wrong rather than looping. If the host cannot view images, send the image paths and the checklist to the user instead of skipping the pass. A missing CJK serif shows no fallback boxes, only a heavier, flatter page, which is why the font gate exists; never call a CJK document visually verified without it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Other surfaces:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Landing pages and any browser surface","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"--deliver","type":"text","marks":[{"type":"code_inline"}]},{"text":" checks the HTML only. Screenshot the responsive matrix in every shipped locale before calling it done (","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «Responsive screenshot verification»); do not wait for the user to ask about mobile.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Editable PPTX","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"python3 scripts/build.py --verify slides","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"slides-en","type":"text","marks":[{"type":"code_inline"}]},{"text":") and ","type":"text"},{"text":"--check-rhythm","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing dependencies or fonts","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"python3 scripts/build.py --doctor","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports what is installed; ","type":"text"},{"text":"bash scripts/ensure-fonts.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" recovers CJK fonts (the commercial TsangerJinKai02 never ships inside the skill package). Render failures and their fixes: ","type":"text"},{"text":"references/production.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" Part 4.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Fresh review.","type":"text","marks":[{"type":"strong"}]},{"text":" After ","type":"text"},{"text":"READY","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the image pass, review once from the contract rather than from your own rationale: reread ","type":"text"},{"text":"brief","type":"text","marks":[{"type":"code_inline"}]},{"text":", the coverage result, and the page images; check every acceptance item and ","type":"text"},{"text":"preserve","type":"text","marks":[{"type":"code_inline"}]},{"text":" boundary; fix P0/P1 findings, each tied to the page or element that proves it. Use an isolated reviewer when the host has one.","type":"text"}]},{"type":"paragraph","content":[{"text":"Definition of done.","type":"text","marks":[{"type":"strong"}]},{"text":" The closing message gives:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The path of every deliverable, in every promised format.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"--deliver","type":"text","marks":[{"type":"code_inline"}]},{"text":" verdict and page count against the contract.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every ","type":"text"},{"text":"[DATA NEEDED]","type":"text","marks":[{"type":"code_inline"}]},{"text":" gap and every assumption you made, listed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The visual verdict stated honestly: when page images could not be inspected, say \"build verified, visuals unconfirmed\", not \"done\".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Feedback protocol","type":"text"}]},{"type":"paragraph","content":[{"text":"When the user gives visual feedback (\"looks off\", \"太挤了\", \"not elegant\"), inspect the current render before asking them to choose a value. The render is the evidence; the user's negative label is the acceptance signal.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Name the concrete defect in one sentence: page or surface, viewport or state, and whether it is density, hierarchy, alignment, type, color, cropping, or text fit.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lock the repair boundary: ","type":"text"},{"text":"target","type":"text","marks":[{"type":"code_inline"}]},{"text":" is allowed to change; ","type":"text"},{"text":"preserve","type":"text","marks":[{"type":"code_inline"}]},{"text":" names the adjacent pages, sections, content, and shared tokens that must stay stable. Ask only when two plausible targets would produce materially different artifacts.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Make the smallest content, geometry, spacing, typography, crop, or token change that fixes the defect. Never hide a content problem by shrinking type first.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify the affected matrix rather than one screenshot:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PDF: target page, neighboring pages, total page count, font result, and every locale or template variant reached by a shared token.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Screen: the breakpoint, tablet, and baseline matrix in ","type":"text"},{"text":"references/design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" «Responsive screenshot verification»; every shipped locale; affected default, focus/selected, loading, empty/error, and transition states only when the surface has them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PPTX: editable source plus a rendered PDF or opened-deck inspection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Generated asset: target slot at its smallest display size plus sibling assets in the same deliverable.","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Never say \"I'll adjust the spacing\" without naming the exact property and its new value.","type":"text"}]},{"type":"paragraph","content":[{"text":"Escalate after two rounds.","type":"text","marks":[{"type":"strong"}]},{"text":" If the same element is still not approved after two adjustment rounds, stop nudging values and produce one comparison artifact: the current state plus 2-3 labeled variants (A/B/C) of the same content in the product's actual frame and background, changing only the compared property. For choices with no objective criterion (typeface, accent color, logo), start there: a specimen sheet of up to 5 labeled half-page blocks with identical title-plus-paragraph content. After the pick, apply it everywhere and rebuild what it touches in the same round.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When not to use this skill","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The user explicitly wants Material, Fluent, or a Tailwind default: a different design language.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dark, cyberpunk, or futurist aesthetics (this is deliberately anti-future).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Saturated multi-color palettes (this has one accent).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cartoon, animation, or illustration style (this is editorial).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dynamic web app UI (this is for print and static documents).","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"kami","author":"@skillopedia","source":{"stars":12212,"repo_name":"Kami","origin_url":"https://github.com/tw93/Kami/blob/HEAD/skills/kami/SKILL.md","repo_owner":"tw93","body_sha256":"a0f5b0bf8991f985aea7b8d9e50e883add97510454d66fd196ab9aa4e116af0f","cluster_key":"12f9ad11938b6188991e25ae0a5ba7ff741ec24a456c56556bab9306add9195c","clean_bundle":{"format":"clean-skill-bundle-v1","source":"tw93/Kami/skills/kami/SKILL.md","text_bytes":1048297,"source_commit":"0605b1eb0955d8457afa61957de605443ab68802","skills_sh_rank":1869,"attachment_count":73,"text_attachments":73,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/kami/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"documents-office","category_label":"Documents"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"documents-office","import_tag":"skills-sh-top20k-2026-11","description":"Typeset professional documents with Kami templates: resumes, one-pagers, white papers, letters, portfolios, and slide decks. Use when asked to 做 PDF / 排版 / 简历 / 一页纸 / PPT / slides, or to create a Kami landing page. Not for auditing or restyling an existing product site."}},"renderedAt":1793661422241}

kami · 紙 紙 · かみ means paper in Japanese. Good content deserves good paper. Use Kami’s templates and layout rules to create documents and landing pages with serif fonts, warm backgrounds, and ink-blue accents. Part of - Kaku writes code, Waza drills habits, Kami delivers documents . Commands and paths below are relative to this skill's directory. Keep the document in the user's working directory and call the scripts from there by their full path ( ); relative file arguments resolve from your working directory first. Update check (non-blocking). At the start of a task, run . It keeps a local da…