add interactive keyboard viewer, fix parser, move spec PDF

interactive layout viewer with version tabs, modifier key highlighting,
dead key compositions, ISO enter spanning two rows, arrow cluster.
fix keylayout parser mapSet range handling, update PDF build scripts,
move eurkey-layout-complete.pdf to spec/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:47:48 +01:00
parent a8f5cf4097
commit 18718e3424
9 changed files with 702 additions and 67 deletions

View File

@@ -35,6 +35,13 @@ jobs:
run: | run: |
pip install fpdf2 pip install fpdf2
python3 scripts/generate_layout_pdf.py -o eurkey-macos.eu/pdf/ python3 scripts/generate_layout_pdf.py -o eurkey-macos.eu/pdf/
- name: Generate layout JSON
run: |
mkdir -p eurkey-macos.eu/data
for ver in v1.2 v1.3 v1.4 v2.0; do
python3 scripts/parse_keylayout.py "src/keylayouts/EurKEY ${ver}.keylayout" \
-o "eurkey-macos.eu/data/eurkey-${ver}.json"
done
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3 - uses: actions/upload-pages-artifact@v3
with: with:

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@ __pycache__/
build/ build/
*.dmg *.dmg
# Generated site assets
eurkey-macos.eu/data/
eurkey-macos.eu/pdf/
# macOS # macOS
.DS_Store .DS_Store
.AppleDouble .AppleDouble

View File

@@ -17,8 +17,8 @@
<span>EurKEY</span> <span>EurKEY</span>
</a> </a>
<div class="nav-links"> <div class="nav-links">
<a href="#features">Features</a>
<a href="#layout">Layout</a> <a href="#layout">Layout</a>
<a href="#features">Features</a>
<a href="#install">Install</a> <a href="#install">Install</a>
<a href="https://github.com/felixfoertsch/EurKEY-macOS" class="nav-github" aria-label="GitHub"> <a href="https://github.com/felixfoertsch/EurKEY-macOS" class="nav-github" aria-label="GitHub">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
@@ -37,6 +37,34 @@
<a href="https://github.com/felixfoertsch/EurKEY-macOS/releases" class="btn">Download on GitHub</a> <a href="https://github.com/felixfoertsch/EurKEY-macOS/releases" class="btn">Download on GitHub</a>
</section> </section>
<!-- LAYOUT PREVIEW -->
<section id="layout" class="layout-preview">
<div class="container">
<h2>Layout Preview</h2>
<p class="section-sub">The complete key map for each version, showing all modifier layers and dead key compositions.</p>
<div class="layout-controls">
<div class="version-tabs">
<button class="version-tab active" data-version="v2.0">v2.0</button>
<button class="version-tab" data-version="v1.4">v1.4</button>
<button class="version-tab" data-version="v1.3">v1.3</button>
<button class="version-tab" data-version="v1.2">v1.2</button>
</div>
</div>
<figure class="keyboard" id="keyboard"></figure>
<div class="dead-key-panel" id="dead-key-panel" hidden>
<button class="dead-key-close" id="dead-key-close" aria-label="Close">&times;</button>
<h4 id="dead-key-title"></h4>
<div class="dead-key-grid" id="dead-key-grid"></div>
</div>
<div class="layout-pdf-links">
<a href="pdf/eurkey-v1.3-layout.pdf">v1.3 PDF</a>
<a href="pdf/eurkey-v1.2-layout.pdf">v1.2 PDF</a>
<a href="pdf/eurkey-v1.4-layout.pdf">v1.4 PDF</a>
<a href="pdf/eurkey-v2.0-layout.pdf">v2.0 PDF</a>
</div>
</div>
</section>
<!-- FEATURES --> <!-- FEATURES -->
<section id="features" class="features"> <section id="features" class="features">
<div class="container"> <div class="container">
@@ -66,20 +94,6 @@
</div> </div>
</section> </section>
<!-- LAYOUT PREVIEW -->
<section id="layout" class="layout-preview">
<div class="container">
<h2>Layout Preview</h2>
<p class="section-sub">The complete key map for each version, showing all modifier layers and dead key compositions.</p>
<div class="layout-downloads">
<a href="pdf/eurkey-v1.3-layout.pdf" class="layout-link">v1.3 <span>PDF</span></a>
<a href="pdf/eurkey-v1.2-layout.pdf" class="layout-link">v1.2 <span>PDF</span></a>
<a href="pdf/eurkey-v1.4-layout.pdf" class="layout-link">v1.4 <span>PDF</span></a>
<a href="pdf/eurkey-v2.0-layout.pdf" class="layout-link">v2.0 <span>PDF</span></a>
</div>
</div>
</section>
<!-- INSTALLATION --> <!-- INSTALLATION -->
<section id="install" class="install"> <section id="install" class="install">
<div class="container"> <div class="container">
@@ -136,5 +150,6 @@
</div> </div>
</footer> </footer>
<script src="keyboard.js"></script>
</body> </body>
</html> </html>

