1#!/usr/bin/env python32"""Render the checked hardware illustrations from the archived source PDFs.34Run from any directory with Python, PyMuPDF and Pillow installed:5 python scripts/render_hardware_assets.py67PDF page numbers in the manifest are one-based. Crop rectangles are in the8unrotated PDF page's point coordinates, with the origin at the top left.9The PDFs are read only. No drawing is redrawn, mirrored or recolored.10"""1112from __future__ import annotations1314import argparse15import hashlib16import json17from pathlib import Path1819import fitz20from PIL import Image212223ROOT = Path(__file__).resolve().parents[1]24DEFAULT_SOURCE = ROOT / "hardware"25DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "hardware"2627# Bounds were checked against the full HallPos schematic. Each region includes28# complete symbols, local wires, power/ground ends, and the connecting net names.29SCHEMATIC_REGIONS = [30 ("linear-power.png", [45, 265, 402, 440], "U5 input, U7 regulator, C7/C8/C9, D1 and NC1"),31 ("linear-hall.png", [354, 48, 558, 216], "U1/U2/U4 Hall sensors, C3/C4/C5, ADC1/ADC2/ADC3"),32 ("linear-mcu.png", [45, 15, 314, 260], "U3 MCU, C1/C2, BOOT0/NRST, all used net labels"),33 ("linear-rs485.png", [454, 249, 638, 392], "U6 transceiver, R4/R5, C6, TP2/TP3/TP4, all net labels"),34 ("linear-swd.png", [675, 270, 828, 386], "CN1 SWD connector including power and grounded fixing pads"),35 ("linear-bus-connector.png", [45, 265, 205, 440], "U5 power/RS485 connector, NC1 termination and D1 input protection"),36 ("linear-led.png", [636, 63, 716, 184], "LED1/R1, VCC_5V and LED_STA"),37]383940def graphic_bounds(page: fitz.Page) -> fitz.Rect:41 """Find the board drawing, excluding the almost-white PDF export frame.4243 Inspect an 8x RGB raster; ink below luminance 220 forms the board bounds.44 Keep two PDF points of padding so the outer board stroke is not cropped.45 """46 probe_scale = 8.047 pix = page.get_pixmap(matrix=fitz.Matrix(probe_scale, probe_scale), alpha=False)48 image = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)49 mask = image.convert("L").point(lambda value: 255 if value < 220 else 0)50 bbox = mask.getbbox()51 if bbox is None:52 raise ValueError("No board drawing found")53 x0, y0, x1, y1 = bbox54 return fitz.Rect(55 x0 / probe_scale - 2, y0 / probe_scale - 2,56 x1 / probe_scale + 2, y1 / probe_scale + 2,57 ) & page.rect585960def render(source: Path, output: Path) -> dict:61 output.mkdir(parents=True, exist_ok=True)62 documents: dict[str, fitz.Document] = {}63 entries: list[dict] = []6465 def export(filename: str, pdf: str, page_number: int, *, crop=None,66 width: int = 1600, kind: str, description: str,67 automatic_crop: bool = False) -> None:68 if pdf not in documents:69 documents[pdf] = fitz.open(source / ("schematics" if pdf.startswith("SCH_") else "pcb") / pdf)70 page = documents[pdf][page_number - 1]71 bounds = graphic_bounds(page) if automatic_crop else fitz.Rect(crop or page.rect)72 if not page.rect.contains(bounds):73 raise ValueError(f"Crop outside {pdf} page {page_number}: {bounds}")74 scale = width / bounds.width75 pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), clip=bounds, alpha=False)76 target = output / filename77 pixmap.save(target)78 entries.append({79 "file": filename,80 "kind": kind,81 "description": description,82 "source_pdf": "hardware/" + ("schematics/" if pdf.startswith("SCH_") else "pcb/") + pdf,83 "source_sha256": hashlib.sha256((source / ("schematics" if pdf.startswith("SCH_") else "pcb") / pdf).read_bytes()).hexdigest(),84 "page_number": page_number,85 "page_rect_points": [round(n, 4) for n in page.rect],86 "crop_rect_points": [round(n, 4) for n in bounds],87 "crop_method": "8x raster luminance < 220, enclosing bounds + 2 pt padding"88 if automatic_crop else "reviewed fixed region" if crop else "full page",89 "render_scale": round(scale, 8),90 "pixel_size": [pixmap.width, pixmap.height],91 "orientation": "as exported in source PDF; no mirror or rotation",92 })9394 for prefix, pdf in [("linear", "PCB_HallPos.pdf"), ("encoder", "PCB_Encoder.pdf")]:95 export(f"{prefix}-pcb-top.png", pdf, 4, kind="pcb-component-side",96 description="Component-side composite: pads, traces, copper and component outlines; PDF page 4",97 automatic_crop=True)98 export(f"{prefix}-pcb-bottom.png", pdf, 5, kind="pcb-reverse-side",99 description="Reverse-side composite: traces, copper, vias and original markings; PDF page 5",100 automatic_crop=True)101 # Page 1 remains a supplementary locator, never the main PCB illustration.102 export(f"{prefix}-placement.png", pdf, 1, kind="reference-designator-locator",103 description="Supplementary component locator from PDF page 1; not a copper-layer drawing",104 automatic_crop=True, width=1300)105106 for prefix, pdf in [("linear", "SCH_HallPos.pdf"), ("encoder", "SCH_Encoder.pdf")]:107 export(f"{prefix}-schematic.png", pdf, 1, width=2400,108 kind="full-schematic", description="Complete schematic, including source title block")109110 for filename, crop, description in SCHEMATIC_REGIONS:111 export(filename, "SCH_HallPos.pdf", 1, crop=crop, width=1800 if filename == "linear-power.png" else 1400,112 kind="schematic-detail", description=description)113114 manifest = {115 "schema_version": 1,116 "generator": "scripts/render_hardware_assets.py",117 "renderer": f"PyMuPDF {fitz.VersionBind}",118 "coordinate_system": "PDF points, origin top-left, page_number is one-based",119 "board_side_convention": {120 "top": "PDF page 4, identified as the populated/component side from pad and component outlines",121 "bottom": "PDF page 5, opposite side; preserve the source PDF's view",122 "qualification": "PDF pages do not print layer names. These are composite views, not individual Gerber layers.",123 },124 "assets": entries,125 }126 (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")127 for document in documents.values():128 document.close()129 return manifest130131132def main() -> None:133 parser = argparse.ArgumentParser(description=__doc__)134 parser.add_argument("--source-dir", type=Path, default=DEFAULT_SOURCE)135 parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT)136 args = parser.parse_args()137 manifest = render(args.source_dir.resolve(), args.output_dir.resolve())138 for asset in manifest["assets"]:139 print(f"{asset['file']}: {asset['pixel_size'][0]} x {asset['pixel_size'][1]}, "140 f"{Path(asset['source_pdf']).name} p{asset['page_number']}")141142143if __name__ == "__main__":144 main()