mirror of
https://github.com/TinyTapeout/tt-support-tools.git
synced 2026-11-03 04:36:09 +01:00
Copy: drop the 01-06 feature numbering, which implied a sequence these six independent benefits do not have. Reword the academic-calendar answer so the subject is explicit — it previously hung on an "it" with no antecedent — and replace the unexpanded DFT with the broader documentation point. Restore "where the process allows" to the analog claim; without it page 1 offered six analog pins unconditionally while page 2 marked two of the three PDKs as Partial. Remove the €10k discount from the pricing footnote, where it sat immediately after a €3,325 worked example that does not qualify for it; it still appears in the page 2 ordering table. build.py: the YAML parse error printed a hint about escaping HTML quotes regardless of the actual failure, which sent you looking in the wrong place for a stray tab. The hint is now chosen from the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
387 lines
13 KiB
Python
387 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the two-page Tiny Tapeout product datasheet.
|
|
|
|
All copy, numbers and colours live in datasheet.yaml. This script renders them
|
|
through datasheet.html.mustache, inlining the font and photo as data URIs so the
|
|
result is a single self-contained file that prints to two A4 pages.
|
|
|
|
python3 docs/product/build.py # write tt-datasheet.html
|
|
python3 docs/product/build.py --pdf # also render tt-datasheet.pdf
|
|
|
|
The sheets are a fixed 297mm tall with overflow hidden, so copy that grows too
|
|
long is silently clipped rather than spilling onto a third page. Every build
|
|
therefore measures the rendered sheets in headless Chrome and fails if anything
|
|
overflows. Pass --no-check to skip that (or set CHROME if it isn't found).
|
|
|
|
Requires chevron and pyyaml, both already pinned in requirements.txt.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import chevron
|
|
import yaml
|
|
|
|
HERE = Path(__file__).parent
|
|
|
|
# `style` tokens in datasheet.yaml -> CSS classes in the template.
|
|
STYLE_CLASSES = {
|
|
"name": "t-name",
|
|
"mono": "t-mono",
|
|
"num": "t-num",
|
|
"price": "t-price",
|
|
"dim": "t-dim",
|
|
"center": "t-center",
|
|
}
|
|
|
|
# Tables are rendered through partials/table.mustache and need preparing first.
|
|
TABLE_KEYS = ["pricing", "process", "ratings", "gpio", "analog", "ordering"]
|
|
|
|
EM_DASH = "—"
|
|
|
|
CHROME_CANDIDATES = [
|
|
os.environ.get("CHROME"),
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"google-chrome",
|
|
"chromium",
|
|
]
|
|
|
|
|
|
def cell_classes(style: str, value: str) -> str:
|
|
"""Map a column's style tokens to CSS classes for one cell."""
|
|
classes = []
|
|
for token in style.split():
|
|
try:
|
|
classes.append(STYLE_CLASSES[token])
|
|
except KeyError:
|
|
raise SystemExit(
|
|
f"unknown style '{token}' — expected one of {', '.join(STYLE_CLASSES)}"
|
|
)
|
|
# A lone em dash means "not applicable"; grey it out wherever it appears.
|
|
if value.strip() == EM_DASH and "t-dim" not in classes:
|
|
classes.append("t-dim")
|
|
return " ".join(classes)
|
|
|
|
|
|
def prepare_table(name: str, table: dict) -> None:
|
|
"""Expand a table's columns and rows into what the template expects."""
|
|
columns = table.get("columns") or []
|
|
for column in columns:
|
|
width = column.get("width")
|
|
column["width_attr"] = f' style="width:{width}"' if width else ""
|
|
column.setdefault("label", "")
|
|
|
|
styles = [column.get("style", "") for column in columns]
|
|
|
|
rows = []
|
|
for index, row in enumerate(table.get("rows") or [], start=1):
|
|
if len(row) != len(columns):
|
|
raise SystemExit(
|
|
f"{name}: row {index} has {len(row)} cells "
|
|
f"but there are {len(columns)} columns"
|
|
)
|
|
rows.append(
|
|
{
|
|
"cells": [
|
|
{"v": value, "cls": cell_classes(style, value)}
|
|
for style, value in zip(styles, row)
|
|
]
|
|
}
|
|
)
|
|
|
|
table["rows"] = rows
|
|
table["has_note"] = bool(table.get("note"))
|
|
table["has_footnote"] = bool(table.get("footnote"))
|
|
|
|
|
|
def load_config(path: Path) -> dict:
|
|
"""Parse the content YAML, reporting syntax errors against the offending line."""
|
|
text = path.read_text(encoding="utf-8")
|
|
try:
|
|
return yaml.safe_load(text)
|
|
except yaml.YAMLError as error:
|
|
mark = getattr(error, "problem_mark", None)
|
|
detail = getattr(error, "problem", str(error))
|
|
if mark is None:
|
|
raise SystemExit(f"{path}: {detail}")
|
|
lines = text.splitlines()
|
|
line = lines[mark.line] if mark.line < len(lines) else ""
|
|
|
|
# Only offer a hint that matches the actual failure; a generic one is noise.
|
|
if "\\t" in detail or line.startswith("\t"):
|
|
hint = "YAML forbids tabs for indentation — use spaces"
|
|
elif "expected ',' or ']'" in detail or "'\"'" in detail:
|
|
hint = 'inside a "quoted" value, HTML quotes must be escaped as \\"'
|
|
else:
|
|
hint = None
|
|
|
|
report = f"{path}:{mark.line + 1}: {detail}\n {line.strip()}"
|
|
raise SystemExit(f"{report}\n hint: {hint}" if hint else report)
|
|
|
|
|
|
def walk_strings(node, path: str = ""):
|
|
"""Yield (dotted-path, text) for every string in the loaded config."""
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
yield from walk_strings(value, f"{path}.{key}" if path else str(key))
|
|
elif isinstance(node, list):
|
|
for index, value in enumerate(node):
|
|
yield from walk_strings(value, f"{path}[{index}]")
|
|
elif isinstance(node, str):
|
|
yield path, node
|
|
|
|
|
|
def validate_markup(content: dict) -> None:
|
|
"""Catch the two easy ways to write broken HTML in the config.
|
|
|
|
YAML only processes \\" as an escape inside "quoted" values. In a >- folded
|
|
or plain value the backslash survives into the page and breaks the markup,
|
|
so a loaded string should never still contain one.
|
|
"""
|
|
problems = []
|
|
for path, text in walk_strings(content):
|
|
if '\\"' in text:
|
|
problems.append(
|
|
f'{path}: contains a literal \\" — in a >- or plain value write '
|
|
"the quote on its own, with no backslash"
|
|
)
|
|
for tag in ("b", "a", "span"):
|
|
opened = len(re.findall(rf"<{tag}\b", text))
|
|
closed = len(re.findall(rf"</{tag}>", text))
|
|
if opened != closed:
|
|
problems.append(
|
|
f"{path}: {opened} <{tag}> but {closed} </{tag}> — unbalanced tag"
|
|
)
|
|
# An unterminated href swallows the rest of the tag, which still leaves
|
|
# <a> and </a> balanced, so check the attribute closes its own quotes.
|
|
if len(re.findall(r"href=", text)) != len(re.findall(r'href="[^"]*"', text)):
|
|
problems.append(
|
|
f'{path}: malformed link — href="..." is missing its closing quote'
|
|
)
|
|
if problems:
|
|
raise SystemExit("\n".join(f"{p}" for p in problems))
|
|
|
|
|
|
MIME_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
|
|
|
|
|
|
def as_data_uri_payload(path: Path) -> str:
|
|
if not path.is_file():
|
|
raise SystemExit(f"missing asset: {path}")
|
|
return base64.b64encode(path.read_bytes()).decode()
|
|
|
|
|
|
def check_glyph_coverage(content: dict, font: Path) -> None:
|
|
"""Warn about characters the embedded font cannot draw.
|
|
|
|
The font is subset, so a stray character silently falls back to whatever the
|
|
renderer has to hand — a different typeface mid-word, or a blank box in the
|
|
PDF. Needs fontTools; skipped quietly when it isn't installed, since it is
|
|
not a runtime dependency of the build.
|
|
"""
|
|
try:
|
|
from fontTools.ttLib import TTFont
|
|
except ImportError:
|
|
return
|
|
|
|
covered = set(TTFont(font).getBestCmap())
|
|
missing: dict[str, str] = {}
|
|
for path, text in walk_strings(content):
|
|
for char in text:
|
|
if ord(char) > 0x20 and ord(char) not in covered:
|
|
missing.setdefault(char, path)
|
|
|
|
for char, path in sorted(missing.items()):
|
|
logging.warning(
|
|
f"{path}: U+{ord(char):04X} {char!r} is not in the embedded font "
|
|
"and will fall back to another typeface"
|
|
)
|
|
|
|
|
|
def prepare_supporters(supporters: dict, assets: Path) -> None:
|
|
"""Inline each supporter logo and work out its per-item sizing."""
|
|
height = supporters.get("logo_height", "8.5mm")
|
|
for item in supporters.get("items") or []:
|
|
path = assets / "supporters" / item["file"]
|
|
suffix = path.suffix.lower()
|
|
if suffix not in MIME_TYPES:
|
|
raise SystemExit(f"{path}: expected one of {', '.join(MIME_TYPES)}")
|
|
item["data_uri"] = (
|
|
f"data:{MIME_TYPES[suffix]};base64,{as_data_uri_payload(path)}"
|
|
)
|
|
scale = item.get("scale", 1)
|
|
item["style"] = f"height: calc({height} * {scale})"
|
|
|
|
|
|
def find_chrome() -> str:
|
|
for candidate in CHROME_CANDIDATES:
|
|
if candidate and (Path(candidate).is_file() or shutil.which(candidate)):
|
|
return candidate
|
|
raise SystemExit(
|
|
"could not find Chrome — set the CHROME environment variable to its path"
|
|
)
|
|
|
|
|
|
MEASURE_SCRIPT = """
|
|
<div id="fit-report">pending</div>
|
|
<script>
|
|
window.addEventListener("load", () => setTimeout(() => {
|
|
const mm = 25.4 / 96;
|
|
const report = [...document.querySelectorAll(".sheet")].map((sheet, i) => {
|
|
const body = sheet.querySelector(".body");
|
|
return [i + 1, ((body.scrollHeight - body.clientHeight) * mm).toFixed(1)].join(":");
|
|
});
|
|
document.getElementById("fit-report").textContent = report.join(",");
|
|
}, 500));
|
|
</script>
|
|
"""
|
|
|
|
|
|
def run_chrome(*flags: str, page: Path) -> bytes:
|
|
# Deliberately no --user-data-dir: combined with --print-to-pdf, Chrome
|
|
# writes the PDF correctly and then never exits.
|
|
result = subprocess.run(
|
|
[
|
|
find_chrome(),
|
|
"--headless",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
*flags,
|
|
page.resolve().as_uri(),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=120,
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def as_document(html_path: Path, extra: str = "") -> Path:
|
|
"""Chrome needs a complete document; the published artifact is wrapped for us."""
|
|
wrapped = html_path.with_name("." + html_path.stem + ".chrome.html")
|
|
wrapped.write_text(
|
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
"<style>*{margin:0;padding:0}</style></head><body>"
|
|
+ html_path.read_text(encoding="utf-8")
|
|
+ extra
|
|
+ "</body></html>",
|
|
encoding="utf-8",
|
|
)
|
|
return wrapped
|
|
|
|
|
|
def check_fit(html_path: Path) -> bool:
|
|
"""Measure each sheet in Chrome; report how far any of them overflows."""
|
|
page = as_document(html_path, MEASURE_SCRIPT)
|
|
try:
|
|
dom = run_chrome("--virtual-time-budget=5000", "--dump-dom", page=page)
|
|
finally:
|
|
page.unlink(missing_ok=True)
|
|
|
|
found = re.search(rb'<div id="fit-report">([^<]*)</div>', dom)
|
|
if not found or found.group(1) == b"pending":
|
|
# Fail closed: reporting success without having measured anything is how
|
|
# clipped content ships unnoticed.
|
|
logging.error(
|
|
"could not measure sheet fit — Chrome returned no report. "
|
|
"Re-run, or pass --no-check to build without verifying the fit"
|
|
)
|
|
return False
|
|
|
|
overflowed = False
|
|
for entry in found.group(1).decode().split(","):
|
|
sheet, overflow = entry.split(":")
|
|
if float(overflow) > 0:
|
|
overflowed = True
|
|
logging.error(
|
|
f"page {sheet} overflows by {overflow}mm — content past the bottom "
|
|
"of the sheet is CLIPPED, not moved to another page"
|
|
)
|
|
else:
|
|
logging.info(f"page {sheet} fits")
|
|
if overflowed:
|
|
logging.error("trim copy in datasheet.yaml, or shorten a table")
|
|
return not overflowed
|
|
|
|
|
|
def render_pdf(html_path: Path, pdf_path: Path) -> None:
|
|
page = as_document(html_path)
|
|
try:
|
|
run_chrome("--no-pdf-header-footer", f"--print-to-pdf={pdf_path}", page=page)
|
|
finally:
|
|
page.unlink(missing_ok=True)
|
|
pages = len(re.findall(rb"/Type\s*/Page[^s]", pdf_path.read_bytes()))
|
|
logging.info(f"wrote {pdf_path} ({pages} pages)")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--config", default=HERE / "datasheet.yaml", type=Path, help="content YAML"
|
|
)
|
|
parser.add_argument(
|
|
"--out", default=HERE / "tt-datasheet.html", type=Path, help="output HTML"
|
|
)
|
|
parser.add_argument(
|
|
"--pdf", action="store_true", help="also render a PDF next to the HTML"
|
|
)
|
|
parser.add_argument(
|
|
"--no-check",
|
|
dest="check",
|
|
action="store_false",
|
|
help="skip the headless-Chrome overflow check",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(levelname)-8s %(message)s", stream=sys.stdout
|
|
)
|
|
|
|
content = load_config(args.config)
|
|
validate_markup(content)
|
|
|
|
for key in TABLE_KEYS:
|
|
if key not in content:
|
|
raise SystemExit(f"{args.config}: missing '{key}' section")
|
|
prepare_table(key, content[key])
|
|
|
|
assets = HERE / "assets"
|
|
check_glyph_coverage(content, assets / content["assets"]["font"])
|
|
content["font_b64"] = as_data_uri_payload(assets / content["assets"]["font"])
|
|
content["photo_b64"] = as_data_uri_payload(assets / content["assets"]["photo"])
|
|
|
|
texture = content["theme"].get("bar_texture")
|
|
if texture:
|
|
content["bar_texture_b64"] = as_data_uri_payload(assets / texture)
|
|
content["theme"].setdefault("bar_texture_opacity", 0.55)
|
|
if "supporters" in content:
|
|
prepare_supporters(content["supporters"], assets)
|
|
|
|
with open(HERE / "datasheet.html.mustache", encoding="utf-8") as fh:
|
|
rendered = chevron.render(
|
|
fh.read(),
|
|
content,
|
|
partials_path=str(HERE / "partials"),
|
|
partials_ext="mustache",
|
|
)
|
|
|
|
args.out.write_text(rendered, encoding="utf-8")
|
|
logging.info(f"wrote {args.out} ({len(rendered) / 1024:.0f} KB)")
|
|
|
|
if args.pdf:
|
|
render_pdf(args.out, args.out.with_suffix(".pdf"))
|
|
|
|
if args.check and not check_fit(args.out):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|