273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
#!/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("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
|
||
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()
|