#!/usr/bin/env python3 """Generate keyboard layout PDFs from .keylayout files. Renders an ISO keyboard diagram showing Base, Shift, Option, and Option+Shift layers on each key, plus dead key composition tables. Requires: fpdf2 (pip install fpdf2) Usage: python3 scripts/generate_layout_pdf.py # all versions python3 scripts/generate_layout_pdf.py -v v1.3 # specific version python3 scripts/generate_layout_pdf.py -o docs/ # custom output dir """ import argparse import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from parse_keylayout import parse_keylayout, TYPING_KEY_CODES try: from fpdf import FPDF except ImportError: print("ERROR: fpdf2 required for PDF generation") print("Install with: pip install fpdf2") sys.exit(1) # --- Physical ISO keyboard layout --- # Each entry: (key_code, width_in_units, physical_label) # key_code = None for non-typing keys (modifiers, spacers) KEYBOARD_ROWS = [ # Row 0: Number row [ (10, 1.0, "§"), (18, 1.0, "1"), (19, 1.0, "2"), (20, 1.0, "3"), (21, 1.0, "4"), (23, 1.0, "5"), (22, 1.0, "6"), (26, 1.0, "7"), (28, 1.0, "8"), (25, 1.0, "9"), (29, 1.0, "0"), (27, 1.0, "-"), (24, 1.0, "="), (None, 1.5, "⌫"), ], # Row 1: QWERTY row (1.0u spacer at end for ISO enter) [ (None, 1.5, "⇥"), (12, 1.0, "Q"), (13, 1.0, "W"), (14, 1.0, "E"), (15, 1.0, "R"), (17, 1.0, "T"), (16, 1.0, "Y"), (32, 1.0, "U"), (34, 1.0, "I"), (31, 1.0, "O"), (35, 1.0, "P"), (33, 1.0, "["), (30, 1.0, "]"), ("spacer", 1.0, ""), ], # Row 2: Home row (0.75u enter spanning into row 1) [ (None, 1.75, "⇪"), (0, 1.0, "A"), (1, 1.0, "S"), (2, 1.0, "D"), (3, 1.0, "F"), (5, 1.0, "G"), (4, 1.0, "H"), (38, 1.0, "J"), (40, 1.0, "K"), (37, 1.0, "L"), (41, 1.0, ";"), (39, 1.0, "'"), (42, 1.0, "\\"), ("enter", 0.75, "⏎"), ], # Row 3: Bottom row [ (None, 1.25, "⇧"), (50, 1.0, "`"), (6, 1.0, "Z"), (7, 1.0, "X"), (8, 1.0, "C"), (9, 1.0, "V"), (11, 1.0, "B"), (45, 1.0, "N"), (46, 1.0, "M"), (43, 1.0, ","), (47, 1.0, "."), (44, 1.0, "/"), (None, 2.25, "⇧"), ], # Row 4: Modifier row [ (None, 1.0, "fn"), (None, 1.0, "⌃"), (None, 1.0, "⌥"), (None, 1.25, "⌘"), (None, 5.0, ""), (None, 1.25, "⌘"), (None, 1.0, "⌥"), ("arrows", 3.0, ""), ], ] # Modifier layer indices MOD_BASE = "0" MOD_SHIFT = "1" MOD_OPTION = "3" MOD_OPTION_SHIFT = "4" # Display layers: (modifier_index, color_rgb, position) DISPLAY_LAYERS = [ (MOD_SHIFT, (0, 40, 170), "top_left"), (MOD_OPTION_SHIFT, (120, 0, 120), "top_right"), (MOD_BASE, (0, 0, 0), "bottom_left"), (MOD_OPTION, (170, 0, 0), "bottom_right"), ] # Layout dimensions (mm) KU = 20 # key unit size KEY_GAP = 1 # gap between keys MARGIN = 10 # page margin # 15 keys × 20mm = 300mm + 2×10mm margins = 320mm → custom page width PAGE_W = 320 PAGE_H = 210 # A4 height # Colors (RGB tuples) C_KEY_BG = (242, 242, 242) C_KEY_BORDER = (190, 190, 190) C_DEAD_BG = (255, 238, 204) C_MOD_BG = (225, 225, 230) # Font paths (relative to project root) FONT_DIR = Path(__file__).parent.parent / "fonts" / "iosevka" FONT_REGULAR = FONT_DIR / "IosevkaFixed-Regular.ttf" FONT_BOLD = FONT_DIR / "IosevkaFixed-Bold.ttf" def safe_char(c): """Format a character for display, handling control chars and blanks.""" if not c: return "" if len(c) == 1: cp = ord(c) if cp < 0x20: return f"U+{cp:04X}" if cp == 0x7F: return "" if cp == 0xA0: return "NBSP" return c def get_key_info(data, mod_idx, code_str): """Get display character and dead-key status for a key.""" km = data["keyMaps"].get(mod_idx, {}).get("keys", {}) key_data = km.get(code_str, {}) dead_state = key_data.get("deadKey", "") if dead_state: terminator = data["deadKeys"].get(dead_state, {}).get("terminator", "") return safe_char(terminator), True return safe_char(key_data.get("output", "")), False class LayoutPDF(FPDF): def __init__(self, layout_name): super().__init__(orientation="L", unit="mm", format=(PAGE_H, PAGE_W)) self.layout_name = layout_name self.set_auto_page_break(auto=False) if not FONT_REGULAR.exists(): print(f"ERROR: font not found: {FONT_REGULAR}") print("Run: scripts/download-fonts.sh or see fonts/README.md") sys.exit(1) self.add_font("Iosevka", "", str(FONT_REGULAR)) if FONT_BOLD.exists(): self.add_font("Iosevka", "B", str(FONT_BOLD)) def _font(self, size, bold=False): self.set_font("Iosevka", "B" if bold else "", size) def _color(self, rgb): self.set_text_color(*rgb) def generate(self, data): """Generate the full PDF for a layout.""" self._draw_keyboard_page(data) self._draw_dead_key_pages(data) def _draw_keyboard_page(self, data): self.add_page() # title self._font(18, bold=True) self._color((0, 0, 0)) self.set_xy(MARGIN, 7) self.cell(0, 7, self.layout_name) # legend self._draw_legend() # keyboard kb_y = 24 for row_idx, row in enumerate(KEYBOARD_ROWS): x = MARGIN y = kb_y + row_idx * KU for key_code, width, label in row: kw = width * KU - KEY_GAP kh = KU - KEY_GAP if key_code == "spacer": # invisible spacer — just advance x pass elif key_code == "enter": # tall enter key spanning up into previous row enter_h = 2 * KU - KEY_GAP enter_y = y - KU self._draw_mod_key(x, enter_y, kw, enter_h, label, key_code) elif key_code == "arrows": self._draw_arrow_cluster(x, y, kw, kh) elif key_code is None or key_code not in TYPING_KEY_CODES: self._draw_mod_key(x, y, kw, kh, label, key_code) else: self._draw_key(x, y, kw, kh, key_code, data) x += width * KU def _draw_legend(self): y = 17 x = MARGIN self._font(8) items = [ ((0, 0, 0), "Base"), ((0, 40, 170), "Shift"), ((170, 0, 0), "Option"), ((120, 0, 120), "Option+Shift"), ] for color, label in items: self._color(color) self.set_xy(x, y) self.cell(0, 4, f"■ {label}") x += 26 # dead key indicator self.set_fill_color(*C_DEAD_BG) self.set_draw_color(*C_KEY_BORDER) self.rect(x, y, 5, 3.5, "DF") self._color((0, 0, 0)) self.set_xy(x + 6, y) self.cell(0, 4, "Dead key") def _draw_mod_key(self, x, y, w, h, label, key_code): """Draw a modifier/non-typing key.""" self.set_fill_color(*C_MOD_BG) self.set_draw_color(*C_KEY_BORDER) self.rect(x, y, w, h, "DF") if label: self._font(14, bold=True) self._color((130, 130, 130)) self.set_xy(x, y + h / 2 - 3) self.cell(w, 6, label, align="C") def _draw_arrow_cluster(self, x, y, w, h): """Draw inverted-T arrow keys.""" arrow_w = w / 3 - KEY_GAP * 0.67 arrow_h = h / 2 - KEY_GAP * 0.5 arrows_top = [("", arrow_w), ("▲", arrow_w), ("", arrow_w)] arrows_bot = [("◀", arrow_w), ("▼", arrow_w), ("▶", arrow_w)] for row_arrows, ay in [(arrows_top, y), (arrows_bot, y + h / 2)]: ax = x for label, aw in row_arrows: if label: self.set_fill_color(*C_MOD_BG) self.set_draw_color(*C_KEY_BORDER) self.rect(ax, ay, aw, arrow_h, "DF") self._font(7) self._color((130, 130, 130)) self.set_xy(ax, ay + arrow_h / 2 - 2) self.cell(aw, 4, label, align="C") ax += aw + KEY_GAP def _draw_key(self, x, y, w, h, key_code, data): """Draw a typing key with 4 modifier layers.""" code_str = str(key_code) has_dead = False # check if any layer is a dead key for mod_idx, _, _ in DISPLAY_LAYERS: _, is_dead = get_key_info(data, mod_idx, code_str) if is_dead: has_dead = True break # background bg = C_DEAD_BG if has_dead else C_KEY_BG self.set_fill_color(*bg) self.set_draw_color(*C_KEY_BORDER) self.rect(x, y, w, h, "DF") pad = 1.2 mid_x = x + w / 2 mid_y = y + h / 2 for mod_idx, color, pos in DISPLAY_LAYERS: char, is_dead = get_key_info(data, mod_idx, code_str) if not char: continue self._color(color) if pos == "bottom_left": self._font(14) self.set_xy(x + pad, mid_y - 0.5) self.cell(w / 2 - pad, h / 2, char) elif pos == "top_left": self._font(11) self.set_xy(x + pad, y + pad) self.cell(w / 2 - pad, 5.5, char) elif pos == "bottom_right": self._font(11) self.set_xy(mid_x, mid_y - 0.5) self.cell(w / 2 - pad, h / 2, char, align="R") elif pos == "top_right": self._font(11) self.set_xy(mid_x, y + pad) self.cell(w / 2 - pad, 5.5, char, align="R") # catchy names keyed by terminator character (stable across versions) DEAD_KEY_NAMES_BY_TERMINATOR = { "´": "The Acutes", "`": "The Graves", "^": "The Circumflexes", "~": "The Tildes", "¨": "The Umlauts", "ˇ": "The Háčeks", "¯": "The Macrons", "˚": "The Rings & Dots", "α": "The Greeks", "√": "The Mathematicians", "¬": "The Navigators", "©": "The Navigators", " ": "The Mathematicians", } def _find_dead_key_trigger(self, data, state_name): """Find which key combo triggers a dead key state.""" mod_names = { "0": "", "1": "⇧ ", "2": "⇪ ", "3": "⌥ ", "4": "⌥⇧ ", "5": "⇪⌥ ", } # map key codes to physical labels from KEYBOARD_ROWS code_labels = {} for row in KEYBOARD_ROWS: for key_code, _, label in row: if isinstance(key_code, int): code_labels[str(key_code)] = label for mod_idx, km in data["keyMaps"].items(): for kc, kd in km["keys"].items(): if kd.get("deadKey") == state_name: prefix = mod_names.get(mod_idx, f"mod{mod_idx} ") key_label = code_labels.get(kc, f"key{kc}") return f"{prefix}{key_label}" return state_name def _draw_dead_key_pages(self, data): """Draw dead key compositions as full keyboard layouts.""" dead_keys = data.get("deadKeys", {}) if not dead_keys: return actions = data.get("actions", {}) for state_name in sorted(dead_keys.keys()): dk = dead_keys[state_name] terminator = dk.get("terminator", "") compositions = dk.get("compositions", {}) if not compositions: continue # build char → composed lookup char_to_composed = {} for action_id, composed in compositions.items(): base = actions.get(action_id, {}).get("none", "") if base: char_to_composed[base] = composed # skip if no compositions mapped if not char_to_composed: continue trigger = self._find_dead_key_trigger(data, state_name) catchy = self.DEAD_KEY_NAMES_BY_TERMINATOR.get(terminator, state_name) self.add_page() # title self._font(18, bold=True) self._color((0, 0, 0)) self.set_xy(MARGIN, 7) self.cell(0, 7, f"{self.layout_name} — {catchy} ({trigger}, terminator: {safe_char(terminator)})") # draw keyboard with compositions kb_y = 24 for row_idx, row in enumerate(KEYBOARD_ROWS): x = MARGIN y = kb_y + row_idx * KU for key_code, width, label in row: kw = width * KU - KEY_GAP kh = KU - KEY_GAP if key_code == "spacer": pass elif key_code == "enter": enter_h = 2 * KU - KEY_GAP enter_y = y - KU self._draw_mod_key(x, enter_y, kw, enter_h, label, key_code) elif key_code == "arrows": self._draw_arrow_cluster(x, y, kw, kh) elif not isinstance(key_code, int) or key_code not in TYPING_KEY_CODES: self._draw_mod_key(x, y, kw, kh, label, key_code) else: self._draw_dead_composition_key( x, y, kw, kh, key_code, data, char_to_composed, ) x += width * KU def _draw_dead_composition_key(self, x, y, w, h, key_code, data, char_to_composed): """Draw a key showing dead key compositions for base and shift layers.""" code_str = str(key_code) km_base = data["keyMaps"].get(MOD_BASE, {}).get("keys", {}) km_shift = data["keyMaps"].get(MOD_SHIFT, {}).get("keys", {}) base_char = km_base.get(code_str, {}).get("output", "") shift_char = km_shift.get(code_str, {}).get("output", "") base_composed = char_to_composed.get(base_char, "") shift_composed = char_to_composed.get(shift_char, "") # background — highlight if any composition exists has_comp = bool(base_composed or shift_composed) bg = C_DEAD_BG if has_comp else C_KEY_BG self.set_fill_color(*bg) self.set_draw_color(*C_KEY_BORDER) self.rect(x, y, w, h, "DF") pad = 1.2 mid_y = y + h / 2 if base_composed: self._font(14) self._color((0, 0, 0)) self.set_xy(x + pad, mid_y - 0.5) self.cell(w / 2 - pad, h / 2, safe_char(base_composed)) elif base_char: self._font(14) self._color((200, 200, 200)) self.set_xy(x + pad, mid_y - 0.5) self.cell(w / 2 - pad, h / 2, safe_char(base_char)) if shift_composed: self._font(11) self._color((0, 40, 170)) self.set_xy(x + pad, y + pad) self.cell(w / 2 - pad, 5.5, safe_char(shift_composed)) elif shift_char: self._font(11) self._color((200, 200, 200)) self.set_xy(x + pad, y + pad) self.cell(w / 2 - pad, 5.5, safe_char(shift_char)) def generate_pdf(version, output_dir): """Generate a PDF for the given layout version.""" src_dir = Path(__file__).parent.parent / "src" / "keylayouts" keylayout = src_dir / f"EurKEY {version}.keylayout" display_name = f"EurKEY {version}" pdf_name = f"eurkey-{version.lower()}-layout.pdf" if not keylayout.exists(): print(f"ERROR: {keylayout} not found") return False print(f"Generating PDF for {display_name}...") data = parse_keylayout(str(keylayout), keyboard_type=0) pdf = LayoutPDF(display_name) pdf.generate(data) out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) output_path = out / pdf_name pdf.output(str(output_path)) print(f" Written: {output_path}") return True def main(): parser = argparse.ArgumentParser(description="Generate keyboard layout PDFs") parser.add_argument( "--version", "-v", nargs="*", default=["Next", "v1.4", "v1.3", "v1.2"], help="Layout versions to generate (default: all)", ) default_output = str(Path(__file__).parent.parent / "build") parser.add_argument( "--output", "-o", default=default_output, help="Output directory (default: build/)", ) args = parser.parse_args() success = True for version in args.version: if not generate_pdf(version, args.output): success = False sys.exit(0 if success else 1) if __name__ == "__main__": main()