keyboard_layouter/kle_to_svg.py

273 lines
9.8 KiB
Python
Raw Permalink Normal View History

2026-07-02 10:19:24 +00:00
#!/usr/bin/env python3
"""Convert a KLE (keyboard-layout-editor.com) JSON file to a flat SVG
suitable for keycap manufacturing single-colour rectangles, no 3-D shading."""
import json
import html
import sys
import math
# ── layout constants (mm) ──────────────────────────────────────────────
KEY_PITCH = 19.05 # centre-to-centre spacing
CAP_INSET = 0.475 # gap between adjacent caps (each side)
CORNER_R = 1.0 # rounded-corner radius on each cap
PAD = 1.5 # text padding inside cap edge
MARGIN = 3.0 # margin around the whole board
STROKE_W = 0.25 # thin border on each cap for definition
FONT = "'Roboto Condensed', 'Roboto', Arial Narrow, Arial, Helvetica, sans-serif"
FONT_STRETCH = "condensed" # 75% width axis
# KLE font-size index (1-9) → mm height. 0 means "use default".
# KLE uses fontSize_px = 6 + 2*size on a ~48 px key; scale to 18.1 mm cap
FS_MM = {0: 4.5, 1: 3.0, 2: 3.8, 3: 4.5, 4: 5.3,
5: 6.0, 6: 6.8, 7: 7.5, 8: 8.3, 9: 9.0}
# Label-index → (column 0-2, row 0-3) in the 3×4 keycap label grid.
# col: 0=left 1=centre 2=right
# row: 0=top 1=middle 2=bottom 3=front-face (skipped in top-view)
LABEL_POS = {
0: (0, 0), 1: (0, 2), 2: (2, 0), 3: (2, 2),
4: (0, 3), 5: (2, 3), 6: (0, 1), 7: (2, 1),
8: (1, 0), 9: (1, 1), 10: (1, 2), 11: (1, 3),
}
# ── KLE JSON parser ────────────────────────────────────────────────────
def parse_kle(path):
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
meta, keys = {}, []
# sticky state
c, t, a, f = "#cccccc", "#000000", 0, 3
fa = []
cy = -1
for item in data:
if isinstance(item, dict):
meta = item
continue
if not isinstance(item, list):
continue
cy += 1
cx = 0.0
nw, nh, nx, ny = 1, 1, 0, 0
for el in item:
if isinstance(el, dict):
c = el.get("c", c)
t = el.get("t", t)
a = el.get("a", a)
if "f" in el:
f = el["f"]; fa = []
if "fa" in el:
fa = list(el["fa"])
nw = el.get("w", nw)
nh = el.get("h", nh)
nx = el.get("x", nx)
ny = el.get("y", ny)
elif isinstance(el, str):
cx += nx
cy += ny
labels = [html.unescape(s) for s in el.split("\n")]
keys.append(dict(
x=cx, y=cy, w=nw, h=nh,
c=c, t=t, a=a, f=f, fa=list(fa),
labels=labels,
))
cx += nw
nw, nh, nx, ny = 1, 1, 0, 0
return meta, keys
# ── helpers ─────────────────────────────────────────────────────────────
def _xml(text):
return (text.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
def _font_mm(key, idx):
v = key["fa"][idx] if idx < len(key["fa"]) else 0
return FS_MM.get(v if v > 0 else key["f"], FS_MM[0])
def _wrap(text, avail_mm, fmm):
"""Break *text* into lines that roughly fit *avail_mm* at *fmm* size."""
char_w = fmm * 0.60
if len(text) * char_w <= avail_mm:
return [text]
max_chars = max(int(avail_mm / char_w), 1)
words, lines, cur = text.split(" "), [], ""
for w in words:
trial = f"{cur} {w}" if cur else w
if len(trial) <= max_chars or not cur:
cur = trial
else:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines
def _darken(hex_color, factor=0.15):
"""Return a slightly darker version of *hex_color* for the key border."""
h = hex_color.lstrip("#")
if len(h) != 6:
return "#333333"
r, g, b = (int(h[i:i+2], 16) for i in (0, 2, 4))
r = int(r * (1 - factor))
g = int(g * (1 - factor))
b = int(b * (1 - factor))
return f"#{r:02x}{g:02x}{b:02x}"
# ── SVG builder ─────────────────────────────────────────────────────────
def build_svg(meta, keys):
max_x = max((k["x"] + k["w"]) * KEY_PITCH for k in keys)
max_y = max((k["y"] + k["h"]) * KEY_PITCH for k in keys)
W = max_x + 2 * MARGIN
H = max_y + 2 * MARGIN
bg = meta.get("backcolor", "#333333")
name = meta.get("name", "keyboard")
out = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<!-- {_xml(name)} flat manufacturing SVG -->',
f'<svg xmlns="http://www.w3.org/2000/svg"',
f' viewBox="0 0 {W:.2f} {H:.2f}"',
f' width="{W:.2f}mm" height="{H:.2f}mm">',
' <defs><style>',
" @font-face {",
" font-family: 'Roboto Condensed';",
" font-style: normal;",
" font-weight: 400;",
" font-stretch: 75%;",
" src: url('https://fonts.gstatic.com/s/roboto/v51/"
"KFOMCnqEu92Fr1ME7kSn66aGLdTylR4MQXC89YmC2DPNWubEbVmUiAo"
".woff2') format('woff2');",
" }",
" @font-face {",
" font-family: 'Roboto Condensed';",
" font-style: normal;",
" font-weight: 700;",
" font-stretch: 75%;",
" src: url('https://fonts.gstatic.com/s/roboto/v51/"
"KFOMCnqEu92Fr1ME7kSn66aGLdTylR4MQXC89YmC2DPNWubEbVmUiAo"
".woff2') format('woff2');",
" }",
' </style></defs>',
f' <rect width="{W:.2f}" height="{H:.2f}" fill="{bg}"/>',
f' <g transform="translate({MARGIN},{MARGIN})"'
f' font-family="{FONT}" font-stretch="{FONT_STRETCH}">',
]
for k in keys:
kx = k["x"] * KEY_PITCH + CAP_INSET
ky = k["y"] * KEY_PITCH + CAP_INSET
kw = k["w"] * KEY_PITCH - 2 * CAP_INSET
kh = k["h"] * KEY_PITCH - 2 * CAP_INSET
border = _darken(k["c"], 0.20)
out.append(
f' <rect x="{kx:.2f}" y="{ky:.2f}" '
f'width="{kw:.2f}" height="{kh:.2f}" '
f'rx="{CORNER_R}" ry="{CORNER_R}" '
f'fill="{k["c"]}" stroke="{border}" stroke-width="{STROKE_W}"/>'
)
non_empty = [(i, l) for i, l in enumerate(k["labels"])
if l.strip() and i < 12]
if not non_empty:
continue
centred = (k["a"] == 7)
avail_w = kw - 2 * PAD
# single centred label — most common for function keys
if centred and len(non_empty) == 1:
idx, lab = non_empty[0]
fmm = _font_mm(k, idx)
lines = _wrap(lab, avail_w, fmm)
total_h = len(lines) * fmm * 1.25
start_y = ky + (kh - total_h) / 2 + fmm * 0.9
cx = kx + kw / 2
for li, ln in enumerate(lines):
ty = start_y + li * fmm * 1.25
out.append(
f' <text x="{cx:.2f}" y="{ty:.2f}" '
f'font-size="{fmm:.1f}" fill="{k["t"]}" '
f'text-anchor="middle" font-weight="bold">{_xml(ln)}</text>'
)
continue
# multiple labels at indexed positions
for idx, lab in non_empty:
col, row = LABEL_POS[idx]
if row == 3:
continue # skip front-face labels
fmm = _font_mm(k, idx)
if centred:
tx, anc = kx + kw / 2, "middle"
elif col == 0:
tx, anc = kx + PAD, "start"
elif col == 1:
tx, anc = kx + kw / 2, "middle"
else:
tx, anc = kx + kw - PAD, "end"
if row == 0:
ty = ky + PAD + fmm * 0.85
elif row == 1:
ty = ky + kh / 2 + fmm * 0.35
else:
ty = ky + kh - PAD
lines = _wrap(lab, avail_w, fmm)
if len(lines) == 1:
out.append(
f' <text x="{tx:.2f}" y="{ty:.2f}" '
f'font-size="{fmm:.1f}" fill="{k["t"]}" '
f'text-anchor="{anc}" font-weight="bold">{_xml(lines[0])}</text>'
)
else:
out.append(
f' <text x="{tx:.2f}" y="{ty:.2f}" '
f'font-size="{fmm:.1f}" fill="{k["t"]}" '
f'text-anchor="{anc}" font-weight="bold">'
)
for li, ln in enumerate(lines):
dy = 0 if li == 0 else fmm * 1.25
out.append(
f' <tspan x="{tx:.2f}" dy="{dy:.1f}">'
f'{_xml(ln)}</tspan>'
)
out.append(' </text>')
out.append(' </g>')
out.append('</svg>')
return "\n".join(out)
# ── main ────────────────────────────────────────────────────────────────
def main():
src = sys.argv[1] if len(sys.argv) > 1 else "rdt-v1.json"
dst = sys.argv[2] if len(sys.argv) > 2 else src.replace(".json", ".svg")
meta, keys = parse_kle(src)
svg = build_svg(meta, keys)
with open(dst, "w", encoding="utf-8") as fh:
fh.write(svg)
print(f"Done: {dst}")
print(f" {len(keys)} keys, "
f"{max((k['x']+k['w'])*KEY_PITCH for k in keys):.1f} × "
f"{max((k['y']+k['h'])*KEY_PITCH for k in keys):.1f} mm layout")
if __name__ == "__main__":
main()