#3 SVG path mode — second composer tab

Editor card now has two tabs: "Block text" and "SVG paths". Picking
an .svg file extracts every <path d="..."/> via DOMParser, hands the
d-strings to WASM, which builds sh1e.SvgPath entries (one per path)
anchored at (innerMargin, innerMargin) at 100% scale. Live preview
renders them on the plate via native browser <path> support — same
stroke styling as the engraving font so the visual is consistent.

JS side (web/composer/app.js):
- onSVGFile(): reads file, runs DOMParser on the text, harvests
  every <path>'s `d` attribute, errors out clearly if no paths
  found ("flatten shapes to paths in your editor first")
- mode state ("text" | "svg") switches which encoder/preview is
  called from refresh(), showQR(), showBytes()
- setMode() flips the active tab + the visible editor pane
- a summary line shows filename, path count, total chars

Go side stays as-is (composerEncodeSVG / composerPreviewSVG /
composerQRSVG were wired in the previous commit). Plate chrome
rendering shared between text- and svg-preview via writePlateChrome
so the two modes draw the same plate outline + margin guides.

Limitations (acceptable for MVP, documented in the tab help text):
- Only flat <path d="..."> elements are recognised; <rect>,
  <circle>, <use>, transforms, and nested <svg> are ignored. Users
  should flatten in their editor (Inkscape: Object → Path, then
  Flatten Transforms).
- Strict-subset path syntax check (M/L/H/V/Q/C/Z only — no arcs
  per the SH1E spec) is not enforced composer-side yet. The Pi
  parser will reject non-subset paths. Composer-side validation
  is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:23:05 +10:00
parent 50b5301545
commit f4a61f6cf0
3 changed files with 151 additions and 34 deletions

View File

