-
Notifications
You must be signed in to change notification settings - Fork 0
docs(api): pure-vector SVG samples for all 12 seed templates #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| .PHONY: docs-svg-samples | ||
|
|
||
| ## docs-svg-samples — regenerate pure-vector SVG previews for all seed templates | ||
| ## Output: docs/site/operations/templates/svg-samples/{template-id}.svg | ||
| docs-svg-samples: | ||
| cd backend && uv run python scripts/generate_template_svgs.py |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,205 @@ | ||||||||||||||||||
| """Render a label template as a pure-vector SVG string. | ||||||||||||||||||
|
|
||||||||||||||||||
| The SVG mirrors the LabelRenderer's pixel coordinate system 1:1 (top-left | ||||||||||||||||||
| origin, 300 DPI Brother geometry) so that SVG previews match what gets printed. | ||||||||||||||||||
|
|
||||||||||||||||||
| QR codes are rendered as inline pure-vector ``<path>`` elements using | ||||||||||||||||||
| qrcode's SvgPathImage factory. All text elements become ``<text>`` nodes — | ||||||||||||||||||
| no raster embeds for text. A gray ``<rect>`` outlines the tape boundary. | ||||||||||||||||||
|
|
||||||||||||||||||
| Coordinate system: | ||||||||||||||||||
| - The tape's printable height is taken from TAPE_HEIGHT_PX, same as | ||||||||||||||||||
| LabelRenderer. | ||||||||||||||||||
| - The canvas width is fixed at DEFAULT_LABEL_WIDTH_PX (600 px), same as | ||||||||||||||||||
| LabelRenderer, so element x/y coordinates translate 1:1. | ||||||||||||||||||
| - An annotation strip of ANNOTATION_HEIGHT_PX is reserved above the tape | ||||||||||||||||||
| rect so the viewBox shows the title line outside the printable area. | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||
|
|
||||||||||||||||||
| import re | ||||||||||||||||||
| import xml.etree.ElementTree as ET | ||||||||||||||||||
|
|
||||||||||||||||||
| import qrcode | ||||||||||||||||||
| import qrcode.image.svg | ||||||||||||||||||
|
|
||||||||||||||||||
| from app.services.label_renderer import DEFAULT_LABEL_WIDTH_PX, TAPE_HEIGHT_PX | ||||||||||||||||||
|
|
||||||||||||||||||
| # Extra vertical space above the tape rect used for the template-key annotation. | ||||||||||||||||||
| _ANNOTATION_HEIGHT_PX: int = 18 | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def _qr_svg_group(qr_data: str, x: int, y: int, size_px: int) -> str: | ||||||||||||||||||
| """Return an SVG ``<g>`` element containing the QR code as a pure-vector path. | ||||||||||||||||||
|
|
||||||||||||||||||
| The path is extracted from qrcode's SvgPathImage output and scaled so that | ||||||||||||||||||
| the QR module fills exactly ``size_px x size_px`` pixels at the given (x, y) | ||||||||||||||||||
| position. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| qr_data: The payload to encode. | ||||||||||||||||||
| x: Left edge in the tape's pixel coordinate system. | ||||||||||||||||||
| y: Top edge in the tape's pixel coordinate system. | ||||||||||||||||||
| size_px: Target width/height in pixels. | ||||||||||||||||||
|
|
||||||||||||||||||
| Returns: | ||||||||||||||||||
| A ``<g transform="...">...</g>`` string ready for embedding in the SVG. | ||||||||||||||||||
| """ | ||||||||||||||||||
| # Use box_size=1 + border=0 so the path coordinates are in module units | ||||||||||||||||||
| # (integers), making the scale calculation straightforward. | ||||||||||||||||||
| factory = qrcode.image.svg.SvgPathImage | ||||||||||||||||||
| qr_img = qrcode.make( | ||||||||||||||||||
| qr_data, | ||||||||||||||||||
| image_factory=factory, | ||||||||||||||||||
| box_size=1, | ||||||||||||||||||
| border=0, | ||||||||||||||||||
| ) | ||||||||||||||||||
| raw_svg = qr_img.to_string(encoding="unicode") | ||||||||||||||||||
|
|
||||||||||||||||||
| # Parse the outer <svg> to grab the viewBox dimensions and the <path> element. | ||||||||||||||||||
| root = ET.fromstring(raw_svg) | ||||||||||||||||||
| ns = {"svg": "http://www.w3.org/2000/svg"} | ||||||||||||||||||
| path_el = root.find("svg:path", ns) | ||||||||||||||||||
| if path_el is None: | ||||||||||||||||||
| # Fallback: try without namespace (some qrcode versions omit it) | ||||||||||||||||||
| path_el = root.find("path") | ||||||||||||||||||
| if path_el is None: | ||||||||||||||||||
| raise RuntimeError(f"qrcode SvgPathImage produced no <path> element for data={qr_data!r}") | ||||||||||||||||||
|
|
||||||||||||||||||
| path_d = path_el.attrib.get("d", "") | ||||||||||||||||||
|
|
||||||||||||||||||
| # Derive the QR grid size from the viewBox. With box_size=1 the viewBox | ||||||||||||||||||
| # width equals the number of modules. | ||||||||||||||||||
| vb = root.attrib.get("viewBox", "") | ||||||||||||||||||
| vb_parts = vb.split() | ||||||||||||||||||
| if len(vb_parts) == 4: | ||||||||||||||||||
| qr_units = float(vb_parts[2]) # width in module units | ||||||||||||||||||
| else: | ||||||||||||||||||
| # Parse from width attribute ("29mm" etc.) as fallback. | ||||||||||||||||||
| w_str = root.attrib.get("width", "1") | ||||||||||||||||||
| qr_units = float(re.sub(r"[^0-9.]", "", w_str) or "1") | ||||||||||||||||||
|
|
||||||||||||||||||
| scale = size_px / qr_units if qr_units else 1.0 | ||||||||||||||||||
|
|
||||||||||||||||||
| # Shift the annotation offset: QR y is in tape-space so we add | ||||||||||||||||||
| # the annotation strip below in the outer SVG, not here. | ||||||||||||||||||
| return ( | ||||||||||||||||||
| f'<g transform="translate({x},{y}) scale({scale:.6f})">' | ||||||||||||||||||
| f'<path d="{path_d}" fill="black" fill-rule="evenodd"/>' | ||||||||||||||||||
| f"</g>" | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def _annotation_label(template_id: str, tape_mm: int, elements: list[dict[str, object]]) -> str: | ||||||||||||||||||
| """Return a short human-readable title string for the SVG annotation strip.""" | ||||||||||||||||||
| text_count = sum(1 for el in elements if el.get("type") == "text") | ||||||||||||||||||
| plural = "s" if text_count != 1 else "" | ||||||||||||||||||
| return f"{template_id} — tape {tape_mm}mm — {text_count} text line{plural}" | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def _resolve_sample_value(field: str, sample: dict[str, object]) -> str: | ||||||||||||||||||
| """Look up *field* in *sample*, joining list values with ' | '.""" | ||||||||||||||||||
| value = sample.get(field, "") | ||||||||||||||||||
| if isinstance(value, (list, tuple)): | ||||||||||||||||||
| return " | ".join(str(v) for v in value) | ||||||||||||||||||
| return str(value) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def render_template_svg( | ||||||||||||||||||
| template_definition: dict[str, object], sample_data: dict[str, object] | ||||||||||||||||||
| ) -> str: | ||||||||||||||||||
| """Render a template's preview as a pure-vector SVG string. | ||||||||||||||||||
|
|
||||||||||||||||||
| The SVG mirrors the LabelRenderer's pixel coordinate system 1:1 so it | ||||||||||||||||||
| matches the print output. QR codes are rendered as inline ``<path>`` | ||||||||||||||||||
| using python-qrcode's SvgPathImage factory; text elements become | ||||||||||||||||||
| ``<text>``; the tape outline is a ``<rect>`` with a 1px gray border. | ||||||||||||||||||
|
|
||||||||||||||||||
| Args: | ||||||||||||||||||
| template_definition: contents of Template.definition JSON column | ||||||||||||||||||
| (already deserialised). Must include ``tape_mm`` and ``elements``. | ||||||||||||||||||
| sample_data: per-template preview_sample dict (already validated). | ||||||||||||||||||
|
|
||||||||||||||||||
| Returns: | ||||||||||||||||||
| Full SVG XML as a string starting with ``<svg …>``. | ||||||||||||||||||
| """ | ||||||||||||||||||
| tape_mm = int(str(template_definition["tape_mm"])) | ||||||||||||||||||
| tape_h = TAPE_HEIGHT_PX.get(tape_mm) | ||||||||||||||||||
| if tape_h is None: | ||||||||||||||||||
| raise ValueError(f"Unsupported tape_mm: {tape_mm}. Supported: {sorted(TAPE_HEIGHT_PX)}") | ||||||||||||||||||
|
|
||||||||||||||||||
| w = DEFAULT_LABEL_WIDTH_PX | ||||||||||||||||||
| raw_elements = template_definition.get("elements", []) | ||||||||||||||||||
| element_list: list[object] = list(raw_elements) if isinstance(raw_elements, list) else [] | ||||||||||||||||||
| elements: list[dict[str, object]] = [dict(el) for el in element_list if isinstance(el, dict)] | ||||||||||||||||||
| template_id = str(template_definition.get("id", "unknown")) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Total SVG height = annotation strip + tape body | ||||||||||||||||||
| total_h = _ANNOTATION_HEIGHT_PX + tape_h | ||||||||||||||||||
|
|
||||||||||||||||||
| # viewBox: origin is at the top-left of the annotation strip; the tape | ||||||||||||||||||
| # rect starts at y=_ANNOTATION_HEIGHT_PX. | ||||||||||||||||||
| vb = f"0 0 {w} {total_h}" | ||||||||||||||||||
|
|
||||||||||||||||||
| lines: list[str] = [] | ||||||||||||||||||
| lines.append( | ||||||||||||||||||
| f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{total_h}" viewBox="{vb}">' | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # — Annotation strip (outside the printable tape area) —————————————— | ||||||||||||||||||
| annotation = _annotation_label(template_id, tape_mm, elements) | ||||||||||||||||||
| lines.append( | ||||||||||||||||||
| f' <text x="4" y="{_ANNOTATION_HEIGHT_PX - 4}"' | ||||||||||||||||||
| f' font-family="Liberation Sans, DejaVu Sans, sans-serif"' | ||||||||||||||||||
| f' font-size="10" fill="#666">' | ||||||||||||||||||
| f"{annotation}" | ||||||||||||||||||
| f"</text>" | ||||||||||||||||||
|
Comment on lines
+153
to
+157
|
||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # — Tape background + outline —————————————————————————————————————— | ||||||||||||||||||
| ty = _ANNOTATION_HEIGHT_PX # tape top y in SVG coordinates | ||||||||||||||||||
| lines.append( | ||||||||||||||||||
| f' <rect x="0.5" y="{ty + 0.5}" width="{w - 1}" height="{tape_h - 1}"' | ||||||||||||||||||
| f' fill="white" stroke="#aaa" stroke-width="1"/>' | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # — Label elements ————————————————————————————————————————————————— | ||||||||||||||||||
| for el in elements: | ||||||||||||||||||
| el_type = str(el.get("type", "")) | ||||||||||||||||||
| ex = int(str(el.get("x", 0))) | ||||||||||||||||||
| ey = int(str(el.get("y", 0))) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Shift element y coordinates by the annotation strip height so that | ||||||||||||||||||
| # the element positions in the SVG match the pixel coordinates used | ||||||||||||||||||
| # by the LabelRenderer on the tape. | ||||||||||||||||||
| svg_y = ty + ey | ||||||||||||||||||
|
|
||||||||||||||||||
| if el_type == "qr": | ||||||||||||||||||
| data_field = str(el.get("data_field", "qr_payload")) | ||||||||||||||||||
| size_px = int(str(el.get("size", 80))) | ||||||||||||||||||
| qr_data = _resolve_sample_value(data_field, sample_data) | ||||||||||||||||||
| # QR group: translate to tape-offset-adjusted position. | ||||||||||||||||||
| qr_group = _qr_svg_group(qr_data, ex, svg_y, size_px) | ||||||||||||||||||
| lines.append(f" {qr_group}") | ||||||||||||||||||
|
|
||||||||||||||||||
| elif el_type == "text": | ||||||||||||||||||
| field = str(el.get("field", "")) | ||||||||||||||||||
| font_size = int(str(el.get("font_size", 14))) | ||||||||||||||||||
| text_value = _resolve_sample_value(field, sample_data) | ||||||||||||||||||
| # Escape XML special characters in user data. | ||||||||||||||||||
| text_value = ( | ||||||||||||||||||
| text_value.replace("&", "&") | ||||||||||||||||||
| .replace("<", "<") | ||||||||||||||||||
| .replace(">", ">") | ||||||||||||||||||
| .replace('"', """) | ||||||||||||||||||
| ) | ||||||||||||||||||
|
Comment on lines
+191
to
+196
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||
| lines.append( | ||||||||||||||||||
| f' <text x="{ex}" y="{svg_y + font_size}"' | ||||||||||||||||||
| f' font-family="Liberation Sans, DejaVu Sans, sans-serif"' | ||||||||||||||||||
| f' font-size="{font_size}"' | ||||||||||||||||||
| f' fill="black">{text_value}</text>' | ||||||||||||||||||
|
Comment on lines
+198
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding
Suggested change
|
||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| lines.append("</svg>") | ||||||||||||||||||
| return "\n".join(lines) | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Generate SVG samples for every seed template under docs/. | ||
|
|
||
| Run with: | ||
| cd backend && uv run python scripts/generate_template_svgs.py | ||
|
|
||
| Writes one ``{template-id}.svg`` per seed template that contains a | ||
| ``preview_sample`` block into: | ||
| docs/site/operations/templates/svg-samples/ | ||
|
|
||
| These SVGs are the visual basis for the Phase 7e layout-system brainstorming | ||
| (GitHub issue #81). They are pure-vector: text is rendered as ``<text>`` | ||
| elements, QR codes as ``<path>`` elements — no raster embeds. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import yaml | ||
|
|
||
| # Ensure the project root is on sys.path so ``app.*`` imports work when the | ||
| # script is executed directly via ``uv run python scripts/…`` from the | ||
| # ``backend/`` directory. | ||
| _BACKEND_DIR = Path(__file__).resolve().parents[1] | ||
| if str(_BACKEND_DIR) not in sys.path: | ||
| sys.path.insert(0, str(_BACKEND_DIR)) | ||
|
|
||
| from app.services.svg_renderer import render_template_svg # noqa: E402 | ||
|
|
||
| SEED_DIR = _BACKEND_DIR / "app" / "seed" / "templates" | ||
| OUT_DIR = _BACKEND_DIR.parent / "docs" / "site" / "operations" / "templates" / "svg-samples" | ||
|
|
||
|
|
||
| def main() -> int: | ||
| """Generate one SVG per seed template that has a preview_sample. | ||
|
|
||
| Returns: | ||
| 0 on success, 1 if any template lacks a preview_sample (counted as a | ||
| warning, not a failure). | ||
|
Comment on lines
+39
to
+40
|
||
| """ | ||
| OUT_DIR.mkdir(parents=True, exist_ok=True) | ||
| written: list[Path] = [] | ||
| skipped: list[str] = [] | ||
|
|
||
| for yaml_file in sorted(SEED_DIR.glob("*.yaml")): | ||
| definition: dict[str, object] = yaml.safe_load(yaml_file.read_text()) | ||
| sample = definition.get("preview_sample") | ||
| if not sample: | ||
| print(f"SKIP {yaml_file.name}: no preview_sample") # noqa: T201 | ||
| skipped.append(yaml_file.name) | ||
| continue | ||
|
|
||
| template_id = str(definition.get("id", yaml_file.stem)) | ||
| svg = render_template_svg(definition, dict(sample)) # type: ignore[arg-type] | ||
| out_path = OUT_DIR / f"{template_id}.svg" | ||
| out_path.write_text(svg, encoding="utf-8") | ||
| written.append(out_path) | ||
| print(f"WROTE {out_path}") # noqa: T201 | ||
|
|
||
| print() # noqa: T201 | ||
| print(f"Done: {len(written)} SVG(s) written, {len(skipped)} skipped.") # noqa: T201 | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
annotationstring is inserted directly into the SVG without escaping. If thetemplate_idcontains XML special characters (like & or <), the resulting SVG will be invalid. Consider usingxml.sax.saxutils.escapeorhtml.escapefor all text content.