keyboard_layouter/kle_to_svg.py

273 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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()