#!/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 = [ '', f'', f'', ' ', f' ', f' ', ] 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' ' ) 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' {_xml(ln)}' ) 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' {_xml(lines[0])}' ) else: out.append( f' ' ) for li, ln in enumerate(lines): dy = 0 if li == 0 else fmm * 1.25 out.append( f' ' f'{_xml(ln)}' ) out.append(' ') out.append(' ') out.append('') 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()