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
This commit is contained in:
+11
@@ -328,6 +328,8 @@ const kleData = (ConfigObject | KeyLabel)[][]
|
||||
- ユニットサイズ(mm)、ビジュアルオフセット、クラスター構造、部品ノード、per-key LEDはKLE JSONに出力されない
|
||||
- バリアント構造はKLE JSONに出力しない。VariantGroupごとにバリアントを1つ選び、その配下だけを畳んで出力する
|
||||
- 部品グループ・スイッチの向きはKLE JSONに出力しない。sm, sb, st には取り込み時に保持した値をそのまま書き戻す
|
||||
- p, sm, sb, st は空文字に戻す指定がKLE側で無視される。値を持つキーと持たないキーが混在する配置は、
|
||||
KLEで開いた時点で後続のキーに前の値が残る
|
||||
- KLEの `p` はキーキャップのプロファイル名(SA、DCSなど)であり、PHYのプロファイルとは対応させない
|
||||
|
||||
### KLE JSONの取り込み
|
||||
@@ -340,6 +342,8 @@ const kleData = (ConfigObject | KeyLabel)[][]
|
||||
- 構造の復元は保証しない。KLE側での編集が大きいほど、クラスター構造は失われる
|
||||
- 配列の順番は照合に使わない。KLEは保存時にキーを「角度 → rx → ry → y → x」の順に並べ替えるため、順番は形状から決まる以上の情報を持たない
|
||||
|
||||
r, rx, ry を行の先頭以外に置いたKLE JSONが公開されている。KLE本家はこれを読み込まずエラーにする。
|
||||
|
||||
KLEの rx, ry, r はキーの絶対配置 (x, y, rotation) に変換して取り込む。元の rx, ry は保持しない。
|
||||
|
||||
見た目の属性(c, t, p, a, f, f2, fa, l, n)は、KLE JSONへ書き戻せるように保持する。どの層で持つかは決まっていない。
|
||||
@@ -363,6 +367,13 @@ Beyond KLEはスイッチの品種を持たないため、対応づける先が
|
||||
- 現状の認識: 形状の許容誤差、ラベルの扱い、複数候補がある場合の決め方が未定
|
||||
- 格上げ条件: 照合規則を決めたうえで、KLE本家でキーの移動・追加・削除をしたJSONを取り込み、意図どおりに対応づくことを確認する
|
||||
|
||||
### P-16 KLE本家が拒否するJSONを取り込むか
|
||||
|
||||
- 位置づけ: 未解決の問い。本文「KLE JSONの取り込み」が扱えていない
|
||||
- 現状の認識: r, rx, ry を行の先頭以外に置いたJSONが公開されている(1505件中1件)。KLE本家はエラーにするが、
|
||||
VIAのパーサーは読める。本家に合わせて拒否するか、読めるものは読むかが未定
|
||||
- 格上げ条件: 取り込みの寛容さの方針をユーザーが決める
|
||||
|
||||
### P-6 キーキャップの見た目情報を持つ層
|
||||
|
||||
- 位置づけ: 本文「見た目の属性は保持する。どの層で持つかは決まっていない」の未解決部分
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# 公開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)` の形で読む。空文字への書き戻しが無視されるため、
|
||||
一度値を持ったキーの後ろに値を持たないキーが来る並びでは、前の値が後続に残る。
|
||||
|
||||
```txt
|
||||
入力: [[{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キーに漏れる)。
|
||||
並び順に依存する事故なので、出力側で回避しきれない。
|
||||
|
||||
## 検証スクリプト
|
||||
|
||||
コーパスと本家のコードを用意する。
|
||||
|
||||
```sh
|
||||
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ノードの相互変換と比較用フィンガープリント。
|
||||
|
||||
```js
|
||||
// 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` — コーパス全件の往復。
|
||||
|
||||
```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)));
|
||||
```
|
||||
Reference in New Issue
Block a user