From 8ba5c1944e610df5c901ca4cc28d8cdd5b74c352 Mon Sep 17 00:00:00 2001 From: Alejandro Martin Perez Date: Wed, 14 Oct 2026 00:51:54 +0200 Subject: [PATCH] feat(tools): xkbcompile converts XKB layouts to .kbd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sourcing layouts from Microsoft `.klc` is awkward (MSKLC GUI) and the files are Microsoft-licensed. Add a host tool that reads the open, offline XKB data (`/usr/share/X11/xkb/symbols/*`) and emits the NeoDOS `.kbd` format directly. - resolves `include "file(section)"` chains, maps EVDEV -> set-1 scancodes, maps levels 1-3 to normal/shift/altgr, translates Latin-1 / `U` / common keysym names, marks dead keys and builds the compose table from the X11 `Compose` file. - verified `us`, `es`, `fr`, `de`: the `es` output matches the `.klc`-derived `es-ES` (ñ/Ñ, ´/¨, ç/Ç, dead keys, ¡/¿). Related: #390 (dynamic .kbd discovery) — new layouts still need wiring into the image allowlist and the kernel load list. --- docs/architecture/repository.md | 10 +- docs/design/neokbd-design.md | 12 +- tools/xkbcompile/Cargo.lock | 7 + tools/xkbcompile/Cargo.toml | 10 + tools/xkbcompile/README.md | 46 +++ tools/xkbcompile/src/main.rs | 691 ++++++++++++++++++++++++++++++++ 6 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 tools/xkbcompile/Cargo.lock create mode 100644 tools/xkbcompile/Cargo.toml create mode 100644 tools/xkbcompile/README.md create mode 100644 tools/xkbcompile/src/main.rs diff --git a/docs/architecture/repository.md b/docs/architecture/repository.md index 68aea252..a5b67480 100644 --- a/docs/architecture/repository.md +++ b/docs/architecture/repository.md @@ -28,10 +28,11 @@ neodos/ ├── libnet/ # User-mode networking library ├── userbin/ # 52 user binaries (PID 1, shell, utilities) ├── drivers/ # 10 standalone NEM drivers -├── tools/ # 4 Rust host tools + 1 Python script +├── tools/ # 5 Rust host tools + 1 Python script │ ├── crashdump/ # Crash dump decoder │ ├── gen-hiv/ # Registry hive generator -│ ├── kbdcompile/ # Keyboard layout compiler +│ ├── kbdcompile/ # Keyboard layout compiler (.klc → .kbd) +│ ├── xkbcompile/ # Keyboard layout compiler (XKB → .kbd) │ └── nltc/ # NLT compiler ├── scripts/ # bash utilities (MCP server migrated → neodos-dev-server) ├── data/ # Locale TOML (141 files) + keyboard layouts @@ -109,7 +110,8 @@ kbdcompile (host) ───→ data/keyboard (layout sources) | `nxdump` | Host tool, reads ELF/NXE/NEM files. | | `nxeinfo` | Host tool, reads NXE headers. | | `nxpkg` | Host tool, creates/extracts NXP packages. | -| `kbdcompile` | Host tool, compiles keyboard layouts. | +| `kbdcompile` | Host tool, compiles keyboard layouts (`.klc` → `.kbd`). | +| `xkbcompile` | Host tool, converts XKB layouts → `.kbd`. | | `scripts/mcp_server` | Python, completely independent. | | `scripts/check_deps.py` | Python, standalone validation. | | `scripts/gen_*` | Python, host-side data generation. | @@ -142,7 +144,7 @@ kbdcompile (host) ───→ data/keyboard (layout sources) |-----------|-----------|-----------| | `neodev` | `NeoDev` | Fully standalone host tool. Standard Rust deps (clap, anyhow, serde). Changes at own pace. Can version independently. Build/test/image creation for any repo. | | `neodos-lsp` + `scripts/mcp_server` | `NeoDOS Dev Server` (merged) | **Migrated.** LSP + MCP merged into single Rust workspace at [neodos-dev-server](https://github.com/NeoDOS-Project/neodos-dev-server). Shared toolkit (neodos-toolkit) + LSP binary + MCP binary. | -| `nltc, kbdcompile` | `NeoToolchain` or separate | Both are host-side compilers for OS data formats. Can evolve independently of kernel version. | +| `nltc, kbdcompile, xkbcompile` | `NeoToolchain` or separate | Host-side compilers for OS data formats. Can evolve independently of kernel version. | | `nxdump, nxeinfo, nxpkg` | `NeoTools` | UX-focused analysis tools. Independent release cycle. Useful for OS analysis even without building kernel. | | `scripts/mcp_server` | `NeoMCP` | Python MCP server for AI-assisted development. Completely independent of kernel. | | `data/locale/*` | `NeoTranslations` | Translation data. Community contributions don't need kernel access. Can be translated entirely by non-developers. | diff --git a/docs/design/neokbd-design.md b/docs/design/neokbd-design.md index 3b074efb..f02c5b0d 100644 --- a/docs/design/neokbd-design.md +++ b/docs/design/neokbd-design.md @@ -909,7 +909,17 @@ Los layouts se generan desde los mismos archivos `.klc` existentes, pero convert | `US.kbd` | US | en-US | 256 | 0 | | `Spanish.kbd` | Spanish | es-ES | 256 | 54 | -La herramienta `kbdcompile` toma un `.klc` y produce un `.kbd` binario. Se invoca en el build de la imagen, no en runtime. +La herramienta `kbdcompile` toma un `.klc` y produce un `.kbd` binario. + +Alternativa abierta y offline: `tools/xkbcompile` convierte un layout XKB +(`/usr/share/X11/xkb/symbols/*`) a `.kbd` directamente, sin pasar por `.klc`: + +```text +xkbcompile es basic --name es-ES --lang es-ES --out data/keyboard/es-ES.kbd +``` + +XKB cubre muchos idiomas con licencia abierta; sus límites (un solo grupo, +niveles 1-3, keysyms exóticos) están en `tools/xkbcompile/README.md`. --- diff --git a/tools/xkbcompile/Cargo.lock b/tools/xkbcompile/Cargo.lock new file mode 100644 index 00000000..65de7452 --- /dev/null +++ b/tools/xkbcompile/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "xkbcompile" +version = "0.1.0" diff --git a/tools/xkbcompile/Cargo.toml b/tools/xkbcompile/Cargo.toml new file mode 100644 index 00000000..fe78b64d --- /dev/null +++ b/tools/xkbcompile/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xkbcompile" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "xkbcompile" +path = "src/main.rs" + +[dependencies] diff --git a/tools/xkbcompile/README.md b/tools/xkbcompile/README.md new file mode 100644 index 00000000..a4804c6e --- /dev/null +++ b/tools/xkbcompile/README.md @@ -0,0 +1,46 @@ +# xkbcompile + +Host tool: convert a **Linux XKB** layout (`/usr/share/X11/xkb/symbols/*`) into +the NeoDOS `.kbd` keyboard format. Complements `kbdcompile` (which reads the +Microsoft `.klc` format); XKB is offline and openly licensed, so it is the +preferred source for shipping many languages. + +```bash +cargo build +./target/debug/xkbcompile [variant] \ + [--name TAG] [--lang TAG] [--out FILE] \ + [--symbols-dir DIR] [--keycodes FILE] [--compose FILE] + +# Spanish (es) base variant -> data/keyboard/es-ES.kbd +./target/debug/xkbcompile es basic --name es-ES --lang es-ES \ + --out ../../data/keyboard/es-ES.kbd +``` + +Defaults: `--symbols-dir /usr/share/X11/xkb/symbols`, +`--keycodes /usr/share/X11/xkb/keycodes/evdev`, +`--compose /usr/share/X11/locale/en_US.UTF-8/Compose`, `--out +data/keyboard/.kbd`. + +## What it does + +- Resolves `include "file(section)"` chains (e.g. `es(basic)` → + `latin(type4)` → `latin(basic)`), later `key` definitions overriding earlier. +- Maps EVDEV keycodes to PC set-1 scancodes (`set1 = evdev - 8`). +- Maps levels 1-3 to the kernel's `normal`/`shift`/`altgr` columns. +- Translates keysyms: Latin-1 names, `U` Unicode keysyms and the common + punctuation/accent names. +- Marks `dead_*` keysyms (per level) and builds the compose table from the + X11 `Compose` file for the dead keys used. + +## Limits + +- Only the first keyboard group; levels 4+ (Shift+AltGr) are dropped (the + kernel format has no such column). +- Exotic keysyms (Greek, Cyrillic, ligatures, `Greek_*`, `XF86*`) are not + translated and leave the key unmapped. +- The `Ctrl` column is left unset (XKB does not define ctrl levels); the kernel + falls back to the normal/shift value. + +Adding a layout to the OS also needs wiring it into `neodev/src/image.rs` +(`kbd_layouts`) and the kernel load list (`load_layouts()`), until dynamic +`.kbd` discovery lands (#390). diff --git a/tools/xkbcompile/src/main.rs b/tools/xkbcompile/src/main.rs new file mode 100644 index 00000000..4fd99033 --- /dev/null +++ b/tools/xkbcompile/src/main.rs @@ -0,0 +1,691 @@ +//! xkbcompile — convert an XKB layout (`/usr/share/X11/xkb/symbols/*`) into +//! the NeoDOS `.kbd` format. +//! +//! Usage: +//! xkbcompile [variant] [--name TAG] [--lang TAG] [--out FILE] +//! [--symbols-dir DIR] [--keycodes FILE] [--compose FILE] +//! +//! Scope: single group, levels 1-3 (normal / shift / AltGr). It resolves +//! `include "file(section)"` chains, maps EVDEV keycodes to PC set-1 +//! scancodes, translates X11 keysyms (Latin-1 / `U` / the common names), +//! marks dead keys and, when a Compose file is given, builds the composition +//! table for the dead keysyms used. +//! +//! Known limits: only the first keyboard group; levels 4+ (Shift+AltGr) are +//! dropped; exotic keysyms (Greek, Cyrillic, ligatures) are not translated. + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const KBD_MAGIC: &[u8; 4] = b"KBD\0"; + +#[repr(C)] +#[derive(Clone, Copy)] +struct KeyEntry { + normal: u16, + shift: u16, + altgr: u16, + ctrl: u16, + flags: u8, + _pad: [u8; 7], +} + +impl KeyEntry { + fn empty() -> Self { + KeyEntry { normal: 0xFFFF, shift: 0xFFFF, altgr: 0xFFFF, ctrl: 0xFFFF, flags: 0, _pad: [0u8; 7] } + } +} + +struct Compose { + dead: u16, + base: u16, + result: u16, +} + +struct Args { + layout: String, + variant: Option, + name: String, + lang: String, + out: PathBuf, + symbols_dir: PathBuf, + keycodes: PathBuf, + compose: PathBuf, +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(msg) => { + eprintln!("xkbcompile: {msg}"); + eprintln!( + "usage: xkbcompile [variant] [--name TAG] [--lang TAG] [--out FILE] \ + [--symbols-dir DIR] [--keycodes FILE] [--compose FILE]" + ); + std::process::exit(2); + } + }; + + let mut keymap: BTreeMap> = BTreeMap::new(); + let mut visited: HashSet<(String, String)> = HashSet::new(); + if let Err(e) = resolve(&args.symbols_dir, &args.layout, args.variant.as_deref(), &mut keymap, &mut visited) { + eprintln!("xkbcompile: {e}"); + std::process::exit(1); + } + + let keycodes = match parse_keycodes(&args.keycodes) { + Ok(m) => m, + Err(e) => { + eprintln!("xkbcompile: {}", e); + std::process::exit(1); + } + }; + + let mut entries = vec![KeyEntry::empty(); 256]; + let mut dead_labels: HashSet = HashSet::new(); + + for (kname, syms) in &keymap { + let ev = match keycodes.get(kname) { + Some(v) => *v, + None => continue, + }; + // PC set-1 scancode = EVDEV keycode - 8 for the alphanumeric block. + if ev < 9 { + continue; + } + let set1 = ev - 8; + if set1 == 0 || set1 > 0x58 { + continue; + } + let mut e = KeyEntry::empty(); + for (level, sym) in syms.iter().enumerate() { + if sym.is_empty() || sym == "NoSymbol" || sym == "VoidSymbol" { + continue; + } + let (cp, dead) = match symbol_to_char(sym) { + Some(v) => v, + None => continue, + }; + if dead { + dead_labels.insert(cp); + } + match level { + 0 => { e.normal = cp; if dead { e.flags |= 0x01; } } + 1 => { e.shift = cp; if dead { e.flags |= 0x02; } } + 2 => { e.altgr = cp; if dead { e.flags |= 0x04; } } + _ => {} // level 4+ (Shift+AltGr): no kernel column + } + } + entries[set1 as usize] = e; + } + + let compose = if args.compose.exists() { + parse_compose(&args.compose, &dead_labels) + } else { + Vec::new() + }; + + let bytes = write_kbd(&args.name, &args.lang, &entries, &compose); + if let Err(e) = fs::write(&args.out, &bytes) { + eprintln!("xkbcompile: cannot write {}: {e}", args.out.display()); + std::process::exit(1); + } + let mapped = entries.iter().filter(|e| e.normal != 0xFFFF).count(); + eprintln!( + "xkbcompile: wrote {} ({} keys, {} compose) [{}]", + args.out.display(), mapped, compose.len(), args.name + ); +} + +fn parse_args() -> Result { + let mut pos: Vec = Vec::new(); + let mut name = None; + let mut lang = None; + let mut out = None; + let mut symbols_dir = PathBuf::from("/usr/share/X11/xkb/symbols"); + let mut keycodes = PathBuf::from("/usr/share/X11/xkb/keycodes/evdev"); + let mut compose = PathBuf::from("/usr/share/X11/locale/en_US.UTF-8/Compose"); + + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--name" => name = it.next(), + "--lang" => lang = it.next(), + "--out" => out = it.next().map(PathBuf::from), + "--symbols-dir" => symbols_dir = it.next().map(PathBuf::from).ok_or("--symbols-dir needs a value")?, + "--keycodes" => keycodes = it.next().map(PathBuf::from).ok_or("--keycodes needs a value")?, + "--compose" => compose = it.next().map(PathBuf::from).ok_or("--compose needs a value")?, + "-h" | "--help" => return Err("help".into()), + other if other.starts_with("--") => return Err(format!("unknown option {other}")), + other => pos.push(other.to_string()), + } + } + + if pos.is_empty() { + return Err("missing ".into()); + } + let layout = pos[0].clone(); + let variant = pos.get(1).cloned(); + let tag = variant.as_ref().map(|v| format!("{layout}-{v}")).unwrap_or_else(|| layout.clone()); + let name = name.unwrap_or_else(|| tag.clone()); + let lang = lang.unwrap_or(tag); + let out = out.unwrap_or_else(|| PathBuf::from(format!("data/keyboard/{name}.kbd"))); + + Ok(Args { layout, variant, name, lang, out, symbols_dir, keycodes, compose }) +} + +// ── XKB symbols parsing ───────────────────────────────────────────── + +/// Resolve a `xkb_symbols` section, expanding `include` chains in order so +/// that later `key` definitions override earlier (included) ones. +fn resolve( + dir: &Path, + file: &str, + section: Option<&str>, + keymap: &mut BTreeMap>, + visited: &mut HashSet<(String, String)>, +) -> Result<(), String> { + let key = (file.to_string(), section.unwrap_or_default().to_string()); + if !visited.insert(key) { + return Ok(()); // cycle guard + } + + let path = dir.join(file); + let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let body = find_section(&text, section).ok_or_else(|| { + format!("{}: section '{}' not found", path.display(), section.unwrap_or("")) + })?; + + for stmt in split_statements(&body) { + let s = stmt.trim(); + if s.is_empty() { + continue; + } + if let Some(rest) = s.strip_prefix("include") { + if let Some(spec) = quoted(rest) { + let (f, sec) = parse_include(&spec); + resolve(dir, &f, sec.as_deref(), keymap, visited)?; + } + } else if s.starts_with("key") { + if let Some((kname, syms)) = parse_key(s) { + keymap.insert(kname, syms); + } + } + } + Ok(()) +} + +/// Return the body of `xkb_symbols "name" { ... }`. With `None`, the default +/// (or first) section is used. +fn find_section(text: &str, name: Option<&str>) -> Option { + let mut idx = 0usize; + let mut first: Option = None; + let mut default: Option = None; + + while let Some(rel) = text[idx..].find("xkb_symbols") { + let start = idx + rel; + let after = &text[start + "xkb_symbols".len()..]; + let sname = quoted(after).unwrap_or_default(); + // `default` attribute lives just before `xkb_symbols`. + let prefix_start = text[..start].rfind('}').map(|p| p + 1).unwrap_or(0); + let is_default = text[prefix_start..start].contains("default"); + + let brace = match after.find('{') { + Some(b) => b, + None => break, + }; + let body_start = start + "xkb_symbols".len() + brace + 1; + let body = match balanced_braces(&text[body_start..]) { + Some(b) => b, + None => break, + }; + + if let Some(n) = name { + if sname == n { + return Some(body); + } + } else { + if first.is_none() { + first = Some(body.clone()); + } + if is_default && default.is_none() { + default = Some(body.clone()); + } + } + idx = body_start + body.len() + 1; + } + + if name.is_none() { + default.or(first) + } else { + None + } +} + +/// Split a section body into `;`-terminated statements (respecting quotes and +/// brackets/braces). +fn split_statements(body: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quote = false; + let mut depth = 0i32; + for c in body.chars() { + match c { + '"' => { in_quote = !in_quote; cur.push(c); } + '[' | '{' => { depth += 1; cur.push(c); } + ']' | '}' => { depth -= 1; cur.push(c); } + ';' if !in_quote && depth <= 0 => { out.push(std::mem::take(&mut cur)); } + _ => cur.push(c), + } + } + if !cur.trim().is_empty() { + out.push(cur); + } + out +} + +fn parse_key(stmt: &str) -> Option<(String, Vec)> { + let lt = stmt.find('<')?; + let gt = stmt[lt..].find('>')? + lt; + let name = stmt[lt + 1..gt].trim().to_string(); + + // Find the keysym list: the first `[` group not preceded by `type`/`symbols`. + let bytes = stmt.as_bytes(); + let mut i = 0; + while let Some(rel) = stmt[i..].find('[') { + let p = i + rel; + // preceding word + let mut w = p; + while w > 0 && (bytes[w - 1].is_ascii_alphanumeric() || bytes[w - 1] == b'_') { + w -= 1; + } + let word = &stmt[w..p]; + if word == "type" || word == "symbols" { + i = p + 1; + continue; + } + let close = stmt[p..].find(']')? + p; + let inner = &stmt[p + 1..close]; + let syms: Vec = inner + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + return Some((name, syms)); + } + Some((name, Vec::new())) +} + +/// Parse the first quoted string in `s`. +fn quoted(s: &str) -> Option { + let start = s.find('"')? + 1; + let end = s[start..].find('"')? + start; + Some(s[start..end].to_string()) +} + +/// Parse `file` or `file(section)` into its parts. +fn parse_include(spec: &str) -> (String, Option) { + match spec.find('(') { + Some(open) => { + let file = spec[..open].trim().to_string(); + let section = spec[open + 1..].trim_end_matches(')').trim().to_string(); + (file, Some(section)) + } + None => (spec.trim().to_string(), None), + } +} + +/// Body of a `{ ... }` block (the input must start right after the `{`). +fn balanced_braces(s: &str) -> Option { + let mut depth = 1i32; + for (i, c) in s.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(s[..i].to_string()); + } + } + _ => {} + } + } + None +} + +// ── keycodes ──────────────────────────────────────────────────────── + +fn parse_keycodes(path: &Path) -> Result, String> { + let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut map = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with("//") || line.starts_with("alias") { + continue; + } + // = 24; + if let Some(lt) = line.find('<') { + if let Some(gt) = line[lt..].find('>') { + let name = line[lt + 1..lt + gt].trim().to_string(); + if let Some(eq) = line.find('=') { + let num: String = line[eq + 1..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(v) = num.parse::() { + map.insert(name, v); + } + } + } + } + } + Ok(map) +} + +// ── keysyms ───────────────────────────────────────────────────────── + +/// Map an XKB keysym name to `(codepoint, is_dead)`. +fn symbol_to_char(name: &str) -> Option<(u16, bool)> { + let name = name.trim(); + if name.is_empty() || name == "NoSymbol" || name == "VoidSymbol" { + return None; + } + if let Some(rest) = name.strip_prefix("dead_") { + return dead_label(rest).map(|cp| (cp, true)); + } + // Unicode keysym `U` (4-6 hex digits). + if let Some(hex) = name.strip_prefix('U') { + if hex.len() >= 4 && hex.len() <= 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(v) = u32::from_str_radix(hex, 16) { + if let Ok(cp) = u16::try_from(v) { + return Some((cp, false)); + } + } + } + } + if name.chars().count() == 1 { + let c = name.chars().next().unwrap(); + if (c as u32) < 0x80 { + return Some((c as u16, false)); + } + } + named_keysym(name).map(|cp| (cp, false)) +} + +/// Spacing codepoint used as the compose key for each dead keysym (matches the +/// convention of the `.klc`-derived layouts). +fn dead_label(rest: &str) -> Option { + Some(match rest { + "grave" => 0x0060, + "acute" => 0x00B4, + "circumflex" => 0x005E, + "tilde" => 0x007E, + "diaeresis" => 0x00A8, + "macron" => 0x00AF, + "breve" => 0x02D8, + "abovedot" => 0x02D9, + "abovering" => 0x02DA, + "doubleacute" => 0x02DD, + "caron" => 0x02C7, + "cedilla" => 0x00B8, + "ogonek" => 0x02DB, + "belowdot" => 0x0323, + "hook" => 0x0309, + "horn" => 0x031B, + "stroke" => 0x0338, + "belowcomma" => 0x0326, + "iota" => 0x0345, + "voiced_sound" => 0x3099, + "semivoiced_sound" => 0x309A, + _ => return None, + }) +} + +fn named_keysym(name: &str) -> Option { + Some(match name { + "space" => 0x20, + "exclam" => 0x21, + "quotedbl" => 0x22, + "numbersign" => 0x23, + "dollar" => 0x24, + "percent" => 0x25, + "ampersand" => 0x26, + "apostrophe" | "quoteright" => 0x27, + "parenleft" => 0x28, + "parenright" => 0x29, + "asterisk" => 0x2A, + "plus" => 0x2B, + "comma" => 0x2C, + "minus" => 0x2D, + "period" => 0x2E, + "slash" => 0x2F, + "0" => 0x30, + "1" => 0x31, + "2" => 0x32, + "3" => 0x33, + "4" => 0x34, + "5" => 0x35, + "6" => 0x36, + "7" => 0x37, + "8" => 0x38, + "9" => 0x39, + "colon" => 0x3A, + "semicolon" => 0x3B, + "less" => 0x3C, + "equal" => 0x3D, + "greater" => 0x3E, + "question" => 0x3F, + "at" => 0x40, + "bracketleft" => 0x5B, + "backslash" => 0x5C, + "bracketright" => 0x5D, + "asciicircum" => 0x5E, + "underscore" => 0x5F, + "grave" | "quoteleft" => 0x60, + "braceleft" => 0x7B, + "bar" => 0x7C, + "braceright" => 0x7D, + "asciitilde" => 0x7E, + // Latin-1 supplement + "nobreakspace" => 0xA0, + "exclamdown" => 0xA1, + "cent" => 0xA2, + "sterling" => 0xA3, + "currency" => 0xA4, + "yen" => 0xA5, + "brokenbar" => 0xA6, + "section" => 0xA7, + "diaeresis" => 0xA8, + "copyright" => 0xA9, + "ordfeminine" => 0xAA, + "guillemotleft" => 0xAB, + "notsign" => 0xAC, + "hyphen" => 0xAD, + "registered" => 0xAE, + "macron" => 0xAF, + "degree" => 0xB0, + "plusminus" => 0xB1, + "twosuperior" => 0xB2, + "threesuperior" => 0xB3, + "acute" => 0xB4, + "mu" => 0xB5, + "paragraph" => 0xB6, + "periodcentered" => 0xB7, + "cedilla" => 0xB8, + "onesuperior" => 0xB9, + "masculine" => 0xBA, + "guillemotright" => 0xBB, + "onequarter" => 0xBC, + "onehalf" => 0xBD, + "threequarters" => 0xBE, + "questiondown" => 0xBF, + "Agrave" => 0xC0, + "Aacute" => 0xC1, + "Acircumflex" => 0xC2, + "Atilde" => 0xC3, + "Adiaeresis" => 0xC4, + "Aring" => 0xC5, + "AE" => 0xC6, + "Ccedilla" => 0xC7, + "Egrave" => 0xC8, + "Eacute" => 0xC9, + "Ecircumflex" => 0xCA, + "Ediaeresis" => 0xCB, + "Igrave" => 0xCC, + "Iacute" => 0xCD, + "Icircumflex" => 0xCE, + "Idiaeresis" => 0xCF, + "ETH" => 0xD0, + "Ntilde" => 0xD1, + "Ograve" => 0xD2, + "Oacute" => 0xD3, + "Ocircumflex" => 0xD4, + "Otilde" => 0xD5, + "Odiaeresis" => 0xD6, + "multiply" => 0xD7, + "Oslash" => 0xD8, + "Ugrave" => 0xD9, + "Uacute" => 0xDA, + "Ucircumflex" => 0xDB, + "Udiaeresis" => 0xDC, + "Yacute" => 0xDD, + "THORN" => 0xDE, + "ssharp" => 0xDF, + "agrave" => 0xE0, + "aacute" => 0xE1, + "acircumflex" => 0xE2, + "atilde" => 0xE3, + "adiaeresis" => 0xE4, + "aring" => 0xE5, + "ae" => 0xE6, + "ccedilla" => 0xE7, + "egrave" => 0xE8, + "eacute" => 0xE9, + "ecircumflex" => 0xEA, + "ediaeresis" => 0xEB, + "igrave" => 0xEC, + "iacute" => 0xED, + "icircumflex" => 0xEE, + "idiaeresis" => 0xEF, + "eth" => 0xF0, + "ntilde" => 0xF1, + "ograve" => 0xF2, + "oacute" => 0xF3, + "ocircumflex" => 0xF4, + "otilde" => 0xF5, + "odiaeresis" => 0xF6, + "division" => 0xF7, + "oslash" => 0xF8, + "ugrave" => 0xF9, + "uacute" => 0xFA, + "ucircumflex" => 0xFB, + "udiaeresis" => 0xFC, + "yacute" => 0xFD, + "thorn" => 0xFE, + "ydiaeresis" => 0xFF, + // Common extras + "EuroSign" => 0x20AC, + "oe" => 0x0153, + "OE" => 0x0152, + "leftsinglequotemark" => 0x2018, + "rightsinglequotemark" => 0x2019, + "leftdoublequotemark" => 0x201C, + "rightdoublequotemark" => 0x201D, + "endash" => 0x2013, + "emdash" => 0x2014, + "ellipsis" => 0x2026, + "trademark" => 0x2122, + "bullet" => 0x2022, + _ => return None, + }) +} + +// ── Compose ───────────────────────────────────────────────────────── + +fn parse_compose(path: &Path, dead_labels: &HashSet) -> Vec { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with("include") { + continue; + } + let colon = match line.find(':') { + Some(c) => c, + None => continue, + }; + let (keys_part, rest) = line.split_at(colon); + let keys: Vec<&str> = keys_part + .split('<') + .skip(1) + .filter_map(|s| s.split('>').next()) + .collect(); + if keys.len() != 2 { + continue; // only two-key sequences + } + let (d_cp, d_dead) = match symbol_to_char(keys[0]) { + Some(v) => v, + None => continue, + }; + if !d_dead || !dead_labels.contains(&d_cp) { + continue; + } + let base = match symbol_to_char(keys[1]) { + Some((cp, false)) => cp, + _ => continue, + }; + let result = match compose_result(rest) { + Some(cp) => cp, + None => continue, + }; + out.push(Compose { dead: d_cp, base, result }); + } + out +} + +/// First Unicode codepoint of the `"..."` string after the `:`. +fn compose_result(rest: &str) -> Option { + let q = rest.find('"')? + 1; + let end = rest[q..].find('"')? + q; + rest[q..end].chars().next().map(|c| c as u16) +} + +// ── .kbd writer ───────────────────────────────────────────────────── + +fn write_kbd(name: &str, lang: &str, entries: &[KeyEntry], compose: &[Compose]) -> Vec { + let mut out = Vec::with_capacity(60 + 256 * 16 + 4 + compose.len() * 6); + out.extend_from_slice(KBD_MAGIC); + out.extend_from_slice(&1u32.to_le_bytes()); + + let mut name_bytes = [0u8; 32]; + let nlen = name.len().min(31); + name_bytes[..nlen].copy_from_slice(&name.as_bytes()[..nlen]); + out.extend_from_slice(&name_bytes); + + let mut tag_bytes = [0u8; 16]; + let tlen = lang.len().min(15); + tag_bytes[..tlen].copy_from_slice(&lang.as_bytes()[..tlen]); + out.extend_from_slice(&tag_bytes); + + out.extend_from_slice(&256u32.to_le_bytes()); + for e in entries { + out.extend_from_slice(&e.normal.to_le_bytes()); + out.extend_from_slice(&e.shift.to_le_bytes()); + out.extend_from_slice(&e.altgr.to_le_bytes()); + out.extend_from_slice(&e.ctrl.to_le_bytes()); + out.push(e.flags); + out.extend_from_slice(&e._pad); + } + out.extend_from_slice(&(compose.len() as u32).to_le_bytes()); + for c in compose { + out.extend_from_slice(&c.dead.to_le_bytes()); + out.extend_from_slice(&c.base.to_le_bytes()); + out.extend_from_slice(&c.result.to_le_bytes()); + } + out +}