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()
|