The IR supersedes both intermediate designs (requirements D6, D7):
station/tools/docgen and the graph model that briefly lived in graphgen.
Shipping them beside atlas2/docgen would mean two graph models, which is the
thing the architecture argues against.
Carried across rather than lost:
- style/extract.py and tokens.py, the offline theme harvester (R31). Rewritten
to emit a *theme* — a slot-to-hex binding — rather than a whole style file,
since what harvesting recovers is which colour a slot should be, not what a
kind should look like.
- graphgen/README.md, rewritten for what graphgen actually is now: the
schema explorer. It fixes the blank station-index entry at run.py:304.
Two bugs found while doing it:
- Canvas and ink are the two lightness extremes, not the two most common
values. In a Graphviz SVG every label carries a fill, so the ink outnumbers
the canvas 87 to 43 and the old rule produced a theme whose text was
invisible against its own background.
- A name defined in both branches of an if/else produced a duplicate id, which
failed validation on docgen's own source. Disambiguated by line.
222 lines
8.7 KiB
Python
222 lines
8.7 KiB
Python
"""
|
|
A folder of exported diagrams -> the style vocabulary they use.
|
|
|
|
The premise, from `spr/def/prompts/lucid` §2a: style values are *visual*
|
|
metadata — hex codes, stroke widths, corner radii, font stacks. None of that
|
|
requires reading what the diagram says, and none of it requires sending the file
|
|
anywhere. So for confidential diagrams this is the path: run it locally, skip
|
|
both the Lucid API and any assistant.
|
|
|
|
Two rules, and they are not stylistic preferences:
|
|
|
|
1. **Text content is never read.** `<text>` elements are visited for their style
|
|
attributes and nothing else; `.text` and `.tail` are never touched anywhere in
|
|
this module. The semantics of a diagram are not needed to derive a palette,
|
|
so they are not looked at. `selftest.py` asserts a label from a known fixture
|
|
appears nowhere in the output.
|
|
|
|
2. **Fully offline.** No network import in this file, and nothing here opens a
|
|
socket. Confidential source diagrams stay off any network path, and off the
|
|
Lucid API path.
|
|
|
|
## Why lxml and not grep
|
|
|
|
`prompts/lucid` §2a gives a grep recipe and then says to do this instead, which
|
|
is the right call: `fill` appears as a presentation attribute (`fill="#fff"`),
|
|
inside an inline style (`style="fill:#fff"`), and inside a `<style>` block that
|
|
applies to elements that carry none of it. Grep sees three unrelated strings and
|
|
counts a CSS rule once no matter how many shapes it paints. Parsing sees one
|
|
vocabulary and counts what is actually drawn.
|
|
|
|
`lxml` is imported inside the function, so the rest of docgen works without it.
|
|
|
|
## Frequency is the whole point
|
|
|
|
The output is sorted by count, because that is what turns a heap of values into a
|
|
palette: the top two or three fills *are* the palette, and the modal stroke width
|
|
*is* the house line weight. A list of every colour in the file, unsorted, is not
|
|
usable — real exports carry dozens of one-off values from shadows, gradients and
|
|
whatever someone recoloured once.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
# The vocabulary worth harvesting, from prompt 35.5.
|
|
PROPERTIES = ("fill", "stroke", "stroke-width", "font-family", "font-size", "rx")
|
|
|
|
# Lucid's idiom for "no fill". Mapping it to black is the obvious wrong answer
|
|
# and would poison the palette with a colour the diagram does not contain.
|
|
TRANSPARENT = "#00000000"
|
|
|
|
# Values that are the absence of a value. Counted separately rather than dropped,
|
|
# because "most shapes have no stroke" is itself a fact about the house style.
|
|
NULLISH = {"none", "transparent", "currentColor", "inherit"}
|
|
|
|
|
|
def _values_from(el) -> dict:
|
|
"""One element's style vocabulary. Attributes and inline style, never text.
|
|
|
|
The inline `style="..."` wins over the presentation attribute, which is what
|
|
the SVG spec says and what browsers do.
|
|
"""
|
|
found = {p: el.get(p) for p in PROPERTIES if el.get(p)}
|
|
inline = el.get("style")
|
|
if inline:
|
|
for decl in inline.split(";"):
|
|
if ":" not in decl:
|
|
continue
|
|
name, _, value = decl.partition(":")
|
|
name, value = name.strip(), value.strip()
|
|
if name in PROPERTIES and value:
|
|
found[name] = value
|
|
return found
|
|
|
|
|
|
def _normalise(prop: str, value: str) -> str | None:
|
|
"""One spelling per value, so `#FFF` and `#ffffff` are not two palette entries."""
|
|
value = value.strip()
|
|
if not value or value in NULLISH:
|
|
return value if value in NULLISH else None
|
|
|
|
if prop in ("fill", "stroke"):
|
|
if value.startswith("url("):
|
|
return None # a gradient or pattern reference, not a colour
|
|
if value == TRANSPARENT:
|
|
return "transparent"
|
|
if value.startswith("#"):
|
|
hexv = value[1:].lower()
|
|
if len(hexv) in (3, 4): # #abc -> #aabbcc
|
|
hexv = "".join(c * 2 for c in hexv)
|
|
if len(hexv) == 8 and hexv[6:] == "ff":
|
|
hexv = hexv[:6] # fully opaque; the alpha says nothing
|
|
return "#" + hexv
|
|
rgb = re.match(r"rgba?\(([^)]+)\)", value)
|
|
if rgb:
|
|
parts = [p.strip() for p in rgb.group(1).replace("/", ",").split(",")]
|
|
try:
|
|
r, g, b = (int(float(p)) for p in parts[:3])
|
|
except ValueError:
|
|
return value.lower()
|
|
return f"#{r:02x}{g:02x}{b:02x}"
|
|
return value.lower()
|
|
|
|
if prop in ("stroke-width", "font-size", "rx"):
|
|
# `8pt`, `8px`, `8` — the number is the value; the unit is noise for
|
|
# font-size (DOT's fontsize is already points) and for stroke width.
|
|
num = re.match(r"(-?[\d.]+)", value)
|
|
if not num:
|
|
return None
|
|
# `11.00` and `11` are the same size and must not be two entries in the
|
|
# frequency count. DOT takes either; the tidy one is what lands in a
|
|
# profile a person will read.
|
|
text = num.group(1)
|
|
return text.rstrip("0").rstrip(".") if "." in text else text
|
|
|
|
if prop == "font-family":
|
|
# A font stack's first entry is the one that renders where it exists.
|
|
return value.split(",")[0].strip().strip("'\"")
|
|
|
|
return value
|
|
|
|
|
|
def _svg_files(folder: Path, workdir: Path) -> list[Path]:
|
|
"""Every SVG to read, converting PDFs on the way.
|
|
|
|
`pdftocairo -svg` is the conversion `prompts/lucid` §2a names. Converted
|
|
files land in a temp directory — the target folder is read-only here, the
|
|
same way `histgen`'s source is.
|
|
"""
|
|
files = sorted(folder.rglob("*.svg"))
|
|
pdfs = sorted(folder.rglob("*.pdf"))
|
|
if pdfs:
|
|
if shutil.which("pdftocairo") is None:
|
|
print(f" {len(pdfs)} PDF(s) skipped — pdftocairo not found "
|
|
"(install with: sudo apt install poppler-utils)")
|
|
else:
|
|
for i, pdf in enumerate(pdfs):
|
|
out = workdir / f"pdf-{i:03d}-{pdf.stem}.svg"
|
|
proc = subprocess.run(
|
|
["pdftocairo", "-svg", str(pdf), str(out)], capture_output=True
|
|
)
|
|
if proc.returncode == 0 and out.exists():
|
|
files.append(out)
|
|
else:
|
|
print(f" could not convert {pdf.name}: "
|
|
f"{proc.stderr.decode('utf-8', 'replace').strip()}")
|
|
return files
|
|
|
|
|
|
def harvest(folder: Path | str) -> dict:
|
|
"""Read a target folder, return the frequency-sorted token vocabulary."""
|
|
try:
|
|
from lxml import etree
|
|
except ImportError: # pragma: no cover - depends on the host
|
|
raise RuntimeError(
|
|
"extraction needs lxml — pip install lxml\n"
|
|
"(the rest of docgen does not; this is the only place it is used)"
|
|
) from None
|
|
|
|
folder = Path(folder)
|
|
if not folder.is_dir():
|
|
raise NotADirectoryError(f"not a folder: {folder}")
|
|
|
|
counters = {p: Counter() for p in PROPERTIES}
|
|
read, failed = 0, []
|
|
|
|
with tempfile.TemporaryDirectory(prefix="docgen-extract-") as tmp:
|
|
files = _svg_files(folder, Path(tmp))
|
|
for path in files:
|
|
try:
|
|
tree = etree.parse(str(path))
|
|
except Exception as e:
|
|
failed.append((path.name, str(e).splitlines()[0]))
|
|
continue
|
|
read += 1
|
|
for el in tree.iter():
|
|
# Style attributes only. `el.text` is never referenced — that is
|
|
# the "never read text content" rule, and it is one line.
|
|
for prop, raw in _values_from(el).items():
|
|
value = _normalise(prop, raw)
|
|
if value:
|
|
counters[prop][value] += 1
|
|
|
|
return {
|
|
"source": str(folder),
|
|
"files_read": read,
|
|
"files_failed": [{"file": n, "error": e} for n, e in failed],
|
|
"tokens": {
|
|
prop: [{"value": v, "count": c} for v, c in counters[prop].most_common()]
|
|
for prop in PROPERTIES
|
|
},
|
|
}
|
|
|
|
|
|
def write(folder: Path | str, out: Path | str) -> Path:
|
|
"""Harvest and write `tokens.json`. Returns the path."""
|
|
data = harvest(folder)
|
|
out = Path(out)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps(data, indent=2) + "\n")
|
|
return out
|
|
|
|
|
|
def summarise(data: dict, top: int = 5) -> str:
|
|
"""What was found, frequency-sorted — the part a person reads."""
|
|
lines = [f"{data['files_read']} file(s) read from {data['source']}"]
|
|
for fail in data["files_failed"]:
|
|
lines.append(f" could not parse {fail['file']}: {fail['error']}")
|
|
for prop in PROPERTIES:
|
|
entries = data["tokens"][prop][:top]
|
|
if not entries:
|
|
continue
|
|
lines.append(f"\n {prop}")
|
|
for e in entries:
|
|
lines.append(f" {e['count']:6} {e['value']}")
|
|
return "\n".join(lines)
|