継続行がスペース2つだと、VS Codeがインデント幅を2と自動判定する。 .vscode/settings.json でMarkdownの自動判定を切って幅4に固定し、 既存の継続行もリストの記号から4つ下げた位置に揃える。 変更したファイルに markdownlint --fix をかけた分(journalのURL囲みと空行)も含む。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
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 を使った。自前の再実装ではない。
- 元データ →
deserialize→ キー配列A - A → PHY(root直下に平坦なKey木、絶対座標を
Translate·Rotateに分解) - PHY → KLEのキー配列 → 本家
serialize→deserialize→ キー配列B - 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)));