Files
Beyond-KLE/journal/2026-09-21-kle-corpus.md
T
ewdernaandClaude Opus 5 12a47fe822 phy: 公開KLE JSON 1505件で変換可否を確かめ、書き戻しの制約を加える
VIAの定義とKLE本家のプリセット・サンプルを集め、本家の serial.js を通して往復させた。
幾何は全件無損失で、PHYで表せないKLEの幾何情報は無かった。
KLEは p, sm, sb, st を空文字に戻す指定を無視するため、混在する配置は書き戻せない。
本家が読めないJSONも実在するので、取り込みの寛容さを問いとして残す。

Journal: journal/2026-09-21-kle-corpus.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01246a5KFLmwcRXriZvX8fdJ
2026-09-21 19:00:32 +09:00

11 KiB

公開KLE JSONの収集とPHYへの変換可否

結論

公開されているKLE JSON 1505件(キー111,619個)を集め、PHYへ変換して書き戻す往復を通した。

  • 幾何(位置・サイズ・回転・2つ目の矩形・decal・ghost)は全件で無損失。PHYのデータモデルで表せないKLEの 幾何情報は無かった
  • 置き場がないのは見た目の属性だけ(c, t, p, a, f, f2, fa, n, l と sm, sb, st、ファイル単位の backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの
  • KLE本家のデシリアライザが読めないJSONが実在する(1505件中1件)
  • l(stepped)が付いたキーが16個あった。KLEはstepped Caps Lockに「段差がある」ことしか持たず、 スイッチ位置のオフセットは持たない。PHYの switchOffset は取り込みでは決まらない

コーパス

出所 件数 備考
KLE本家のプリセット (layouts.json) 12 ANSI 104, ISO 105, ErgoDox, Kinesis Advantage, Atreus など
KLE本家のサンプル (samples/) 9 Symbolics Space Cadet, Apple Wireless, Commodore VIC-20 など
VIA キーボード定義 (the-via/keyboards の layouts.keymap) 1484 市販・自作基板の実データ

Geekhackやgistは当たっていない。VIAの定義はKLE JSONそのもの(layouts.keymap)で、 実際に流通している基板をほぼ網羅しているため、これで足りると判断した。

検証方法

KLE本家のリポジトリ (ijprest/keyboard-layout-editor) から serial.js と extensions.js を そのままNodeで読み込み、本家の deserialize / serialize を使った。自前の再実装ではない。

  1. 元データ → deserialize → キー配列A
  2. A → PHY(root直下に平坦なKey木、絶対座標を Translate·Rotate に分解)
  3. PHY → KLEのキー配列 → 本家 serialize → deserialize → キー配列B
  4. AとBを、絶対位置・角度・サイズ・ラベル・見た目属性のフィンガープリントの多重集合で比較

比較対象は「Aをそのまま本家で往復させた結果」とした(本家自身の往復の癖を分離するため)。

結果

  • 1504/1504 で配置が一致(1件は手順1で本家が例外を投げるため対象外)
  • 直列化結果そのものが本家の出力と完全一致: 1408件。小数4桁に丸めると一致(誤差のみ): 20件。 残り76件は行の切り方が違うだけで、読み直した配置は一致する

出現頻度(キー単位、111,619個中):

属性 件数
c (色) 35,888
0.25U刻みでない座標・サイズ 6,824
回転あり 4,583 (うち15度の倍数でない 4,155)
rx/ry あり 2,567
f/f2/fa 893
d (decal) 830
2つ目の矩形 (x2,y2,w2,h2) 570
sm/sb/st 142
t (文字色) 61
n (ホームポジションの突起) 52
l (stepped) 16
g (ghost) 8

ファイル単位のメタデータは name 21件、backcolor 7件、author 2件、radii 2件、 background/switchMount/switchBrand/switchType 各1件。

座標・サイズ・回転は正規化されていない値が普通に出てくる。PHYが「スナップするが正規化しない」と 決めているのと整合する。

本家が読めないJSON

owlab/spring/spring.json の layouts.keymap は {"r":12,"y":-0.8,"x":0.1} を行の先頭以外に置いており、 本家の deserialize は 'r' can only be used on the first key in a row で例外を投げる。 VIA側のパーサーは通してしまうため、このまま公開されている。

取り込みでこれをどう扱うか(本家と同じく拒否するか、行の途中の r を受け入れるか)は未定。 PHY の未確認事項に置いた。

見た目属性の扱い

退避して書き戻す方針は、次の制約とセットでないと成立しない。

本家の deserialize は p / sm / sb / st を if(key.p) の形で読む。空文字への書き戻しが無視されるため、 一度値を持ったキーの後ろに値を持たないキーが来る並びでは、前の値が後続に残る。

入力:  [[{p:'SA',sm:'cherry'},'A',{p:'',sm:''},'B']]
結果:  A → p=SA sm=cherry / B → p=SA sm=cherry   (Bの空文字指定は無視される)

実コーパスでは、クラスターの原点を rx,ry に選ぶ出力では1件も発生しなかった。 キーごとに rx,ry を選ぶ出力では1件発生した(floookay/adelheid、p:"SPACE" が11キーに漏れる)。 並び順に依存する事故なので、出力側で回避しきれない。

検証スクリプト

コーパスと本家のコードを用意する。

git clone --depth 1 https://github.com/ijprest/keyboard-layout-editor.git kle
git clone --depth 1 https://github.com/the-via/keyboards.git via
# 本家の serial.js を Node から使えるようにする(angular は toJson しか使われない)
{ echo 'global.angular = { toJson: (o) => JSON.stringify(o) };'; cat kle/extensions.js; cat kle/serial.js; } > kleio.js

convert.js — KLEキーとPHYノードの相互変換と比較用フィンガープリント。

// KLE JSON <-> Beyond KLE PHY の変換(検証用)
const $serial = require('./kleio.js');
const R = (d) => d * Math.PI / 180;

// KLE key -> PHY Key (root直下の平坦な木)
function kleKeyToPhy(k, i) {
  const th = R(k.rotation_angle), c = Math.cos(th), s = Math.sin(th);
  const dx = k.x - k.rotation_x, dy = k.y - k.rotation_y;
  const node = {
    id: 'k' + i,
    transform: {
      x: k.rotation_x + dx * c - dy * s,
      y: k.rotation_y + dx * s + dy * c,
      rotation: k.rotation_angle,
    },
    size: { w: k.width, h: k.height },
    profile: 'default',
  };
  if (k.width2 !== k.width || k.height2 !== k.height || k.x2 !== 0 || k.y2 !== 0) {
    Object.assign(node.size, { x2: k.x2, y2: k.y2, w2: k.width2, h2: k.height2 });
  }
  if (k.decal) node.decal = true;
  if (k.ghost) node.ghost = true;
  const labels = k.labels.map(x => x === undefined ? '' : x);
  if (labels.some(x => x !== '')) node.labels = labels;
  // PHYに置き場のない属性(P-6)。検証用に退避
  node._kle = { c: k.color, t: k.textColor, p: k.profile, n: k.nub, l: k.stepped,
                sm: k.sm, sb: k.sb, st: k.st, ts: k.textSize, dflt: k.default };
  return node;
}

// PHY Key -> KLE key (rx,ry の選び方は出力側の裁量)
function phyToKleKey(n, opts = {}) {
  const k = $serial.defaultKeyProps();
  const rot = n.transform.rotation;
  const [rx, ry] = opts.pivot ? opts.pivot(n) : (rot === 0 ? [0, 0] : [n.transform.x, n.transform.y]);
  const th = R(rot), c = Math.cos(th), s = Math.sin(th);
  // 絶対座標 -> KLE の (x,y): 逆回転
  const dx = n.transform.x - rx, dy = n.transform.y - ry;
  k.x = rx + dx * c + dy * s;
  k.y = ry + -dx * s + dy * c;
  k.rotation_angle = rot; k.rotation_x = rx; k.rotation_y = ry;
  k.width = n.size.w; k.height = n.size.h;
  k.width2 = n.size.w2 !== undefined ? n.size.w2 : n.size.w;
  k.height2 = n.size.h2 !== undefined ? n.size.h2 : n.size.h;
  k.x2 = n.size.x2 || 0; k.y2 = n.size.y2 || 0;
  k.decal = !!n.decal; k.ghost = !!n.ghost;
  k.labels = (n.labels || []).slice();
  const e = n._kle || {};
  k.color = e.c !== undefined ? e.c : k.color;
  k.textColor = e.t || []; k.profile = e.p || ''; k.nub = !!e.n; k.stepped = !!e.l;
  k.sm = e.sm || ''; k.sb = e.sb || ''; k.st = e.st || '';
  k.textSize = e.ts || []; if (e.dflt) k.default = e.dflt;
  return k;
}

// 比較用: キーの見た目を決める絶対量だけを取り出す
function fingerprint(k) {
  const th = R(k.rotation_angle), c = Math.cos(th), s = Math.sin(th);
  const dx = k.x - k.rotation_x, dy = k.y - k.rotation_y;
  const ax = k.rotation_x + dx * c - dy * s, ay = k.rotation_y + dx * s + dy * c;
  const r6 = (v) => Math.round(v * 1e6) / 1e6;
  const ang = ((k.rotation_angle % 360) + 360) % 360;
  return JSON.stringify([r6(ax), r6(ay), r6(ang), k.width, k.height, k.width2, k.height2, k.x2, k.y2,
    k.decal, k.ghost, k.stepped, k.nub, (k.labels || []).map(x => x || '').join('\u0001'),
    k.color, k.profile, k.sm, k.sb, k.st,
    (k.textColor || []).map(x => x || '').join('\u0001'),
    (k.textSize || []).map(x => x || 0).join('\u0001'), k.default && k.default.textSize]);
}

module.exports = { $serial, kleKeyToPhy, phyToKleKey, fingerprint };

run2.js — コーパス全件の往復。

const fs=require('fs'), path=require('path');
const { $serial, kleKeyToPhy, phyToKleKey, fingerprint } = require('./convert.js');
const rnd=v=>Math.round(v*1e6)/1e6;
function walk(d,out=[]){for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name); e.isDirectory()?walk(p,out):e.name.endsWith('.json')&&out.push(p);}return out;}
const corpus=[];
for(const p of JSON.parse(fs.readFileSync('kle/layouts.json','utf8')).presets) if(p.data.length) corpus.push({name:'preset:'+p.name,data:p.data});
for(const f of fs.readdirSync('kle/samples')) { try{ corpus.push({name:'sample:'+f,data:JSON.parse(fs.readFileSync('kle/samples/'+f,'utf8'))});}catch(e){} }
for(const p of walk('via/src')){ try{ const d=JSON.parse(fs.readFileSync(p,'utf8')); if(d.layouts&&Array.isArray(d.layouts.keymap)&&d.layouts.keymap.length) corpus.push({name:'via:'+path.relative('via/src',p),data:d.layouts.keymap}); }catch(e){} }