302
eurkey-macos.eu/keyboard.js Normal file
View File

@@ -0,0 +1,302 @@
/* EurKEY interactive keyboard viewer */
const KEYBOARD_ROWS = [
[
[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, "="], [null, 1.5, "⌫"],
],
[
[null, 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, ""],
],
[
[null, 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, "⏎"],
],
[
[null, 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, "/"],
[null, 2.25, "⇧"],
],
[
[null, 1.0, "fn"], [null, 1.0, "⌃"], [null, 1.0, "⌥"], [null, 1.25, "⌘"],
[null, 5.0, ""], [null, 1.25, "⌘"], [null, 1.0, "⌥"],
["arrow-cluster", 3.0, ""],
],
];
const MOD_BASE = "0";
const MOD_SHIFT = "1";
const MOD_OPTION = "3";
const MOD_SHIFT_OPTION = "4";
const LAYERS = [
{ mod: MOD_SHIFT, cls: "key-char--shift" },
{ mod: MOD_SHIFT_OPTION, cls: "key-char--shift-option" },
{ mod: MOD_BASE, cls: "key-char--base" },
{ mod: MOD_OPTION, cls: "key-char--option" },
];
const MOD_LABELS = { "\u21e7": "shift", "\u2325": "option" };
const cache = new Map();
let currentVersion = "v2.0";
let currentData = null;
async function loadVersion(version) {
if (cache.has(version)) return cache.get(version);
const resp = await fetch("data/eurkey-" + version + ".json");
if (!resp.ok) throw new Error("Failed to load " + version);
const data = await resp.json();
cache.set(version, data);
return data;
}
function charForKey(data, modIndex, keyCode) {
const keyMap = data.keyMaps[modIndex];
if (!keyMap) return null;
const key = keyMap.keys[String(keyCode)];
if (!key) return null;
if (key.deadKey) {
const terminator = data.terminators[key.deadKey] || data.deadKeys[key.deadKey]?.terminator;
return { char: terminator || "\u25c6", deadKey: key.deadKey };
}
return { char: key.output || "" };
}
function displayChar(ch) {
if (!ch || ch === " ") return "\u00a0";
if (ch === "\u00a0") return "\u237d";
return ch;
}
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function renderKeyboard(data) {
const kb = document.getElementById("keyboard");
clearElement(kb);
for (const row of KEYBOARD_ROWS) {
const rowEl = document.createElement("div");
rowEl.className = "keyboard-row";
for (const [keyCode, width, label] of row) {
const keyEl = document.createElement("div");
keyEl.className = "key";
keyEl.style.setProperty("--w", width);
if (keyCode === "arrow-cluster") {
keyEl.classList.add("key--arrow-cluster");
function makeArrowKey(sym) {
const ak = document.createElement("div");
ak.className = "arrow-key key--mod";
const lbl = document.createElement("span");
lbl.className = "key-mod-label";
lbl.textContent = sym;
ak.appendChild(lbl);
return ak;
}
const topRow = document.createElement("div");
topRow.className = "arrow-row";
const spacerL = document.createElement("div");
spacerL.className = "arrow-key arrow-key--spacer";
const spacerR = document.createElement("div");
spacerR.className = "arrow-key arrow-key--spacer";
topRow.append(spacerL, makeArrowKey("▲"), spacerR);
const bottomRow = document.createElement("div");
bottomRow.className = "arrow-row";
for (const sym of ["◀", "▼", "▶"]) {
bottomRow.appendChild(makeArrowKey(sym));
}
keyEl.append(topRow, bottomRow);
} else if (keyCode === "spacer") {
keyEl.classList.add("key--spacer");
} else if (keyCode === "enter") {
keyEl.classList.add("key--mod", "key--enter");
const span = document.createElement("span");
span.className = "key-mod-label";
span.textContent = label;
keyEl.appendChild(span);
} else if (keyCode === null) {
keyEl.classList.add("key--mod");
if (MOD_LABELS[label]) keyEl.dataset.mod = MOD_LABELS[label];
const span = document.createElement("span");
span.className = "key-mod-label";
span.textContent = label;
keyEl.appendChild(span);
} else {
let hasDead = false;
let deadState = null;
for (const layer of LAYERS) {
const info = charForKey(data, layer.mod, keyCode);
const span = document.createElement("span");
span.className = "key-char " + layer.cls;
if (info) {
span.textContent = displayChar(info.char);
if (info.deadKey) {
hasDead = true;
deadState = info.deadKey;
span.classList.add("key-char--is-dead");
}
}
keyEl.appendChild(span);
}
if (hasDead) {
keyEl.classList.add("key--dead");
keyEl.dataset.deadKey = deadState;
keyEl.addEventListener("click", () => showDeadKeyPanel(data, deadState));
}
}
rowEl.appendChild(keyEl);
}
kb.appendChild(rowEl);
}
}
function showDeadKeyPanel(data, state) {
const panel = document.getElementById("dead-key-panel");
const title = document.getElementById("dead-key-title");
const grid = document.getElementById("dead-key-grid");
const dk = data.deadKeys[state];
if (!dk) return;
const terminator = dk.terminator || data.terminators[state] || "";
title.textContent = state + " \u2192 " + displayChar(terminator);
clearElement(grid);
const compositions = dk.compositions;
if (!compositions) return;
for (const [actionId, composed] of Object.entries(compositions)) {
const action = data.actions[actionId];
const base = action?.none || actionId;
const pair = document.createElement("div");
pair.className = "dead-key-pair";
const baseSpan = document.createElement("span");
baseSpan.className = "dead-key-base";
baseSpan.textContent = displayChar(base);
const arrow = document.createElement("span");
arrow.className = "dead-key-arrow";
arrow.textContent = "\u2192";
const composedSpan = document.createElement("span");
composedSpan.className = "dead-key-composed";
composedSpan.textContent = displayChar(composed);
pair.appendChild(baseSpan);
pair.appendChild(arrow);
pair.appendChild(composedSpan);
grid.appendChild(pair);
}
panel.hidden = false;
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function showError(msg) {
const kb = document.getElementById("keyboard");
clearElement(kb);
const p = document.createElement("p");
p.className = "keyboard-error";
p.textContent = msg;
kb.appendChild(p);
}
/* --- Modifier key detection --- */
const activeModifiers = new Set();
function updateActiveLayer() {
const kb = document.getElementById("keyboard");
const shift = activeModifiers.has("shift");
const option = activeModifiers.has("option");
let layer = null;
if (shift && option) layer = "shift-option";
else if (shift) layer = "shift";
else if (option) layer = "option";
if (layer) {
kb.dataset.activeLayer = layer;
} else {
delete kb.dataset.activeLayer;
}
}
document.addEventListener("keydown", (e) => {
if (e.key === "Shift") activeModifiers.add("shift");
if (e.key === "Alt") activeModifiers.add("option");
updateActiveLayer();
});
document.addEventListener("keyup", (e) => {
if (e.key === "Shift") activeModifiers.delete("shift");
if (e.key === "Alt") activeModifiers.delete("option");
updateActiveLayer();
});
window.addEventListener("blur", () => {
activeModifiers.clear();
updateActiveLayer();
});
/* --- Version tabs --- */
function initTabs() {
const tabs = document.querySelectorAll(".version-tab");
tabs.forEach(tab => {
tab.addEventListener("click", async () => {
const version = tab.dataset.version;
if (version === currentVersion) return;
tabs.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
currentVersion = version;
document.getElementById("dead-key-panel").hidden = true;
try {
currentData = await loadVersion(version);
renderKeyboard(currentData);
} catch (e) {
console.error("Failed to load layout:", e);
showError("Failed to load layout data.");
}
});
});
}
/* --- Init --- */
document.getElementById("dead-key-close").addEventListener("click", () => {
document.getElementById("dead-key-panel").hidden = true;
});
initTabs();
loadVersion(currentVersion).then(data => {
currentData = data;
renderKeyboard(data);
}).catch(e => {
console.error("Failed to load initial layout:", e);
showError("Failed to load layout data.");
});

View File

@@ -207,40 +207,338 @@ img {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.layout-downloads { /* Version tabs */
.layout-controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 1rem; margin-bottom: 1.5rem;
flex-wrap: wrap;
} }
.layout-link { .version-tabs {
display: inline-flex; display: flex;
align-items: center; gap: 0.25rem;
gap: 0.5rem; background: #e0e4ed;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px; border-radius: 8px;
padding: 0.75rem 1.5rem; padding: 3px;
font-size: 1rem; }
.version-tab {
border: none;
background: transparent;
padding: 0.4rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: #5a6178;
text-decoration: none; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.layout-link span { .version-tab:hover {
font-size: 0.75rem; color: #1a1a2e;
font-weight: 500;
color: #5a6178;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.layout-link:hover { .version-tab.active {
border-color: #003399; background: #fff;
color: #1a1a2e;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Keyboard */
.keyboard {
--key-unit: 44px;
--key-gap: 6px;
--row-gap: 6px;
width: fit-content;
margin: 0 auto;
padding: 12px;
background: linear-gradient(to bottom, #dcdcdc, #d8d8d8 95%, #e2e2e2);
border: 1px solid #a0a0a0;
border-radius: 7px;
box-shadow:
0 1px 0 #8c8c8c,
0 2px 0 #969696,
0 4px 12px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
.keyboard-row {
display: flex;
gap: var(--key-gap);
}
.key {
width: calc(var(--w) * var(--key-unit) + (var(--w) - 1) * var(--key-gap));
height: var(--key-unit);
flex-shrink: 0;
background: linear-gradient(to bottom, #f9f9f9, #efefef 95%, #e2e2e2);
border-radius: 4px;
box-shadow:
0 1px 0 #fafafa,
0 2px 0 1px #828282,
0 2px 0 2px #464646;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
padding: 3px 5px;
font-size: 12px;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
cursor: default;
transition: transform 0.08s, box-shadow 0.08s;
}
.key:active {
transform: translateY(1px);
box-shadow:
0 0.5px 0 1px #828282,
0 0.5px 0 2px #464646;
}
/* Character positions */
.key-char {
display: flex;
align-items: center;
line-height: 1;
overflow: hidden;
white-space: nowrap;
}
.key-char--shift {
justify-content: flex-start;
color: #0028aa;
}
.key-char--shift-option {
justify-content: flex-end;
color: #780078;
}
.key-char--base {
justify-content: flex-start;
color: #000;
}
.key-char--option {
justify-content: flex-end;
color: #aa0000;
}
.key-char--is-dead {
font-weight: 700;
text-decoration: underline;
text-decoration-style: dotted;
}
/* Dead key highlight */
.key--dead {
background: linear-gradient(to bottom, #fef9e7, #fef3cd 95%, #f5e6a8);
cursor: pointer;
}
.key--dead:hover {
background: linear-gradient(to bottom, #fef3cd, #fce9a0 95%, #f0da88);
}
/* Modifier keys */
.key--mod {
background: linear-gradient(to bottom, #eeeeee, #e4e4e4 95%, #d8d8d8);
display: flex;
align-items: center;
justify-content: center;
}
.key-mod-label {
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #555;
font-weight: 500;
}
/* Invisible spacer (reserves space next to spanning enter key) */
.key--spacer {
background: none;
box-shadow: none;
pointer-events: none;
}
/* ISO Enter — single tall block in row 3, extending upward into row 2 */
.key--enter {
height: calc(2 * var(--key-unit) + var(--row-gap));
margin-top: calc(-1 * var(--key-unit) - var(--row-gap));
z-index: 1;
}
/* Arrow cluster — inverted T, all half-height */
.key--arrow-cluster {
display: flex;
flex-direction: column;
gap: 2px;
background: transparent;
box-shadow: none;
padding: 0;
}
.key--arrow-cluster:active {
transform: none;
box-shadow: none;
}
.arrow-row {
display: flex;
gap: var(--key-gap);
flex: 1;
}
.arrow-key {
flex: 1;
border-radius: 3px;
background: linear-gradient(to bottom, #eeeeee, #e4e4e4 95%, #d8d8d8);
box-shadow:
0 0.5px 0 #fafafa,
0 1px 0 1px #828282,
0 1px 0 2px #464646;
display: flex;
align-items: center;
justify-content: center;
}
.arrow-key:active {
transform: translateY(1px);
box-shadow:
0 0.5px 0 1px #828282,
0 0.5px 0 2px #464646;
}
.arrow-key .key-mod-label {
font-size: 9px;
}
.arrow-key--spacer {
visibility: hidden;
background: none;
box-shadow: none;
}
/* Active modifier layer — dim inactive characters, emphasize active */
.keyboard[data-active-layer] .key-char {
opacity: 0.15;
transition: opacity 0.1s;
}
.keyboard[data-active-layer="shift"] .key-char--shift,
.keyboard[data-active-layer="option"] .key-char--option,
.keyboard[data-active-layer="shift-option"] .key-char--shift-option {
opacity: 1;
font-weight: 700;
font-size: 15px;
}
/* Pressed modifier key visual */
.keyboard[data-active-layer="shift"] .key--mod[data-mod="shift"],
.keyboard[data-active-layer="option"] .key--mod[data-mod="option"],
.keyboard[data-active-layer="shift-option"] .key--mod[data-mod="shift"],
.keyboard[data-active-layer="shift-option"] .key--mod[data-mod="option"] {
transform: translateY(1px);
background: linear-gradient(to bottom, #d4d4d4, #ccc);
box-shadow:
0 0.5px 0 1px #828282,
0 0.5px 0 2px #464646;
}
/* Dead key panel */
.dead-key-panel {
max-width: fit-content;
margin: 1.5rem auto 0;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 1.25rem;
position: relative;
}
.dead-key-panel h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1a1a2e;
}
.dead-key-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
border: none;
background: none;
font-size: 1.25rem;
cursor: pointer;
color: #5a6178;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.dead-key-close:hover {
background: #f0f0f0;
color: #1a1a2e;
}
.dead-key-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.dead-key-pair {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
background: #f5f7fa;
border-radius: 4px;
padding: 0.375rem 0.5rem;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 0.875rem;
}
.dead-key-base {
color: #5a6178;
}
.dead-key-arrow {
color: #aaa;
font-size: 0.75rem;
}
.dead-key-composed {
color: #1a1a2e;
font-weight: 600;
}
/* PDF links */
.layout-pdf-links {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.layout-pdf-links a {
font-size: 0.8rem;
color: #5a6178;
}
.layout-pdf-links a:hover {
color: #003399; color: #003399;
text-decoration: none; }
/* Error */
.keyboard-error {
text-align: center;
padding: 2rem;
color: #aa0000;
} }
/* Installation */ /* Installation */
@@ -344,6 +642,13 @@ img {
} }
/* Responsive */ /* Responsive */
@media (max-width: 830px) {
.keyboard {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hero h1 { .hero h1 {
font-size: 1.75rem; font-size: 1.75rem;

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generate keyboard layout PDFs from .keylayout files. # Generate keyboard layout PDFs from .keylayout files.
# Requires: fpdf2 (pip install fpdf2)
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# auto-install Python dependencies
python3 -c "import fpdf" 2>/dev/null || pip3 install --quiet fpdf2
exec python3 "${SCRIPT_DIR}/generate_layout_pdf.py" "$@" exec python3 "${SCRIPT_DIR}/generate_layout_pdf.py" "$@"

View File

@@ -54,7 +54,7 @@ KEYBOARD_ROWS = [
], ],
# Row 3: Bottom row # Row 3: Bottom row
[ [
(None, 1.25, "Shift"), (93, 1.0, "§"), (6, 1.0, "Z"), (7, 1.0, "X"), (None, 1.25, "Shift"), (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"), (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, "/"), (46, 1.0, "M"), (43, 1.0, ","), (47, 1.0, "."), (44, 1.0, "/"),
(None, 2.75, "Shift"), (None, 2.75, "Shift"),
@@ -352,7 +352,7 @@ def generate_pdf(version, output_dir):
return False return False
print(f"Generating PDF for EurKEY {version}...") print(f"Generating PDF for EurKEY {version}...")
data = parse_keylayout(str(keylayout)) data = parse_keylayout(str(keylayout), keyboard_type=0)
pdf = LayoutPDF(f"EurKEY {version}") pdf = LayoutPDF(f"EurKEY {version}")
pdf.generate(data) pdf.generate(data)

View File

@@ -128,8 +128,13 @@ def _restore_control_chars(text):
return re.sub(r'__CTRL_U([0-9A-F]{4})__', restore, text) return re.sub(r'__CTRL_U([0-9A-F]{4})__', restore, text)
def parse_keylayout(filepath): def parse_keylayout(filepath, keyboard_type=0):
"""Parse a .keylayout XML file and return a structured dict.""" """Parse a .keylayout XML file and return a structured dict.
keyboard_type selects which mapSet to use. Each <layout> element
covers a range of hardware keyboard types (first..last). The mapSet
matching the requested type is used. Default 0 = MacBook built-in.
"""
xml_content = _read_keylayout_xml(filepath) xml_content = _read_keylayout_xml(filepath)
root = ET.fromstring(xml_content) root = ET.fromstring(xml_content)
@@ -160,32 +165,25 @@ def parse_keylayout(filepath):
terminators[state] = output terminators[state] = output
result["terminators"] = terminators result["terminators"] = terminators
# resolve layouts # find the mapSet for the requested keyboard type
layouts = root.findall(".//layout") target_map_set = None
for layout in root.findall(".//layout"):
first = int(layout.get("first", "0"))
last = int(layout.get("last", "0"))
if first <= keyboard_type <= last:
target_map_set = layout.get("mapSet")
break
# build resolved key maps from all layout entries if target_map_set is None:
# first pass: load ALL keys from each keyMapSet (base definitions) # fall back to first layout entry
# second pass: override with keys from layout entries that specify ranges first_layout = root.find(".//layout")
target_map_set = first_layout.get("mapSet") if first_layout is not None else None
# resolve keys from the selected mapSet
resolved = {} resolved = {}
seen_map_sets = set() kms = key_map_sets.get(target_map_set, {})
for layout in layouts:
map_set_id = layout.get("mapSet")
first_code = int(layout.get("first", "0"))
last_code = int(layout.get("last", "0"))
kms = key_map_sets.get(map_set_id, {})
for idx_str, keys in kms.items(): for idx_str, keys in kms.items():
if idx_str not in resolved: resolved[idx_str] = dict(keys)
resolved[idx_str] = {}
for code_str, entry in keys.items():
code = int(code_str)
if map_set_id not in seen_map_sets:
# first time seeing this mapSet: include all keys
resolved[idx_str][code_str] = entry
elif first_code <= code <= last_code:
# subsequent layout with same mapSet: only override in range
resolved[idx_str][code_str] = entry
seen_map_sets.add(map_set_id)
# build the final keyMaps output # build the final keyMaps output
key_maps = {} key_maps = {}
@@ -341,9 +339,11 @@ def main():
parser.add_argument("--output", "-o", help="Output JSON file path") parser.add_argument("--output", "-o", help="Output JSON file path")
parser.add_argument("--summary", "-s", action="store_true", parser.add_argument("--summary", "-s", action="store_true",
help="Print human-readable summary") help="Print human-readable summary")
parser.add_argument("--keyboard-type", "-k", type=int, default=0,
help="Hardware keyboard type ID (default: 0 = MacBook built-in)")
args = parser.parse_args() args = parser.parse_args()
data = parse_keylayout(args.keylayout) data = parse_keylayout(args.keylayout, keyboard_type=args.keyboard_type)
if args.summary: if args.summary:
print_summary(data) print_summary(data)