Files
soleprint/soleprint/atlas2/docgen/style/extract.py
buenosairesam 160ee31b8c docgen: drop the superseded first pass, port the style harvester
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.
2026-09-13 21:41:29 -03:00

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)