mirror of
https://github.com/felixfoertsch/EurKEY-macOS.git
synced 2026-04-16 06:28:28 +02:00
- add scripts/generate_layout_pdf.py (requires fpdf2)
- generates ISO keyboard diagrams with Base, Shift, Option, Shift+Option
layers per key, plus dead key composition tables
- output: docs/eurkey-{version}-layout.pdf for v1.2, v1.3, v1.4, v2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
413 lines
10 KiB
Python
413 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate keyboard layout PDFs from .keylayout files.
|
|
|
|
Renders an ISO keyboard diagram showing Base, Shift, Option, and
|
|
Shift+Option 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, 2.0, "Backspace"),
|
|
],
|
|
# Row 1: QWERTY row
|
|
[
|
|
(None, 1.5, "Tab"), (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, "]"),
|
|
],
|
|
# Row 2: Home row
|
|
[
|
|
(None, 1.75, "Caps"), (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, "\\"), (None, 1.75, "Enter"),
|
|
],
|
|
# Row 3: Bottom row
|
|
[
|
|
(None, 1.25, "Shift"), (93, 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.75, "Shift"),
|
|
],
|
|
# Row 4: Modifier row
|
|
[
|
|
(None, 1.5, "Ctrl"), (None, 1.25, "⌥"), (None, 1.5, "⌘"),
|
|
(None, 6.0, ""), (None, 1.5, "⌘"), (None, 1.25, "⌥"),
|
|
(None, 1.5, "Ctrl"),
|
|
],
|
|
]
|
|
|
|
# Modifier layer indices
|
|
MOD_BASE = "0"
|
|
MOD_SHIFT = "1"
|
|
MOD_OPTION = "3"
|
|
MOD_SHIFT_OPTION = "4"
|
|
|
|
# Display layers: (modifier_index, color_rgb, position)
|
|
DISPLAY_LAYERS = [
|
|
(MOD_SHIFT, (0, 40, 170), "top_left"),
|
|
(MOD_SHIFT_OPTION, (120, 0, 120), "top_right"),
|
|
(MOD_BASE, (0, 0, 0), "bottom_left"),
|
|
(MOD_OPTION, (170, 0, 0), "bottom_right"),
|
|
]
|
|
|
|
# Layout dimensions (mm)
|
|
KU = 16 # key unit size
|
|
KEY_GAP = 1 # gap between keys
|
|
MARGIN = 12 # page margin
|
|
|
|
# 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 candidates (in priority order)
|
|
FONT_PATHS = [
|
|
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
|
"/System/Library/Fonts/SFNS.ttf",
|
|
"/System/Library/Fonts/Supplemental/Tahoma.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
]
|
|
|
|
|
|
def find_font():
|
|
"""Find a system font with good Unicode coverage."""
|
|
for path in FONT_PATHS:
|
|
if Path(path).exists():
|
|
return path
|
|
return None
|
|
|
|
|
|
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, font_path=None):
|
|
super().__init__(orientation="L", unit="mm", format="A4")
|
|
self.layout_name = layout_name
|
|
self.set_auto_page_break(auto=False)
|
|
self._has_unicode = False
|
|
|
|
if font_path:
|
|
try:
|
|
self.add_font("Layout", "", font_path)
|
|
self._has_unicode = True
|
|
except Exception as e:
|
|
print(f" Warning: could not load font {font_path}: {e}")
|
|
|
|
def _font(self, size, bold=False):
|
|
if self._has_unicode:
|
|
self.set_font("Layout", "", size)
|
|
else:
|
|
self.set_font("Helvetica", "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(13, 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 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 = 16
|
|
x = MARGIN
|
|
self._font(5)
|
|
items = [
|
|
((0, 0, 0), "Base"),
|
|
((0, 40, 170), "Shift"),
|
|
((170, 0, 0), "Option"),
|
|
((120, 0, 120), "Shift+Option"),
|
|
]
|
|
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")
|
|
|
|
# show physical label for modifier keys
|
|
if label and key_code is None:
|
|
self._font(5)
|
|
self._color((130, 130, 130))
|
|
self.set_xy(x, y + h / 2 - 2)
|
|
self.cell(w, 4, label, align="C")
|
|
|
|
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(8)
|
|
self.set_xy(x + pad, mid_y - 0.5)
|
|
self.cell(w / 2 - pad, h / 2, char)
|
|
elif pos == "top_left":
|
|
self._font(5.5)
|
|
self.set_xy(x + pad, y + pad)
|
|
self.cell(w / 2 - pad, 5, char)
|
|
elif pos == "bottom_right":
|
|
self._font(5.5)
|
|
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(5.5)
|
|
self.set_xy(mid_x, y + pad)
|
|
self.cell(w / 2 - pad, 5, char, align="R")
|
|
|
|
def _draw_dead_key_pages(self, data):
|
|
"""Draw dead key composition tables on new pages."""
|
|
dead_keys = data.get("deadKeys", {})
|
|
if not dead_keys:
|
|
return
|
|
|
|
actions = data.get("actions", {})
|
|
|
|
self.add_page()
|
|
self._font(12, bold=True)
|
|
self._color((0, 0, 0))
|
|
self.set_xy(MARGIN, 8)
|
|
self.cell(0, 7, f"{self.layout_name} — Dead Key Compositions")
|
|
|
|
y = 20
|
|
col_w = 12 # width per composition entry
|
|
max_cols = int((self.w - 2 * MARGIN) / col_w)
|
|
|
|
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 readable composition list: (base_char, composed_char)
|
|
pairs = []
|
|
for action_id, composed in sorted(compositions.items()):
|
|
base = actions.get(action_id, {}).get("none", action_id)
|
|
base = safe_char(base)
|
|
composed = safe_char(composed)
|
|
if base and composed:
|
|
pairs.append((base, composed))
|
|
|
|
# estimate space needed
|
|
num_rows = (len(pairs) + max_cols - 1) // max_cols
|
|
needed = 10 + num_rows * 4.5
|
|
|
|
if y + needed > self.h - 10:
|
|
self.add_page()
|
|
y = 12
|
|
|
|
# header
|
|
self._font(7, bold=True)
|
|
self._color((0, 0, 0))
|
|
self.set_xy(MARGIN, y)
|
|
display = state_name
|
|
self.cell(0, 5, f"{display} (terminator: {safe_char(terminator)})")
|
|
y += 6
|
|
|
|
# composition grid
|
|
col = 0
|
|
for base, composed in pairs:
|
|
cx = MARGIN + col * col_w
|
|
self._font(5.5)
|
|
self._color((100, 100, 100))
|
|
self.set_xy(cx, y)
|
|
self.cell(5, 4, base)
|
|
self._color((0, 0, 0))
|
|
self.set_xy(cx + 4, y)
|
|
self.cell(7, 4, f"→{composed}")
|
|
|
|
col += 1
|
|
if col >= max_cols:
|
|
col = 0
|
|
y += 4.5
|
|
|
|
if col > 0:
|
|
y += 4.5
|
|
y += 3
|
|
|
|
if y > self.h - 15:
|
|
self.add_page()
|
|
y = 12
|
|
|
|
|
|
def generate_pdf(version, output_dir):
|
|
"""Generate a PDF for the given layout version."""
|
|
bundle_dir = (
|
|
Path(__file__).parent.parent
|
|
/ "EurKey-macOS.bundle"
|
|
/ "Contents"
|
|
/ "Resources"
|
|
)
|
|
keylayout = bundle_dir / f"EurKEY {version}.keylayout"
|
|
|
|
if not keylayout.exists():
|
|
print(f"ERROR: {keylayout} not found")
|
|
return False
|
|
|
|
print(f"Generating PDF for EurKEY {version}...")
|
|
data = parse_keylayout(str(keylayout))
|
|
|
|
font_path = find_font()
|
|
if font_path:
|
|
print(f" Using font: {Path(font_path).name}")
|
|
else:
|
|
print(" Warning: no Unicode font found, falling back to Helvetica (limited charset)")
|
|
|
|
pdf = LayoutPDF(f"EurKEY {version}", font_path)
|
|
pdf.generate(data)
|
|
|
|
out = Path(output_dir)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
output_path = out / f"eurkey-{version}-layout.pdf"
|
|
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=["v1.2", "v1.3", "v1.4", "v2.0"],
|
|
help="Layout versions to generate (default: all)",
|
|
)
|
|
parser.add_argument(
|
|
"--output", "-o", default="docs",
|
|
help="Output directory (default: docs/)",
|
|
)
|
|
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()
|