init projects
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# mkdocs-schematic-footprint-viewer
|
||||
|
||||
A Zensical/Python-Markdown extension that renders an interactive schematic and footprint viewer in Markdown.
|
||||
|
||||
Use this shortcode anywhere in Markdown and pass the JSON file at the call site:
|
||||
|
||||
```md
|
||||
{% schematic-footprint json="files/VL53L0X/VL53L0X_merged.json" %}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install mkdocs-schematic-footprint-viewer
|
||||
```
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
pip install -e ".[test]"
|
||||
```
|
||||
|
||||
## Zensical Configuration
|
||||
|
||||
Zensical's public plugin/module API is still evolving, so this package integrates through the stable Python-Markdown extension path that Zensical supports.
|
||||
|
||||
Add the extension to `zensical.toml`:
|
||||
|
||||
```toml
|
||||
[project.markdown_extensions.schematic_footprint_viewer]
|
||||
default_json = "merged.json"
|
||||
```
|
||||
|
||||
Then use the shortcode in Markdown:
|
||||
|
||||
```md
|
||||
{% schematic-footprint json="files/VL53L0X/VL53L0X_merged.json" %}
|
||||
```
|
||||
|
||||
Run Zensical:
|
||||
|
||||
```bash
|
||||
zensical serve
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
zensical build
|
||||
```
|
||||
|
||||
## Shortcode Options
|
||||
|
||||
The JSON path belongs in the Markdown shortcode:
|
||||
|
||||
```md
|
||||
{% schematic-footprint json="merged.json" %}
|
||||
```
|
||||
|
||||
`src` is also accepted as an alias:
|
||||
|
||||
```md
|
||||
{% schematic-footprint src="files/MP1584/MP1584_merged.json" %}
|
||||
```
|
||||
|
||||
You can override the box dimensions per shortcode:
|
||||
|
||||
```md
|
||||
{% schematic-footprint json="merged.json" width="24rem" height="20rem" %}
|
||||
```
|
||||
|
||||
## Zensical Configuration With Options
|
||||
|
||||
These settings define defaults for every viewer:
|
||||
|
||||
```toml
|
||||
[project.markdown_extensions.schematic_footprint_viewer]
|
||||
default_json = "merged.json"
|
||||
width = "15rem"
|
||||
height = "13rem"
|
||||
konva_url = "https://unpkg.com/konva@9/konva.min.js"
|
||||
```
|
||||
|
||||
If you keep `konva.min.js` locally in your built site, point `konva_url` to that file:
|
||||
|
||||
```toml
|
||||
[project.markdown_extensions.schematic_footprint_viewer]
|
||||
konva_url = "konva.min.js"
|
||||
```
|
||||
|
||||
If your `zensical.toml` already has plugins, keep them separate. For example:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
plugins = [
|
||||
{ search = {} },
|
||||
{ badges = {} },
|
||||
{ document-dates = { position = "top", type = "timeago", exclude = [
|
||||
"index.md",
|
||||
"blog/*",
|
||||
] } },
|
||||
]
|
||||
|
||||
[project.markdown_extensions.schematic_footprint_viewer]
|
||||
default_json = "merged.json"
|
||||
width = "15rem"
|
||||
height = "13rem"
|
||||
```
|
||||
|
||||
Do not add this package to `plugins` in `zensical.toml`; use `markdown_extensions` as shown above.
|
||||
|
||||
## Output
|
||||
|
||||
The shortcode is replaced during Markdown rendering with a scoped viewer containing two Konva canvases: one for PCB/footprint data and one for schematic data. The viewer fetches the JSON path passed in Markdown.
|
||||
@@ -0,0 +1,42 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mkdocs-schematic-footprint-viewer"
|
||||
version = "0.1.1"
|
||||
description = "Zensical/Python-Markdown extension that renders schematic footprint viewer shortcodes."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Nearology" }
|
||||
]
|
||||
keywords = ["mkdocs", "zensical", "schematic", "footprint", "viewer"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Documentation",
|
||||
]
|
||||
dependencies = [
|
||||
"Markdown>=3.3.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/schematic_footprint_viewer"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Zensical/Python-Markdown extension for schematic footprint viewer shortcodes."""
|
||||
|
||||
from .extension import (
|
||||
SchematicFootprintViewerExtension,
|
||||
ViewerOptions,
|
||||
makeExtension,
|
||||
render_viewer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SchematicFootprintViewerExtension",
|
||||
"ViewerOptions",
|
||||
"makeExtension",
|
||||
"render_viewer",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,801 @@
|
||||
"""Python-Markdown extension for schematic footprint viewer shortcodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
|
||||
from markdown import Markdown
|
||||
from markdown.extensions import Extension
|
||||
from markdown.preprocessors import Preprocessor
|
||||
|
||||
|
||||
SHORTCODE_RE = re.compile(r"{%\s*schematic-footprint(?P<attrs>.*?)\s*%}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ViewerOptions:
|
||||
"""Runtime settings for the generated viewer."""
|
||||
|
||||
json_path: str
|
||||
width: str = "15rem"
|
||||
height: str = "13rem"
|
||||
konva_url: str = "https://unpkg.com/konva@9/konva.min.js"
|
||||
|
||||
|
||||
def _parse_shortcode_attrs(raw_attrs: str, default_json: str) -> dict[str, str]:
|
||||
attrs: dict[str, str] = {}
|
||||
for token in shlex.split(raw_attrs.strip()):
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
attrs[key.strip()] = value.strip()
|
||||
elif token:
|
||||
attrs.setdefault("json", token)
|
||||
|
||||
if "src" in attrs and "json" not in attrs:
|
||||
attrs["json"] = attrs["src"]
|
||||
attrs.setdefault("json", default_json)
|
||||
return attrs
|
||||
|
||||
|
||||
def _script_data(value: str) -> str:
|
||||
return escape(value, quote=True)
|
||||
|
||||
|
||||
def render_viewer(options: ViewerOptions) -> str:
|
||||
"""Render the schematic/footprint viewer shell."""
|
||||
|
||||
json_path = _script_data(options.json_path)
|
||||
width = _script_data(options.width)
|
||||
height = _script_data(options.height)
|
||||
konva_url = _script_data(options.konva_url)
|
||||
|
||||
return f"""
|
||||
<div class="schematic-footprint-viewer" data-sfv-json="{json_path}" data-sfv-konva="{konva_url}" style="--sfv-box-w:{width};--sfv-box-h:{height};">
|
||||
<div class="sfv-viewer-box" data-sfv-pcb-box>
|
||||
<div class="sfv-box-header">
|
||||
<span class="sfv-box-label sfv-pcb-label">PCB</span>
|
||||
<div class="sfv-box-controls">
|
||||
<button class="sfv-ctrl-btn" type="button" data-sfv-fit-pcb>Fit</button>
|
||||
<span class="sfv-zoom-pill" data-sfv-pcb-zoom>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sfv-box-canvas-wrap" style="background:#000000">
|
||||
<div data-sfv-pcb-canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sfv-viewer-box" data-sfv-sch-box>
|
||||
<div class="sfv-box-header">
|
||||
<span class="sfv-box-label sfv-sch-label">SCHEMATIC</span>
|
||||
<div class="sfv-box-controls">
|
||||
<button class="sfv-ctrl-btn" type="button" data-sfv-fit-sch>Fit</button>
|
||||
<span class="sfv-zoom-pill" data-sfv-sch-zoom>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sfv-box-canvas-wrap" style="background:#ffffff">
|
||||
<div data-sfv-sch-canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sfv-loading"><span>PARSING...</span></div>
|
||||
<div class="sfv-tooltip"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {{
|
||||
const root = document.currentScript.previousElementSibling;
|
||||
const konvaUrl = root.dataset.sfvKonva;
|
||||
|
||||
function boot() {{
|
||||
if (window.SchematicFootprintViewer) {{
|
||||
window.SchematicFootprintViewer.mount(root);
|
||||
return;
|
||||
}}
|
||||
window.__sfvPending = window.__sfvPending || [];
|
||||
window.__sfvPending.push(root);
|
||||
if (window.__sfvLoading) return;
|
||||
window.__sfvLoading = true;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `{VIEWER_CSS}`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = konvaUrl;
|
||||
script.onload = function() {{
|
||||
installSchematicFootprintViewer();
|
||||
window.__sfvPending.splice(0).forEach(function(el) {{
|
||||
window.SchematicFootprintViewer.mount(el);
|
||||
}});
|
||||
}};
|
||||
script.onerror = function() {{
|
||||
root.querySelector(".sfv-loading span").textContent = "KONVA LOAD FAILED";
|
||||
root.querySelector(".sfv-loading").classList.add("active");
|
||||
}};
|
||||
document.head.appendChild(script);
|
||||
}}
|
||||
|
||||
function installSchematicFootprintViewer() {{
|
||||
if (window.SchematicFootprintViewer) return;
|
||||
window.SchematicFootprintViewer = {{ mount }};
|
||||
|
||||
function mount(root) {{
|
||||
const $ = (selector) => root.querySelector(selector);
|
||||
const jsonPath = root.dataset.sfvJson;
|
||||
const pcbCanvas = $("[data-sfv-pcb-canvas]");
|
||||
const schCanvas = $("[data-sfv-sch-canvas]");
|
||||
const loading = $(".sfv-loading");
|
||||
const tip = $(".sfv-tooltip");
|
||||
|
||||
let pcbStage = null;
|
||||
let schStage = null;
|
||||
let pcbLayers = {{}};
|
||||
let schLayers = {{}};
|
||||
let pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
let schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
let pcbBounds = null;
|
||||
let schBounds = null;
|
||||
const pinRegistry = {{}};
|
||||
|
||||
const ZOOM_MIN = 1;
|
||||
const ZOOM_MAX = 5;
|
||||
const HL_COLOR = "#00e5ff";
|
||||
const HL_SHADOW = "#00e5ff";
|
||||
const PCB_BASE = "#ff0000";
|
||||
const SCH_BASE = "#ff0000";
|
||||
|
||||
function showTip(text, evt) {{
|
||||
tip.textContent = text;
|
||||
tip.style.left = evt.clientX + 14 + "px";
|
||||
tip.style.top = evt.clientY + 14 + "px";
|
||||
tip.style.display = "block";
|
||||
}}
|
||||
function hideTip() {{
|
||||
tip.style.display = "none";
|
||||
}}
|
||||
root.addEventListener("mousemove", function(e) {{
|
||||
if (tip.style.display === "block") {{
|
||||
tip.style.left = e.clientX + 14 + "px";
|
||||
tip.style.top = e.clientY + 14 + "px";
|
||||
}}
|
||||
}});
|
||||
|
||||
function highlightPin(dsg, tooltipText, evt) {{
|
||||
const entry = pinRegistry[dsg];
|
||||
if (!entry) return;
|
||||
if (entry.pcb) {{
|
||||
entry.pcb.stroke(HL_COLOR);
|
||||
entry.pcb.fill(HL_COLOR + "50");
|
||||
entry.pcb.shadowColor(HL_SHADOW);
|
||||
entry.pcb.shadowBlur(12);
|
||||
entry.pcb.shadowOpacity(0.9);
|
||||
pcbLayers.pin.draw();
|
||||
pcbLayers.pad.draw();
|
||||
}}
|
||||
if (entry.sch) {{
|
||||
entry.sch.getChildren().forEach(function(child) {{
|
||||
if (child.className === "Rect") {{
|
||||
child.fill(HL_COLOR);
|
||||
child.shadowColor(HL_SHADOW);
|
||||
child.shadowBlur(10);
|
||||
child.shadowOpacity(0.9);
|
||||
}}
|
||||
if (child.className === "Line") {{
|
||||
child.stroke(HL_COLOR);
|
||||
child.shadowColor(HL_SHADOW);
|
||||
child.shadowBlur(8);
|
||||
child.shadowOpacity(0.8);
|
||||
}}
|
||||
if (child.className === "Text") child.fill(HL_COLOR + "cc");
|
||||
}});
|
||||
schLayers.pin.draw();
|
||||
}}
|
||||
if (tooltipText && evt) showTip(tooltipText, evt);
|
||||
}}
|
||||
|
||||
function unhighlightPin(dsg) {{
|
||||
const entry = pinRegistry[dsg];
|
||||
if (!entry) return;
|
||||
if (entry.pcb) {{
|
||||
entry.pcb.fill(PCB_BASE);
|
||||
entry.pcb.stroke(PCB_BASE);
|
||||
entry.pcb.shadowBlur(0);
|
||||
entry.pcb.shadowOpacity(0);
|
||||
pcbLayers.pin.draw();
|
||||
pcbLayers.pad.draw();
|
||||
}}
|
||||
if (entry.sch) {{
|
||||
entry.sch.getChildren().forEach(function(child) {{
|
||||
if (child.className === "Rect") {{
|
||||
child.fill(SCH_BASE);
|
||||
child.shadowBlur(0);
|
||||
child.shadowOpacity(0);
|
||||
}}
|
||||
if (child.className === "Line") {{
|
||||
child.stroke(SCH_BASE);
|
||||
child.shadowBlur(0);
|
||||
child.shadowOpacity(0);
|
||||
}}
|
||||
if (child.className === "Text") child.fill("black");
|
||||
}});
|
||||
schLayers.pin.draw();
|
||||
}}
|
||||
hideTip();
|
||||
}}
|
||||
|
||||
function clampTransform(transform, bounds, stageW, stageH) {{
|
||||
if (!bounds) return;
|
||||
const margin = 40;
|
||||
const s = transform.scale;
|
||||
const cx1 = transform.x + bounds.x * s;
|
||||
const cy1 = transform.y + bounds.y * s;
|
||||
const cx2 = cx1 + bounds.w * s;
|
||||
const cy2 = cy1 + bounds.h * s;
|
||||
if (cx2 < margin) transform.x += margin - cx2;
|
||||
if (cx1 > stageW - margin) transform.x -= cx1 - (stageW - margin);
|
||||
if (cy2 < margin) transform.y += margin - cy2;
|
||||
if (cy1 > stageH - margin) transform.y -= cy1 - (stageH - margin);
|
||||
}}
|
||||
|
||||
function applyPcbTransform() {{
|
||||
Object.values(pcbLayers).forEach(function(layer) {{
|
||||
layer.x(pcbTransform.x);
|
||||
layer.y(pcbTransform.y);
|
||||
layer.scaleX(pcbTransform.scale);
|
||||
layer.scaleY(pcbTransform.scale);
|
||||
layer.draw();
|
||||
}});
|
||||
$("[data-sfv-pcb-zoom]").textContent = Math.round(pcbTransform.scale * 100) + "%";
|
||||
}}
|
||||
|
||||
function applySchTransform() {{
|
||||
Object.values(schLayers).forEach(function(layer) {{
|
||||
layer.x(schTransform.x);
|
||||
layer.y(schTransform.y);
|
||||
layer.scaleX(schTransform.scale);
|
||||
layer.scaleY(schTransform.scale);
|
||||
layer.draw();
|
||||
}});
|
||||
$("[data-sfv-sch-zoom]").textContent = Math.round(schTransform.scale * 100) + "%";
|
||||
}}
|
||||
|
||||
$("[data-sfv-fit-pcb]").addEventListener("click", function() {{
|
||||
pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
applyPcbTransform();
|
||||
}});
|
||||
$("[data-sfv-fit-sch]").addEventListener("click", function() {{
|
||||
schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
applySchTransform();
|
||||
}});
|
||||
|
||||
function getStageSizePx(el) {{
|
||||
return {{ w: el.clientWidth || 480, h: el.clientHeight || 384 }};
|
||||
}}
|
||||
|
||||
function initStages() {{
|
||||
if (pcbStage) pcbStage.destroy();
|
||||
if (schStage) schStage.destroy();
|
||||
|
||||
const pcbSz = getStageSizePx(pcbCanvas);
|
||||
const schSz = getStageSizePx(schCanvas);
|
||||
pcbStage = new Konva.Stage({{ container: pcbCanvas, width: pcbSz.w, height: pcbSz.h }});
|
||||
schStage = new Konva.Stage({{ container: schCanvas, width: schSz.w, height: schSz.h }});
|
||||
|
||||
pcbLayers = {{
|
||||
board: new Konva.Layer(),
|
||||
track: new Konva.Layer(),
|
||||
pad: new Konva.Layer(),
|
||||
hole: new Konva.Layer(),
|
||||
via: new Konva.Layer(),
|
||||
pin: new Konva.Layer(),
|
||||
comp: new Konva.Layer(),
|
||||
}};
|
||||
schLayers = {{
|
||||
line: new Konva.Layer(),
|
||||
poly: new Konva.Layer(),
|
||||
arc: new Konva.Layer(),
|
||||
comp: new Konva.Layer(),
|
||||
pin: new Konva.Layer(),
|
||||
label: new Konva.Layer(),
|
||||
}};
|
||||
Object.values(pcbLayers).forEach((layer) => pcbStage.add(layer));
|
||||
Object.values(schLayers).forEach((layer) => schStage.add(layer));
|
||||
setupInteraction(pcbStage, function() {{ return pcbTransform; }}, function(v) {{ pcbTransform = v; }}, function() {{ return pcbBounds; }}, applyPcbTransform);
|
||||
setupInteraction(schStage, function() {{ return schTransform; }}, function(v) {{ schTransform = v; }}, function() {{ return schBounds; }}, applySchTransform);
|
||||
setupResize();
|
||||
}}
|
||||
|
||||
function setupInteraction(stage, getTransform, setTransform, getBounds, applyTransform) {{
|
||||
stage.on("wheel", function(e) {{
|
||||
e.evt.preventDefault();
|
||||
const ptr = stage.getPointerPosition();
|
||||
const transform = getTransform();
|
||||
const old = transform.scale;
|
||||
const mpt = {{ x: (ptr.x - transform.x) / old, y: (ptr.y - transform.y) / old }};
|
||||
const dir = e.evt.deltaY > 0 ? -1 : 1;
|
||||
const ns = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, old * (1 + dir * 0.12)));
|
||||
const next = {{ scale: ns, x: ptr.x - mpt.x * ns, y: ptr.y - mpt.y * ns }};
|
||||
clampTransform(next, getBounds(), stage.width(), stage.height());
|
||||
setTransform(next);
|
||||
applyTransform();
|
||||
}});
|
||||
|
||||
let panStart = null;
|
||||
stage.on("mousedown", function() {{
|
||||
const ptr = stage.getPointerPosition();
|
||||
const transform = getTransform();
|
||||
panStart = {{ mx: ptr.x, my: ptr.y, tx: transform.x, ty: transform.y }};
|
||||
stage.container().style.cursor = "grabbing";
|
||||
}});
|
||||
stage.on("mousemove", function() {{
|
||||
if (!panStart) return;
|
||||
const ptr = stage.getPointerPosition();
|
||||
const transform = getTransform();
|
||||
const next = {{ x: panStart.tx + (ptr.x - panStart.mx), y: panStart.ty + (ptr.y - panStart.my), scale: transform.scale }};
|
||||
clampTransform(next, getBounds(), stage.width(), stage.height());
|
||||
setTransform(next);
|
||||
applyTransform();
|
||||
}});
|
||||
stage.on("mouseup", function() {{
|
||||
panStart = null;
|
||||
stage.container().style.cursor = "default";
|
||||
}});
|
||||
stage.container().addEventListener("mouseleave", function() {{
|
||||
panStart = null;
|
||||
stage.container().style.cursor = "default";
|
||||
}});
|
||||
}}
|
||||
|
||||
function setupResize() {{
|
||||
const ro = new ResizeObserver(function() {{
|
||||
if (pcbStage) {{
|
||||
const s = getStageSizePx(pcbCanvas);
|
||||
pcbStage.width(s.w);
|
||||
pcbStage.height(s.h);
|
||||
}}
|
||||
if (schStage) {{
|
||||
const s = getStageSizePx(schCanvas);
|
||||
schStage.width(s.w);
|
||||
schStage.height(s.h);
|
||||
}}
|
||||
}});
|
||||
ro.observe(pcbCanvas);
|
||||
ro.observe(schCanvas);
|
||||
}}
|
||||
|
||||
function createHatchPattern(color, size, lineWidth) {{
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size || 10;
|
||||
canvas.height = size || 10;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth || 1.5;
|
||||
ctx.lineCap = "square";
|
||||
[-canvas.width, 0, canvas.width].forEach(function(offset) {{
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(offset, canvas.height);
|
||||
ctx.lineTo(offset + canvas.width, 0);
|
||||
ctx.stroke();
|
||||
}});
|
||||
return canvas;
|
||||
}}
|
||||
|
||||
function renderPCB(PCB) {{
|
||||
Object.values(pcbLayers).forEach((layer) => layer.destroyChildren());
|
||||
pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
|
||||
const pads = PCB.pads || [];
|
||||
const tracks = PCB.tracks || [];
|
||||
const pins = PCB.pins || [];
|
||||
const comps = PCB.components || [];
|
||||
const holes = PCB.holes || [];
|
||||
const vias = PCB.vias || [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
pads.forEach((p) => {{ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }});
|
||||
tracks.forEach((t) => {{ minX = Math.min(minX, t.x1, t.x2); maxX = Math.max(maxX, t.x1, t.x2); minY = Math.min(minY, t.y1, t.y2); maxY = Math.max(maxY, t.y1, t.y2); }});
|
||||
if (!isFinite(minX)) {{ minX = 0; maxX = 100; minY = 0; maxY = 100; }}
|
||||
|
||||
const margin = 24;
|
||||
const cW = pcbStage.width();
|
||||
const cH = pcbStage.height();
|
||||
const baseScale = Math.min((cW - margin * 2) / (maxX - minX || 1), (cH - margin * 2) / (maxY - minY || 1));
|
||||
const drawnW = (maxX - minX) * baseScale;
|
||||
const drawnH = (maxY - minY) * baseScale;
|
||||
const offX = (cW - drawnW) / 2;
|
||||
const offY = (cH - drawnH) / 2;
|
||||
const tx = (x) => (x - minX) * baseScale + offX;
|
||||
const ty = (y) => (maxY - y) * baseScale + offY;
|
||||
pcbBounds = {{ x: offX, y: offY, w: drawnW, h: drawnH }};
|
||||
|
||||
let boardOutline = null;
|
||||
if (tracks.length) {{
|
||||
const groups = {{}};
|
||||
tracks.forEach((t) => {{ (groups[t.width] = groups[t.width] || []).push(t); }});
|
||||
let bestRatio = -Infinity;
|
||||
Object.values(groups).forEach((group) => {{
|
||||
let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
|
||||
group.forEach((t) => {{ gMinX = Math.min(gMinX, t.x1, t.x2); gMaxX = Math.max(gMaxX, t.x1, t.x2); gMinY = Math.min(gMinY, t.y1, t.y2); gMaxY = Math.max(gMaxY, t.y1, t.y2); }});
|
||||
const ratio = ((gMaxX - gMinX) * (gMaxY - gMinY)) / group.length;
|
||||
if (ratio > bestRatio) {{ bestRatio = ratio; boardOutline = {{ minX: gMinX, minY: gMinY, maxX: gMaxX, maxY: gMaxY }}; }}
|
||||
}});
|
||||
}}
|
||||
|
||||
const hatchPattern = createHatchPattern("#a855f7", 3, 0.75);
|
||||
if (boardOutline) {{
|
||||
pcbLayers.board.add(new Konva.Rect({{ x: tx(boardOutline.minX), y: ty(boardOutline.maxY), width: tx(boardOutline.maxX) - tx(boardOutline.minX), height: ty(boardOutline.minY) - ty(boardOutline.maxY), fillPatternImage: hatchPattern, fillPatternRepeat: "repeat", listening: false }}));
|
||||
}} else {{
|
||||
pcbLayers.board.add(new Konva.Rect({{ x: offX, y: offY, width: drawnW, height: drawnH, fillPatternImage: hatchPattern, fillPatternRepeat: "repeat", listening: false }}));
|
||||
}}
|
||||
|
||||
tracks.forEach((t) => pcbLayers.track.add(new Konva.Line({{ points: [tx(t.x1), ty(t.y1), tx(t.x2), ty(t.y2)], stroke: "Yellow", strokeWidth: Math.max(0.5, t.width * baseScale), lineCap: "round", listening: false }})));
|
||||
holes.forEach((h) => pcbLayers.hole.add(new Konva.Circle({{ x: tx(h.x), y: ty(h.y), radius: Math.max(1, (h.d || 0.5) * baseScale * 0.5), fill: "#1a1a2e", stroke: "#546e7a", strokeWidth: 0.5, listening: false }})));
|
||||
vias.forEach((v) => pcbLayers.via.add(new Konva.Circle({{ x: tx(v.x), y: ty(v.y), radius: Math.max(1.5, (v.d || 1) * baseScale * 0.4), fill: "#263238", stroke: "#80cbc4", strokeWidth: 0.5, listening: false }})));
|
||||
|
||||
pads.forEach((p) => {{
|
||||
const dsg = String(p.pin);
|
||||
const offset = 2.85;
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const pw = Math.max(2, (p.w || 1) * baseScale * 0.6);
|
||||
const ph = Math.max(2, (p.h || 1) * baseScale);
|
||||
const node = new Konva.Rect({{ x: tx(p.x) - pw / 2 + (p.x < centerX ? offset : -offset), y: ty(p.y) - ph / 2, width: pw, height: ph, fill: PCB_BASE, stroke: PCB_BASE, strokeWidth: 0.3 }});
|
||||
if (!pinRegistry[dsg]) pinRegistry[dsg] = {{ pcb: null, sch: null }};
|
||||
pinRegistry[dsg].pcb = node;
|
||||
node.on("mouseenter", (e) => highlightPin(dsg, `[PCB] Pin: ${{dsg}}`, e.evt));
|
||||
node.on("mouseleave", () => unhighlightPin(dsg));
|
||||
pcbLayers.pad.add(node);
|
||||
}});
|
||||
|
||||
comps.forEach((comp) => {{
|
||||
const cx = tx(comp.x), cy = ty(comp.y);
|
||||
const g = new Konva.Group();
|
||||
g.add(new Konva.Line({{ points: [cx - 7, cy, cx + 7, cy], stroke: "#a5d6a7", strokeWidth: 1, opacity: 0.9 }}));
|
||||
g.add(new Konva.Line({{ points: [cx, cy - 7, cx, cy + 7], stroke: "#a5d6a7", strokeWidth: 1, opacity: 0.9 }}));
|
||||
g.add(new Konva.Circle({{ x: cx, y: cy, radius: 3.5, stroke: "#a5d6a7", strokeWidth: 1, fill: "transparent" }}));
|
||||
g.add(new Konva.Text({{ x: cx - 15, y: cy - 14, text: comp.name || "", fontSize: 6, fontFamily: "Share Tech Mono", fill: "#a5d6a7", opacity: 0.9 }}));
|
||||
g.on("mouseenter", (e) => showTip(`${{comp.name || ""}}\\n(${{Number(comp.x).toFixed(2)}}, ${{Number(comp.y).toFixed(2)}})\\n${{comp.layer || ""}}`, e.evt));
|
||||
g.on("mouseleave", hideTip);
|
||||
pcbLayers.comp.add(g);
|
||||
}});
|
||||
|
||||
Object.values(pcbLayers).forEach((layer) => layer.draw());
|
||||
}}
|
||||
|
||||
function renderSchematic(data) {{
|
||||
Object.values(schLayers).forEach((layer) => layer.destroyChildren());
|
||||
schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||
|
||||
const components = data.components || [];
|
||||
const pins = data.pins || [];
|
||||
const lines = data.lines || [];
|
||||
const rects = data.rects || [];
|
||||
const arcs = data.arcs || [];
|
||||
const labels = data.labels || [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const allPts = [];
|
||||
components.forEach((c) => allPts.push({{ x: c.x, y: c.y }}));
|
||||
pins.forEach((p) => allPts.push({{ x: p.x, y: p.y }}));
|
||||
rects.forEach((r) => {{ allPts.push({{ x: r.x1, y: r.y1 }}); allPts.push({{ x: r.x2, y: r.y2 }}); }});
|
||||
allPts.forEach((p) => {{ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }});
|
||||
if (!isFinite(minX)) {{ minX = 0; maxX = 1000; minY = 0; maxY = 800; }}
|
||||
|
||||
const margin = 24;
|
||||
const W = schStage.width();
|
||||
const H = schStage.height();
|
||||
const baseScale = Math.min((W - margin * 2) / (maxX - minX || 1), (H - margin * 2) / (maxY - minY || 1));
|
||||
const drawnW = (maxX - minX) * baseScale;
|
||||
const drawnH = (maxY - minY) * baseScale;
|
||||
const offX = (W - drawnW) / 2;
|
||||
const offY = (H - drawnH) / 2;
|
||||
const tx = (x) => (x - minX) * baseScale + offX;
|
||||
const ty = (y) => H - offY - (y - minY) * baseScale;
|
||||
schBounds = {{ x: offX, y: offY, w: drawnW, h: drawnH }};
|
||||
|
||||
rects.forEach((r) => {{
|
||||
const x = tx(Math.min(r.x1, r.x2));
|
||||
const y = ty(Math.max(r.y1, r.y2));
|
||||
const w = Math.abs(tx(r.x2) - tx(r.x1));
|
||||
const h = Math.abs(ty(r.y2) - ty(r.y1));
|
||||
schLayers.comp.add(new Konva.Rect({{ x, y, width: w, height: h, stroke: "black", fill: "lightyellow", strokeWidth: Math.max(0.5, (r.lineWidth || 1) * baseScale), cornerRadius: 2 }}));
|
||||
}});
|
||||
|
||||
lines.forEach((ln) => {{
|
||||
const pts = (ln.points || []).flatMap((p) => [tx(p.x), ty(p.y)]);
|
||||
if (pts.length < 4) return;
|
||||
schLayers.line.add(new Konva.Line({{ points: pts, stroke: "#4fc3f7", strokeWidth: Math.max(0.3, (ln.lineWidth || 0.5) * baseScale * 0.1), listening: false }}));
|
||||
}});
|
||||
|
||||
arcs.forEach((a) => {{
|
||||
const startAngle = a.startAngle || 0;
|
||||
const endAngle = a.endAngle != null ? a.endAngle : 360;
|
||||
let sweep = endAngle - startAngle;
|
||||
if (sweep <= 0) sweep += 360;
|
||||
schLayers.arc.add(new Konva.Arc({{ x: tx(a.cx), y: ty(a.cy), innerRadius: 0, outerRadius: a.radius * baseScale, angle: sweep, rotation: -startAngle, stroke: "#4fc3f7", strokeWidth: Math.max(0.3, (a.lineWidth || 0.5) * baseScale * 0.08), fill: "transparent", listening: false }}));
|
||||
}});
|
||||
|
||||
pins.forEach((pin) => {{
|
||||
const px = tx(pin.x), py = ty(pin.y);
|
||||
const len = Math.max(6, (pin.length || 20) * baseScale * 0.15);
|
||||
const dir = ((pin.conglomerate || 0) >> 1) & 1;
|
||||
const dx = dir ? -len : len;
|
||||
const dsg = String(pin.designator || "");
|
||||
const g = new Konva.Group();
|
||||
g.add(new Konva.Line({{ points: [px, py, px + dx, py], stroke: SCH_BASE, strokeWidth: 1, opacity: 0.8 }}));
|
||||
g.add(new Konva.Rect({{ x: px - 2, y: py - 2, width: 4, height: 4, fill: SCH_BASE, opacity: 0.9 }}));
|
||||
if (pin.name) {{
|
||||
g.add(new Konva.Text({{ x: px - 28, y: py - 5, width: 52, align: dir ? "right" : "left", text: pin.name, fontSize: 7, fontFamily: "Share Tech Mono,monospace", fill: "black" }}));
|
||||
}}
|
||||
if (dsg) {{
|
||||
if (!pinRegistry[dsg]) pinRegistry[dsg] = {{ pcb: null, sch: null }};
|
||||
pinRegistry[dsg].sch = g;
|
||||
}}
|
||||
g.on("mouseenter", (e) => highlightPin(dsg, `[SCH] Pin: ${{pin.name || "?"}} (${{dsg}})`, e.evt));
|
||||
g.on("mouseleave", () => unhighlightPin(dsg));
|
||||
schLayers.pin.add(g);
|
||||
}});
|
||||
|
||||
labels.forEach((lb) => {{
|
||||
let fontStyle = "normal";
|
||||
let offsetY = 0;
|
||||
const altiumFontSize = lb.fontSize || 10;
|
||||
let renderFontSize;
|
||||
if (altiumFontSize > 10) {{
|
||||
renderFontSize = altiumFontSize * 0.45;
|
||||
fontStyle = "bold";
|
||||
offsetY = 2;
|
||||
}} else {{
|
||||
renderFontSize = altiumFontSize * 0.75;
|
||||
}}
|
||||
schLayers.label.add(new Konva.Text({{ x: tx(lb.x), fontStyle, y: ty(lb.y + altiumFontSize - offsetY), text: lb.text || "", fontSize: renderFontSize, fontFamily: "Share Tech Mono,monospace", fill: "black", listening: false }}));
|
||||
}});
|
||||
|
||||
Object.values(schLayers).forEach((layer) => layer.draw());
|
||||
}}
|
||||
|
||||
function loadFromPath(path) {{
|
||||
loading.classList.add("active");
|
||||
fetch(path)
|
||||
.then(function(res) {{
|
||||
if (!res.ok) throw new Error(`HTTP ${{res.status}}: ${{res.statusText}}`);
|
||||
return res.json();
|
||||
}})
|
||||
.then(function(data) {{
|
||||
Object.keys(pinRegistry).forEach((key) => delete pinRegistry[key]);
|
||||
initStages();
|
||||
if (data.pcb) renderPCB(data.pcb);
|
||||
if (data.schematic) renderSchematic(data.schematic);
|
||||
applyPcbTransform();
|
||||
applySchTransform();
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
console.error("Schematic footprint viewer load failed:", err);
|
||||
loading.querySelector("span").textContent = "JSON LOAD FAILED";
|
||||
}})
|
||||
.finally(function() {{
|
||||
setTimeout(function() {{ loading.classList.remove("active"); }}, 250);
|
||||
}});
|
||||
}}
|
||||
|
||||
loadFromPath(jsonPath);
|
||||
}}
|
||||
}}
|
||||
|
||||
boot();
|
||||
}})();
|
||||
</script>
|
||||
""".strip()
|
||||
|
||||
|
||||
VIEWER_CSS = r"""
|
||||
.schematic-footprint-viewer,
|
||||
.schematic-footprint-viewer * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.schematic-footprint-viewer {
|
||||
--sfv-bg: #0d1117;
|
||||
--sfv-panel: #161b22;
|
||||
--sfv-border: #21262d;
|
||||
--sfv-accent-pcb: #00e5a0;
|
||||
--sfv-accent-sch: #4fc3f7;
|
||||
--sfv-text: #e6edf3;
|
||||
--sfv-dim: #8b949e;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--sfv-bg);
|
||||
color: var(--sfv-text);
|
||||
font-family: "Share Tech Mono", "Courier New", monospace;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-viewer-box {
|
||||
flex: 1 1 0;
|
||||
width: var(--sfv-box-w);
|
||||
height: var(--sfv-box-h);
|
||||
max-width: calc(50% - 0.25rem);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--sfv-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #090b0c;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-box-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
height: 2.1rem;
|
||||
background: var(--sfv-panel);
|
||||
border-bottom: 1px solid var(--sfv-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-box-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 2.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-pcb-label {
|
||||
color: var(--sfv-accent-pcb);
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-sch-label {
|
||||
color: var(--sfv-accent-sch);
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-box-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-box-canvas-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-box-canvas-wrap > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-ctrl-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--sfv-border);
|
||||
color: var(--sfv-dim);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-ctrl-btn:hover {
|
||||
border-color: var(--sfv-accent-pcb);
|
||||
color: var(--sfv-accent-pcb);
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-zoom-pill {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--sfv-accent-pcb);
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-loading {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(13, 17, 23, 0.85);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-loading.active {
|
||||
display: flex;
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-loading span {
|
||||
font-size: 13px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--sfv-accent-pcb);
|
||||
animation: sfv-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sfv-pulse {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.schematic-footprint-viewer .sfv-tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
background: var(--sfv-panel);
|
||||
border: 1px solid var(--sfv-accent-pcb);
|
||||
color: var(--sfv-text);
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
white-space: pre;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0, 229, 160, 0.15);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.schematic-footprint-viewer{
|
||||
--sfv-bg: #0d111700!important;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class SchematicFootprintPreprocessor(Preprocessor):
|
||||
"""Replace schematic footprint shortcodes before Markdown parsing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
md: Markdown,
|
||||
default_json: str,
|
||||
width: str,
|
||||
height: str,
|
||||
konva_url: str,
|
||||
) -> None:
|
||||
super().__init__(md)
|
||||
self.default_json = default_json
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.konva_url = konva_url
|
||||
|
||||
def run(self, lines: list[str]) -> list[str]:
|
||||
markdown = "\n".join(lines)
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
attrs = _parse_shortcode_attrs(
|
||||
match.group("attrs"),
|
||||
default_json=self.default_json,
|
||||
)
|
||||
return render_viewer(
|
||||
ViewerOptions(
|
||||
json_path=attrs["json"],
|
||||
width=attrs.get("width", self.width),
|
||||
height=attrs.get("height", self.height),
|
||||
konva_url=attrs.get("konva", self.konva_url),
|
||||
),
|
||||
)
|
||||
|
||||
rendered = SHORTCODE_RE.sub(replace, markdown)
|
||||
return rendered.split("\n")
|
||||
|
||||
|
||||
class SchematicFootprintViewerExtension(Extension):
|
||||
"""Python-Markdown extension used by Zensical."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.config = {
|
||||
"default_json": ["merged.json", "Default JSON path when shortcode has no json/src attribute."],
|
||||
"width": ["15rem", "Viewer box width."],
|
||||
"height": ["13rem", "Viewer box height."],
|
||||
"konva_url": ["https://unpkg.com/konva@9/konva.min.js", "Konva JavaScript URL."],
|
||||
}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def extendMarkdown(self, md: Markdown) -> None:
|
||||
md.preprocessors.register(
|
||||
SchematicFootprintPreprocessor(
|
||||
md,
|
||||
default_json=str(self.getConfig("default_json")),
|
||||
width=str(self.getConfig("width")),
|
||||
height=str(self.getConfig("height")),
|
||||
konva_url=str(self.getConfig("konva_url")),
|
||||
),
|
||||
"schematic_footprint_viewer",
|
||||
35,
|
||||
)
|
||||
|
||||
|
||||
def makeExtension(**kwargs) -> SchematicFootprintViewerExtension:
|
||||
"""Entry point used by Python-Markdown and Zensical."""
|
||||
|
||||
return SchematicFootprintViewerExtension(**kwargs)
|
||||
@@ -0,0 +1,53 @@
|
||||
from markdown import Markdown
|
||||
|
||||
from schematic_footprint_viewer import makeExtension
|
||||
from schematic_footprint_viewer.extension import ViewerOptions, render_viewer
|
||||
|
||||
|
||||
def test_render_viewer_outputs_two_canvas_boxes():
|
||||
html = render_viewer(ViewerOptions(json_path="files/demo.json"))
|
||||
|
||||
assert 'class="schematic-footprint-viewer"' in html
|
||||
assert 'data-sfv-json="files/demo.json"' in html
|
||||
assert "data-sfv-pcb-canvas" in html
|
||||
assert "data-sfv-sch-canvas" in html
|
||||
assert "sfv-main" not in html
|
||||
|
||||
|
||||
def test_extension_replaces_shortcode_with_json_attribute():
|
||||
md = Markdown(extensions=[makeExtension()])
|
||||
|
||||
rendered = md.convert(
|
||||
'Before\n\n{% schematic-footprint json="files/VL53L0X_merged.json" %}\n\nAfter',
|
||||
)
|
||||
|
||||
assert "{% schematic-footprint" not in rendered
|
||||
assert "Before" in rendered
|
||||
assert "After" in rendered
|
||||
assert 'data-sfv-json="files/VL53L0X_merged.json"' in rendered
|
||||
|
||||
|
||||
def test_extension_accepts_src_alias_and_dimensions():
|
||||
md = Markdown(extensions=[makeExtension()])
|
||||
|
||||
rendered = md.convert(
|
||||
'{% schematic-footprint src="merged.json" width="24rem" height="20rem" %}',
|
||||
)
|
||||
|
||||
assert 'data-sfv-json="merged.json"' in rendered
|
||||
assert "--sfv-box-w:24rem" in rendered
|
||||
assert "--sfv-box-h:20rem" in rendered
|
||||
|
||||
|
||||
def test_extension_uses_default_json_when_no_attribute_is_given():
|
||||
md = Markdown(extensions=[makeExtension(default_json="default.json")])
|
||||
rendered = md.convert("{% schematic-footprint %}")
|
||||
|
||||
assert 'data-sfv-json="default.json"' in rendered
|
||||
|
||||
|
||||
def test_extension_is_loadable_by_module_name():
|
||||
md = Markdown(extensions=["schematic_footprint_viewer"])
|
||||
rendered = md.convert('{% schematic-footprint json="module.json" %}')
|
||||
|
||||
assert 'data-sfv-json="module.json"' in rendered
|
||||
Reference in New Issue
Block a user