@ -424,3 +424,14 @@ main > * { min-width: 0; }
border-radius: 10px;
cursor: pointer;
}
/* Editor tabs */
.editor-card .tabs { margin-bottom: 12px; }
.editor-pane[hidden] { display: none; }
.svg-summary {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.file-pick { margin: 8px 0 0; }

View File

@ -20,6 +20,12 @@ const els = {
qrCanvas: document.getElementById("qr-canvas"),
qrInfo: document.getElementById("qr-info"),
qrClose: document.getElementById("qr-close"),
tabs: document.querySelectorAll(".tab"),
editorText: document.getElementById("editor-text"),
editorSVG: document.getElementById("editor-svg"),
svgFile: document.getElementById("svg-file"),
svgSummary: document.getElementById("svg-summary"),
svgError: document.getElementById("svg-error"),
};
// Build inputs for the largest possible plate; hide rows that don't fit
@ -30,6 +36,8 @@ let wasmReady = false;
let plateType = 0;
let plateInfo = []; // populated from Go: [{id, name, w_mm, h_mm, max_lines}]
let visibleLines = MAX_LINES_ANY_PLATE;
let mode = "text"; // "text" | "svg"
let svgPaths = []; // d-strings extracted from the uploaded SVG, if any
function setStatus(text, error = false) {
els.status.textContent = text;
@ -105,34 +113,46 @@ function scheduleRefresh() {
function refresh() {
refreshTimer = null;
if (!wasmReady) return;
if (mode === "svg") {
refreshSVGMode();
} else {
refreshTextMode();
}
}
function refreshTextMode() {
const lines = readLines();
// Empty: render an empty plate so the geometry still shows.
let bytes = null;
if (lines.length > 0) {
try {
bytes = globalThis.composerEncodeText(plateType, lines);
} catch (e) {
// Show the error in the size meter; preview still renders structurally.
els.size.textContent = `error: ${e?.message ?? e}`;
els.size.classList.add("error");
els.preview.innerHTML = globalThis.composerPreviewText(plateType, lines);
return;
}
}
let svg;
try {
svg = globalThis.composerPreviewText(plateType, lines);
} catch (e) {
els.preview.textContent = `preview error: ${e?.message ?? e}`;
return;
}
els.preview.innerHTML = svg;
els.preview.innerHTML = globalThis.composerPreviewText(plateType, lines);
els.size.classList.remove("error");
if (bytes) {
els.size.textContent = `${bytes.length.toLocaleString("en-US")} B`;
} else {
els.size.textContent = "— B";
els.size.textContent = bytes ? `${bytes.length.toLocaleString("en-US")} B` : "— B";
}
function refreshSVGMode() {
let bytes = null;
if (svgPaths.length > 0) {
try {
bytes = globalThis.composerEncodeSVG(plateType, svgPaths);
} catch (e) {
els.size.textContent = `error: ${e?.message ?? e}`;
els.size.classList.add("error");
els.preview.innerHTML = globalThis.composerPreviewSVG(plateType, svgPaths);
return;
}
}
els.preview.innerHTML = globalThis.composerPreviewSVG(plateType, svgPaths);
els.size.classList.remove("error");
els.size.textContent = bytes ? `${bytes.length.toLocaleString("en-US")} B` : "— B";
}
// hexDump formats bytes in a compact 8-bytes-per-row layout. Total ~33
@ -161,15 +181,26 @@ function hexDump(bytes) {
function showBytes() {
if (!wasmReady) return;
const lines = readLines();
if (lines.length === 0) {
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = "Enter at least one line of text first.";
return;
}
let bytes;
try {
const bytes = globalThis.composerEncodeText(plateType, lines);
if (mode === "svg") {
if (svgPaths.length === 0) {
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = "Upload an SVG file first.";
return;
}
bytes = globalThis.composerEncodeSVG(plateType, svgPaths);
} else {
const lines = readLines();
if (lines.length === 0) {
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = "Enter at least one line of text first.";
return;
}
bytes = globalThis.composerEncodeText(plateType, lines);
}
els.output.hidden = false;
els.output.classList.remove("error");
els.output.textContent = `SH1E envelope — ${bytes.length} bytes\n\n${hexDump(bytes)}`;
@ -180,6 +211,44 @@ function showBytes() {
}
}
function setMode(newMode) {
if (newMode === mode) return;
mode = newMode;
els.tabs.forEach((t) => {
const isActive = t.dataset.mode === mode;
t.classList.toggle("active", isActive);
t.setAttribute("aria-selected", isActive ? "true" : "false");
});
els.editorText.hidden = mode !== "text";
els.editorSVG.hidden = mode !== "svg";
refresh();
}
async function onSVGFile(ev) {
const f = ev.target.files?.[0];
if (!f) return;
els.svgError.hidden = true;
try {
const text = await f.text();
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
if (doc.querySelector("parsererror")) throw new Error("file isn't valid SVG");
const ds = [...doc.querySelectorAll("path")]
.map((p) => p.getAttribute("d"))
.filter(Boolean);
if (ds.length === 0) {
throw new Error("no <path d=\"...\"> elements found — flatten shapes to paths in your editor first");
}
svgPaths = ds;
els.svgSummary.textContent = `${f.name}${ds.length} path${ds.length === 1 ? "" : "s"}, ${ds.reduce((n, d) => n + d.length, 0)} chars total`;
refresh();
} catch (e) {
els.svgError.hidden = false;
els.svgError.textContent = String(e?.message ?? e);
svgPaths = [];
refresh();
}
}
async function loadWasm() {
setStatus("Loading WASM…");
const go = new Go();
@ -210,18 +279,27 @@ async function loadWasm() {
function showQR() {
if (!wasmReady) return;
const lines = readLines();
if (lines.length === 0) {
setStatus("Enter at least one line first", true);
return;
}
let result;
try {
const result = globalThis.composerQR(plateType, lines);
if (mode === "svg") {
if (svgPaths.length === 0) {
setStatus("Upload an SVG file first", true);
return;
}
result = globalThis.composerQRSVG(plateType, svgPaths);
} else {
const lines = readLines();
if (lines.length === 0) {
setStatus("Enter at least one line first", true);
return;
}
result = globalThis.composerQR(plateType, lines);
}
els.qrCanvas.innerHTML = result.svg;
els.qrInfo.textContent = `${result.bytes} B SH1E → QR ${result.modules}×${result.modules}`;
els.qrOverlay.hidden = false;
els.qrOverlay.setAttribute("aria-hidden", "false");
setStatus(`Ready — v${composerVersionString()}`);
setStatus(`Ready — ${composerVersionString()}`);
} catch (e) {
setStatus(`QR encode failed: ${e?.message ?? e}`, true);
}
@ -242,6 +320,8 @@ function composerVersionString() {
els.btnBytes.addEventListener("click", showBytes);
els.btnQR.addEventListener("click", showQR);
els.qrClose.addEventListener("click", hideQR);
els.tabs.forEach((t) => t.addEventListener("click", () => setMode(t.dataset.mode)));
els.svgFile.addEventListener("change", onSVGFile);
els.qrOverlay.addEventListener("click", (e) => {
// Click outside the inner card dismisses; click inside (e.g. on the QR
// itself) does nothing.

View File

@ -43,12 +43,38 @@
</p>
</section>
<section class="card glass editor-card" aria-label="Block text editor">
<div class="editor-head">
<h2>Block text</h2>
<small>One text block per row. ASCII only. SVG paths come later.</small>
<section class="card glass editor-card" aria-label="Editor">
<div class="tabs" role="tablist">
<button class="tab active" data-mode="text" role="tab" aria-selected="true">Block text</button>
<button class="tab" data-mode="svg" role="tab" aria-selected="false">SVG paths</button>
</div>
<div id="editor-text" class="editor-pane" data-mode="text">
<div class="editor-head">
<h2>Block text</h2>
<small>One text block per row. ASCII only.</small>
</div>
<ol id="lines" class="lines"></ol>
</div>
<div id="editor-svg" class="editor-pane" data-mode="svg" hidden>
<div class="editor-head">
<h2>SVG paths</h2>
<small>
Upload an .svg file. Each <code>&lt;path d=&quot;...&quot;&gt;</code>
element becomes one engrave stroke. Pre-flatten shapes and
transforms in your editor (Inkscape: <em>Object → Path</em>
then <em>Flatten Transforms</em>) — the composer reads
<code>d</code> attributes directly.
</small>
</div>
<label class="file-pick">
<input type="file" id="svg-file" accept=".svg,image/svg+xml">
<span>Pick SVG file</span>
</label>
<div id="svg-summary" class="svg-summary"></div>
<pre id="svg-error" class="error" hidden></pre>
</div>
<ol id="lines" class="lines"></ol>
</section>
<section class="card glass actions-card" aria-label="Output">