let ok=0, sameSer=0, n=0, parseFail=0;
let stickyA=0, stickyB=0; const stickyBoards=[];
const RESET=/"(p|sm|sb|st|t)":""/;
const rowsOf=s=>s.filter(Array.isArray).length;
let rowsOrig=0,rowsA=0,rowsB=0, rotBoards=0;
for(const b of corpus){
  let A; try{ A=$serial.deserialize(b.data).keys; }catch(e){ parseFail++; continue; }
  n++;
  const orig=$serial.serialize({meta:{},keys:JSON.parse(JSON.stringify(A))});
  const phy=A.map((k,i)=>{const nd=kleKeyToPhy(k,i); nd._pivot=[k.rotation_x,k.rotation_y]; return nd;});
  const mk=(opts)=>$serial.serialize({meta:{},keys:phy.map(nd=>{const k=phyToKleKey(nd,opts); k.x=rnd(k.x); k.y=rnd(k.y); return k;})});
  const sA=mk(), sB=mk({pivot:nd=>nd._pivot});
  const g=s=>$serial.deserialize(s).keys.map(fingerprint).sort().join('|');
  if(g(sB)===g(orig)) ok++;
  if(JSON.stringify(sB)===JSON.stringify(orig)) sameSer++;
  if(RESET.test(JSON.stringify(sA))) stickyA++;
  if(RESET.test(JSON.stringify(sB))) { stickyB++; stickyBoards.push(b.name); }
  if(A.some(k=>k.rotation_angle)){ rotBoards++; rowsOrig+=rowsOf(orig); rowsA+=rowsOf(sA); rowsB+=rowsOf(sB); }
}
console.log(`boards=${n} parseFail=${parseFail}`);
console.log(`PHY経由(クラスター原点を軸+丸め)で元と同じ配置: ${ok}/${n}`);
console.log(`直列化結果が元のKLE出力と完全一致: ${sameSer}/${n}`);
console.log(`回転を含む板: ${rotBoards} 行数合計 orig=${rowsOrig} A(キーごと軸)=${rowsA} B(クラスター軸)=${rowsB}`);
console.log(`sticky属性の""リセットを含む出力: A=${stickyA} B=${stickyB}`, JSON.stringify(stickyBoards.slice(0,6)));