diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..108b6d3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test diff --git a/.gitignore b/.gitignore index e325e0b..a7ff372 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -# .github/workflows/ is ignored -.github/workflows/ # OS files .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 9e33c39..760b3a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,48 +8,82 @@ DancingMen CipherLab is a web-based tool that implements the classical substitut ## Development Commands -This project has **no build, test, or lint commands**. It consists of static HTML, CSS, and JavaScript files that can be opened directly in a browser. +This project uses static HTML, CSS, and classic JavaScript. No build, npm install, or external dependencies are required. - **To run locally**: Simply open `index.html` in a web browser - **No dependencies**: No npm install, no build process, no external libraries - **Deployment**: Direct deployment to GitHub Pages (static hosting) +- **Tests**: `npm test` (Node.js 22 or later, built-in `node --test`); CI runs on push and pull_request +- **SVG check**: `node tools/build-full-svg.js --check` (no writes) +- **SVG regeneration**: `node tools/build-full-svg.js` (writes only assets/svg/full/) +- **Local HTTP**: `python -m http.server 8000 --bind 127.0.0.1`, then open http://127.0.0.1:8000/ ## Architecture Overview ### Core Components 1. **index.html**: Single-page application with three tabs (Encryption, Decryption, Substitution Table) -2. **script.js**: All cipher logic and UI interactions (~230 lines) +2. **script.js**: Input events, tabs, shared figure rendering, PNG export, decryption state, and native dialog 3. **style.css**: Styling and responsive design -4. **assets/svg/**: Dancing figure SVG images in two variants: - - `tight/`: Minimal spacing for text display +4. **assets/svg/**: Dancing figure SVG images in three variants (52 files each, 156 total): + - `tight/`: Immutable source for full SVG generation - `padded/`: With margins for individual character examination + - `full/`: Generated root viewBox expansion, used by both text displays and PNG export +5. **dancingmen-logic.js**: DOM-independent functions, exposed through globalThis.DancingMenLogic and CommonJS +6. **dancingmen-messages.js**: Japanese dictionary and formatter, exposed through globalThis and CommonJS +7. **test/**: Logic, samples, SVG integrity, layout, messages, HTML, contrast, formatting, static code, and README tests +8. **tools/build-full-svg.js**: Dependency-free Node generator, not used by the browser ### Key Architectural Decisions -1. **Space Encoding**: Spaces are represented by "flagged" characters (e.g., `Af.svg` indicates 'A' followed by a space). This preserves word boundaries while maintaining visual continuity. +1. **Flag rules**: A letter is flagged when its immediate next character is an ASCII space, or when it is the last character + of a line and the immediately following line contains a letter. Empty or space-only lines separate messages. -2. **Dual SVG Strategy**: Two SVG variants serve different purposes: - - Tight version for continuous text display +2. **SVG Strategy**: Keep the original 104 tight/padded assets byte-for-byte unchanged: + - Full version expands the tight viewBox by 5pt horizontally and 2pt vertically, without changing other SVG content + - Natural pt sizing, negative 5pt inline margins, flex-end alignment, and multiply blending preserve scale and advances - Padded version for character reference and selection + - Do not edit full files manually; regenerate them with the Node tool 3. **Pure Client-Side**: No backend, no API calls, no external dependencies. All cipher logic is contained within the browser. -4. **Event-Driven UI**: Tab switching and modal functionality use event delegation for efficiency. +4. **Event-Driven UI**: No inline handlers or styles. Decryption uses a token-line array rather than reading state from the DOM. + Tabs use roving tabindex and ArrowLeft/ArrowRight/Home/End without hard-coding the number of tabs. + The substitution table is built once. Only its buttons open the native dialog; Escape and close restore focus. ### Cipher Implementation The cipher follows these rules: - Each letter A-Z maps to a specific dancing figure -- Flagged versions (with 'f' suffix) indicate the character is followed by a space -- Total of 52 SVG files (26 letters × 2 states) +- Flagged versions use the 'f' suffix, following the two rules above +- Each SVG variant has 52 files (26 letters × 2 states) +- Font notation uses lowercase for unflagged letters and uppercase for flagged letters +- File-name notation accepts A.svg through Z.svg and Af.svg through Zf.svg +- Serializers trim outer blank rows and do not leave trailing spaces; internal blank rows remain +- Six original samples contain 76 letters over 12 rows when separated by blank rows +- Sample 6 has no trailing space; append one space to reproduce the source's final flagged E Key functions: -- `validateAndSanitizeInput()`: Input validation and sanitization (script.js:37-51) -- `encrypt()`: Converts plaintext to dancing figures (script.js:78-128) -- `appendDecryption()`: Builds decrypted text from clicked figures (script.js:189-200) -- Modal system for image zooming (script.js:3-22) +- `sanitizeInput()`: Normalize CRLF/CR, fullwidth Latin letters, and whitespace; remove unsupported code points; cap at 2,000 +- `caretAfterSanitize()`: Restore the caret using the sanitized prefix length +- `encryptText()`, `toFontText()`, `toFileNameText()`: Produce token lines and both notations +- `parseCipherText()`, `decodeTokens()`: Parse either notation and produce uppercase plaintext +- `renderFigures()`: Shared full-SVG renderer for encryption and decryption +- `layoutCipher()`: Logical PNG dimensions and figure coordinates +- `encrypt()`: UI processing; input events do nothing while isComposing, then compositionend processes committed text + +### PNG Export and Browser Security + +PNG export loads same-origin full SVG files with Image.decode(), caches each file in a Map, and draws them onto a white canvas +using multiply compositing at 2x scale. It rejects logical dimensions above 8,000px, saves toBlob output, and revokes the object URL. +No fetch or runtime SVG creation is used. file:// supports encryption, decryption, the table, and clipboard when permitted, +but PNG export deliberately shows the local-server guidance instead. Chromium on Windows has been verified; Firefox/Safari have not. +Clipboard absence or rejection is handled as a status message, without execCommand fallback. +Keep the meta CSP, no-referrer policy, and noopener noreferrer links. Meta CSP does not provide frame-ancestors protection. ### Japanese-First Design -The UI is primarily in Japanese, targeting Japanese Sherlock Holmes enthusiasts while maintaining international accessibility through the visual cipher system. \ No newline at end of file +The UI is Japanese. All dynamically displayed text, alt/title/aria labels, and messages must go through `t(key, values)` +and dancingmen-messages.js. The formatter rejects unknown keys and missing values. Do not add Japanese string literals to +script.js or dancingmen-logic.js. English dictionaries and a language switch are deferred to a later iteration. +Static HTML text may remain Japanese. Keep the README's sample/rule tables, file tree, and screenshots consistent with the code. diff --git a/README.md b/README.md index ba3f5df..14f3419 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,13 @@ hub: true ![GitHub forks](https://img.shields.io/github/forks/ipusiron/dancingmen-cipherlab?style=social) ![GitHub last commit](https://img.shields.io/github/last-commit/ipusiron/dancingmen-cipherlab) ![GitHub license](https://img.shields.io/github/license/ipusiron/dancingmen-cipherlab) +[![GitHub Pages](https://img.shields.io/badge/demo-GitHub%20Pages-blue?logo=github)](https://ipusiron.github.io/dancingmen-cipherlab/) **Day022 - 生成AIで作るセキュリティツール100** -**DancingMen CipherLab** は、シャーロック・ホームズの短編「踊る人形」に登場する古典暗号を再現・体験できるWebツールです。 - -本ツールでは、以下のような特徴を備えています: - -- 平文を入力すると自動で暗号化 -- 「旗付き人形」による空白表現 -- 視覚的に並ぶ人形で暗号の雰囲気を体感 -- 復号支援機能にも対応 - -本ツールは、古典暗号・推理小説・ビジュアル表現に興味をもつ初学者から研究者まで、幅広いユーザーを想定しています。 -学習・教育・創作支援など多様な用途に活用可能です。 -古典暗号やシャーロキアンに興味のある方、遊びながら暗号を学びたい方にもおすすめのツールです。 +**DancingMen CipherLab**は、シャーロック・ホームズの短編「踊る人形」に登場する古典暗号を再現・体験できるWebツールです。 +平文を人形へ暗号化し、人形の選択や暗号文の貼り付けで復号でき、原作6通のサンプルも試せます。 +古典暗号や推理小説に興味のある初学者から研究者まで、学習・教育・創作支援に利用できます。 --- @@ -65,76 +57,51 @@ hub: true ## 📸 スクリーンショット ->![暗号化の例](assets/encryption_example.png) ->*暗号化の例* -> ->![復号の例](assets/decryption_example.png) ->*復号の例* -> ->![置換表(平文文字がアルファベット、暗号文文字が人形文字)](assets/substitution_table.png) ->*置換表(平文文字がアルファベット、暗号文文字が人形文字)* +![暗号化の例](assets/screenshot.png) + +> *原作の5通目を2行で入力。1行目末尾のOにも旗が付き、ファイル名とフォント用の文字列を出力* + +![復号の例](assets/screenshot2.png) + +> *フォント用の文字列「comE herE aT oncE」を貼り付けて「COME HERE AT ONCE」へ復号* + +![置換表](assets/screenshot3.png) + +> *A〜Zの旗なし・旗ありの置換表。ボタンはクリックとキーボードの両方で拡大可能* --- -## 🔍 本ツールの主な機能 +## ✨ 主な機能 ### 暗号化タブ + - アルファベット(A-Z)の英文を入力すると、自動的に対応する「踊る人形」画像に変換 - 空白文字は「旗付き人形」として表現(例:`A` + 空白 = `Af.svg`) - リアルタイムで暗号化処理を実行 -- 生成された人形画像はクリックで拡大表示可能 +- 全角英字・全角スペース・タブを半角へ置き換え、IMEの変換確定後に処理 +- 原作6通と「6通すべて」の7種類のサンプル +- ファイル名の列とGL-DancingMenフォント用の文字列を出力し、後者をコピー可能 +- HTTP配信時は暗号文をPNGで保存可能(file://では保存不可) ### 復号タブ + - 人形画像を順番にクリックして復号文字列を構築 -- 旗付き人形は自動的に文字+空白として認識 +- 旗付き人形を語の終わりとして認識し、結果の行末には空白を残さない +- フォント用の文字列とファイル名の列を貼り付けて復号可能 - 復号中の文字列をリアルタイムで表示 - クリアボタンで復号文字列をリセット可能 ### 置換表タブ + - A-Zまでのアルファベットと対応する人形画像を一覧表示 - 各文字の旗なし版・旗付き版の両方を参照可能 - 画像クリックでモーダル拡大表示機能 ### その他の機能 + - レスポンシブデザイン対応 - 日本語UI(シャーロキアン向け) - 純粋なクライアントサイド実装(外部API不要) - ---- -## 🏗️ アーキテクチャ - -### 技術スタック -- **フロントエンド**: Pure HTML5 + CSS3 + Vanilla JavaScript -- **ビルドツール**: 不要(静的ファイルのみ) -- **外部依存**: なし -- **ホスティング**: GitHub Pages - -### 設計原則 -1. **シンプルさ**: ビルドプロセスや外部ライブラリに依存しない -2. **アクセシビリティ**: 誰でも簡単に利用・展開可能 -3. **保守性**: コード量を最小限に抑え、理解しやすい構造を維持 - -### 実装の特徴 -- **空白文字の処理**: 旗付き人形(`Af.svg`〜`Zf.svg`)で表現し、視覚的な連続性を保持 -- **SVGの2バージョン管理**: tight版(表示用)とpadded版(参照用)を使い分け -- **モーダルシステム**: 純粋なJavaScriptで実装した画像拡大表示機能 -- **イベント駆動設計**: 効率的なイベントデリゲーションによるUI制御 - ---- -## 📁 ディレクトリー構成 - -``` -dancingmen-cipherlab/ -├── index.html # メインUI(暗号化・復号・置換表) -├── script.js # 暗号化/復号処理とUI制御 -├── style.css # 各UI要素のスタイリング -├── assets/ -│ ├── svg/ -│ │ ├── tight/ # tight版SVG素材(デフォルト使用) -│ │ └── padded/ # padded版SVG素材(余白あり) -│ └── *.png # スクリーンショット用画像 -├── CLAUDE.md # 開発ガイドライン -└── README.md # 本ファイル -``` +- タブ・復号ボタン・置換表の拡大をキーボードだけで操作可能 --- ## 📖 使い方 @@ -142,36 +109,85 @@ dancingmen-cipherlab/ [デモページ](https://ipusiron.github.io/dancingmen-cipherlab/)にアクセスするだけで利用可能です。 ### 暗号化の手順 + 1. 「暗号化」タブを開く -2. テキストエリアに英文(a-z、A-Z、空白のみ)を入力 -3. 入力と同時に下部に踊る人形の暗号文が自動生成される -4. 各人形画像をクリックすると拡大表示される +2. テキストエリアに英文(a-z、A-Z、スペース、改行)を入力。全角英字と空白類は半角へ置換 +3. 入力と同時に下部に踊る人形の暗号文が自動生成される。「暗号化」ボタンでも実行可能 +4. 原作の例はサンプルを選び、「入力欄に入れる」を押す +5. フォント用の文字列をコピーするか、HTTP配信時は「暗号文をPNGで保存」を押す + +入力の上限は2,000文字です。 +変換・削除のメッセージは次の入力処理まで残り、修正の不要な入力を行うと消えます。 ### 復号の手順 + 1. 「復号」タブを開く -2. 表示される人形一覧から、復号したい順番でクリック -3. クリックした文字が下部のテキストエリアに追加される -4. 「クリア」ボタンで復号文字列をリセット可能 +2. 表示される人形一覧から、復号したい順番でクリックするか、Tabで選んでEnterを押す +3. 選んだ人形と復号結果が下部に追加される +4. テキストから復号する場合は、フォント用の文字列かファイル名の列を貼り付け、「貼り付けた暗号文を復号」を押す +5. 「1文字削除」で末尾を削除、「クリア」で状態と貼り付け欄をリセット。「復号結果をコピー」でコピー可能 + +フォント用の文字列は小文字が旗なし、大文字が旗ありです。 +空白だけでは語を区切らないため、`am here`は`AMHERE`、`aM here`は`AM HERE`に復号されます。 ### 置換表の確認 + 1. 「置換表」タブを開く 2. A-Zの各文字と対応する人形(旗なし・旗付き)を確認 -3. 画像クリックで拡大表示 +3. 画像クリック、またはTabで選択してEnterを押すと拡大表示 +4. Escか「閉じる」で閉じると、開いたボタンへフォーカスが戻る -### ローカル利用 +タブにフォーカスがあるときは←→で隣へ、Home/Endで最初/最後のタブへ移動できます。 +暗号化タブの人形は拡大操作の対象ではありません。 -本ツールはHTML + JavaScriptのみで構成されており、ローカル環境でそのまま動作します。 +--- +## 🚩 旗の規則 -```bash -# リポジトリをクローン -git clone https://github.com/ipusiron/dancingmen-cipherlab.git +旗は語の区切りを表します。 +まず全角英字・空白類を正規化し、次の2つの規則で旗を付けます。 -# ディレクトリに移動 -cd dancingmen-cipherlab +1. 文字の直後が半角スペースなら旗あり +2. 文字が行の最後にあり、すぐ次の行に英字が1つ以上あれば旗あり -# ブラウザで開く(例:macOS) -open index.html -``` +空行や空白だけの行は通の区切りです。 +その直前の行には規則2を適用しません。 +末尾スペースがなければ、各通の最後の文字は旗なしになります。 +表の`⏎`は改行、`␣`は半角スペースです。 + +| 入力 | フォント用の文字列 | 説明 | +|---|---|---| +| HELLO WORLD | hellO world | 語の最後の文字に旗が付く | +| HELLO WORLD | hellO world | 全角スペースは半角に置き換えてから処理する | +| MEET ME⏎AT NOON | meeT mE⏎aT noon | 続きの行があれば、行末の語にも旗が付く | +| HELLO⏎⏎WORLD | hello⏎⏎world | 空行で区切ると別の通になり、各通の最後の語には旗が付かない | +| COME HERE AT ONCE␣ | comE herE aT oncE | 末尾に半角スペースを入れると、最後の文字にも旗が付く | + +[原文(Project Gutenberg #108)](https://www.gutenberg.org/ebooks/108)では、旗は文を語に分けるために使われていると推測されています。 +また、旗がない暗号文は1語と考えられています。 + +--- +## 🏗️ アーキテクチャ + +### 技術スタック + +- **フロントエンド**: Pure HTML5 + CSS3 + Vanilla JavaScript +- **ビルドツール**: 不要(静的ファイルのみ) +- **外部依存**: なし +- **ホスティング**: GitHub Pages + +### 設計原則 + +1. **シンプルさ**: ビルドプロセスや外部ライブラリーに依存しない +2. **アクセシビリティ**: 誰でも簡単に利用・展開可能 +3. **保守性**: コード量を最小限に抑え、理解しやすい構造を維持 + +### 実装の特徴 + +- **空白文字の処理**: 旗付き人形(`Af.svg`〜`Zf.svg`)で表現し、視覚的な連続性を保持 +- **SVGの3バージョン管理**: full版(表示・PNG保存用)、padded版(参照・選択用)、tight版(full版の生成元) +- **モーダルシステム**: ネイティブのdialog要素による置換表の画像拡大 +- **イベント駆動設計**: 効率的なイベントデリゲーションによるUI制御 +- **分離**: 純粋ロジックはdancingmen-logic.js、文言の辞書はdancingmen-messages.js、画面処理はscript.js --- ## 🖋 使用フォントについて @@ -185,7 +201,7 @@ open index.html --- ### GL-DancingMenフォントの詳細 -本編中には5つの暗号文が登場します。 +本編中には6つの暗号文が登場します。 いずれも比較的短い暗号文であるため、'F'、'J'、'K'、'Q'、'U'、'W'、'X'、'Z'に対応する人形文字が登場しません。 そのため、これらの文字はオリジナルでデザインを採用しているとのことです。 @@ -193,13 +209,14 @@ open index.html 小文字が旗なし、大文字が旗あり、空白はそのまま空白に対応しています。 --- -### 🧷 SVG素材のバリエーションについて(padded版とtight版) +### 🧷 SVG素材のバリエーションについて(full版・padded版・tight版) -本ツールでは、表示される「踊る人形」のSVG素材について、以下の2種類を用意しています: +本ツールでは、表示される「踊る人形」のSVG素材について、以下の3種類を用意しています。 | バージョン | 説明 | 用途 | |------------|------|------| -| **tight版** | 余白を極限まで除去した正式版。`bbox_inches='tight'` に加え、左上基準配置やfigsize最小化などを組み合わせ、**人形同士の間隔がもっとも詰まった表示** になります。 | 暗号文の連続表示(デフォルト) | +| **full版** | tight版のviewBoxを左右5pt・上下2pt拡張。旗のはみ出しを含めた素材 | 暗号文の連続表示・PNG保存 | +| **tight版** | `bbox_inches='tight'`に加え、左上基準配置やfigsize最小化などで余白を最も詰めた既存素材 | full版の生成元 | | **padded版** | 初期生成版。余白(padding)が多めに含まれており、ゆったりとした見た目です。 | 個別文字の参照・比較用 | --- @@ -209,11 +226,11 @@ padded版SVGファイルとtight版SVGファイルを具体的にどう生成し #### padded版SVGの生成方法について -`assets/svg/padded/`以下に格納されているSVG素材は、`GL-DancingMen.ttf`の各文字をPython + Matplotlibを用いて以下の条件で生成したものです: +`assets/svg/padded/`以下に格納されているSVG素材は、`GL-DancingMen.ttf`の各文字をPython + Matplotlibを用いて以下の条件で生成したものです。 - **描画方式**:`matplotlib.pyplot.text()` により人形文字を描画 - **位置指定**:`x=0.5`, `y=0.5`, `ha='center'`, `va='center'`(中央寄せで描画) -- **出力設定**: +- **出力設定** - `figsize=(1, 1)` - `dpi=48` - `bbox_inches` の指定なし(デフォルト) @@ -226,11 +243,11 @@ padded版SVGファイルとtight版SVGファイルを具体的にどう生成し --- #### tight版SVGの生成方法について -デフォルトで使用されているSVG素材(`assets/svg/tight/` 以下)は、`GL-DancingMen.ttf`の各文字をベースにPython + Matplotlibによって以下の条件で自動生成したものです: +full版の生成元であるSVG素材(`assets/svg/tight/`以下)は、`GL-DancingMen.ttf`の各文字をベースにPython + Matplotlibによって以下の条件で自動生成したものです。 - **描画方式**:`matplotlib.pyplot.text()` により人形文字を描画 - **位置指定**:`x=0`, `y=1`, `ha='left'`, `va='top'`(左上に寄せて描画) -- **出力調整**: +- **出力調整** - `figsize=(0.4, 0.4)` - `dpi=120` - `bbox_inches='tight'` + `pad_inches=0` を指定し、**余白を極限まで除去** @@ -242,77 +259,117 @@ padded版SVGファイルとtight版SVGファイルを具体的にどう生成し ## 🔧 技術仕様 ### 暗号化アルゴリズム -```javascript -// 基本的な変換ルール -A-Z → A.svg - Z.svg(旗なし人形) -A-Z + 空白 → Af.svg - Zf.svg(旗付き人形) - -// 実装例(script.js:76-104より) -function encrypt(text) { - // 大文字変換と非アルファベット除去 - const processedText = text.toUpperCase().replace(/[^A-Z ]/g, ''); - - // 各文字を人形に変換 - for (let i = 0; i < processedText.length; i++) { - const char = processedText[i]; - const nextChar = processedText[i + 1]; - - if (char === ' ') continue; // 空白はスキップ - - // 次が空白またはEOFなら旗付き、それ以外は旗なし - const filename = (nextChar === ' ' || nextChar === undefined) - ? `${char}f.svg` - : `${char}.svg`; - } -} -``` + +`dancingmen-logic.js`の`sanitizeInput`で入力を正規化し、`encryptText`で文字と旗の状態を持つトークンの行配列にします。 +`toFileNameText`・`toFontText`が表記を変換し、`parseCipherText`・`decodeTokens`が貼り付けと復号を受け持ちます。 +旗の規則は前述の「旗の規則」を参照してください。 +画面の入力処理はIMEの変換中に値を書き換えず、確定後の置換ではキャレットを戻します。 ### ファイル命名規則 + - **通常文字**: `[A-Z].svg`(例:`A.svg`, `B.svg`) - **旗付き文字**: `[A-Z]f.svg`(例:`Af.svg`, `Bf.svg`) -- **合計**: 52ファイル(26文字 × 2状態) - -### モーダル実装 -```javascript -// 画像クリックで拡大表示(script.js:15-30) -modal.addEventListener('click', (e) => { - if (e.target === modal || e.target.classList.contains('close')) { - modal.style.display = 'none'; - } -}); +- **合計**: 各版52ファイル(26文字×2状態)、3版で156ファイル + +### 人形の表示とPNG保存 + +tight版のSVGは旗が`viewBox`の外へはみ出しています(右へ最大3.8pt、上へ最大1.49pt)。 +そこで`viewBox`を左右5pt・上下2pt広げたfull版を用意し、素の大きさで並べています。 +負のマージン(−5pt)でフォント本来の送り幅(通常38.4px)まで詰め、`mix-blend-mode: multiply`で白い背景を消します。 +両方のタブで共通の`renderFigures`を使い、CSSの`align-items: flex-end`で足元をそろえています。 + +PNG保存は`layoutCipher`で配置を決め、同じfull版をcanvasへ2倍の解像度で描きます。 +論理サイズの幅または高さが8,000pxを超える場合は保存しません。 +full版は`node tools/build-full-svg.js`で作り直せます。 +`node tools/build-full-svg.js --check`は書き込まずに差分を検査します。 +既存のtight版とpadded版は変更しません。 + +### モーダルと文言 + +置換表の拡大にはネイティブの`dialog.showModal()`を使い、Esc・閉じるボタン・背景クリックで閉じます。 +閉じた後は、開いたボタンへフォーカスを戻します。 +JavaScriptが表示する文言は`dancingmen-messages.js`の日本語辞書から`t`で取得します。 +英語辞書と言語切り替えは未実装です。 + +--- +## 🔒 セキュリティ + +- meta CSPで同一オリジンのスクリプト・CSS・画像だけを許可。インラインイベントとstyle属性は不使用 +- referrerをno-referrerに設定。別タブへのリンクにはrel="noopener noreferrer"を指定 +- 入力の正規化と長さ制限を実施。入力・貼り付け・メッセージはtextContentで表示 +- 外部API・CDN・アクセス解析を使用せず、ページの表示と操作で外部への通信は0件 +- コピーAPIが使えない、または拒否された場合は手動コピーの案内を表示 + +外部リンクをユーザーが開いたときは、そのリンク先へ通信します。 +metaのCSPではクリックジャッキングを防げません。 +GitHub Pagesの静的ファイルだけではX-Frame-OptionsなどのHTTPレスポンスヘッダーを設定できないため、この制約が残ります。 + +--- +## 🧪 テスト + +Node.js 22以上で、リポジトリーのルートから実行します。 +依存パッケージはなく、`npm install`は不要です。 + +```bash +npm test +node tools/build-full-svg.js --check ``` +GitHub Actionsはpushとpull_requestの両方で`npm test`を自動実行します。 +旗の規則・正規化・表記の往復・寸法・PNG配置・配色・HTML・辞書・READMEの表を検証します。 +既存SVG素材104枚の無改変をSHA-256で検証し、full版52枚が生成スクリプトの出力と一致することも検証します。 + --- -## 「踊る人形」を探究する +## 🔎 「踊る人形」を探究する 「踊る人形」(原題 *"The Dancing Men"*)は、アーサー・コナン・ドイルの短編集『シャーロック・ホームズの帰還』に収録されています。 -踊る人形では以下の5つの暗号文が登場します。 +踊る人形では以下の6つの暗号文が登場します。 > 最初の暗号文(依頼者であるヒルトン・キュビットがホームズに最初に見せた暗号文) > > "Am here Abe Slaney." > > 「私はここにいる。エイブ・スレーニー」 > > 2つ目の暗号文(2週間後にまた描かれていた暗号文) -> > "At Elriges. Come Elsie." -> >「エルリッジに。こい、エルシー」 +> > "At Elriges." +> >「エルリッジに」 +> +> 3つ目の暗号文(2通目の2日後の朝に、新しく描かれていた暗号文) +> > "Come Elsie." +> >「こい、エルシー」 > -> 3つ目の暗号文(犯人ではなく依頼者の妻エルシーが描いた暗号文) +> 4つ目の暗号文(犯人ではなく依頼者の妻エルシーが描いた暗号文) > > "Never." > >「決して」 > -> 4つ目の暗号文(ヒルトン・キュビットがホームズと別れた2日後に描かれた暗号文) -> > "Elsei prepare to meet the God." +> 5つ目の暗号文(ヒルトン・キュビットがホームズと別れた2日後に描かれた暗号文) +> > "Elsie prepare to meet thy God." > >「エルシー、神に会う準備はできたか」 > -> 5つ目の暗号文(ホームズが犯人をおびき出すために描いた暗号文) +> 6つ目の暗号文(ホームズが犯人をおびき出すために描いた暗号文) > > "Come here at once." > >「すぐ来て」 -本ツールを使えば、この5つの暗号文も生成できます。 +本ツールを使えば、この6つの暗号文も生成できます。 + +| 通 | 平文 | フォント用の文字列(小文字=旗なし、大文字=旗あり) | +|---|---|---| +| 1 | AM HERE ABE SLANEY | aM herE abE slaney | +| 2 | AT ELRIGES | aT elriges | +| 3 | COME ELSIE | comE elsie | +| 4 | NEVER | never | +| 5 | ELSIE PREPARE TO⏎MEET THY GOD | elsiE preparE tO⏎meeT thY god | +| 6 | COME HERE AT ONCE | comE herE aT once | ->![「踊る人形」に登場する暗号文](assets/ciphertext.png) ->*「踊る人形」に登場する暗号文* +1〜5通目の出力は、原著の挿絵をもとにしたフォントGL-DancingMen-Orgの[ReadMe](https://github.com/Gutenberg-Labo/GL-DancingMen/blob/main/documents/GL-DancingMen-Org-readme.txt)が挙げる表記と一致します。 +5通目は原著でも2行で、1行目の最後のOに旗があります。 +6通目はReadMeでは`comE herE aT oncE`と最後のEにも旗があり、ここだけ規則の例外です。 +本ツールで再現するには、入力の末尾に半角スペースを1つ入れます。 +サンプルは末尾スペースなしで収録しています。 + +![「踊る人形」に登場する暗号文](assets/dancingmen_messages.png) + +> *「6通すべて」を本ツールのPNG保存で出力。空行で通を区切り、5通目だけ2行* --- ### 踊る人形の暗号は単一換字式暗号 @@ -320,6 +377,7 @@ modal.addEventListener('click', (e) => { シンプルな単一換字式暗号であるため、頻度分析のアプローチが有効です。 「踊る人形」の中で、ホームズは頻度分析の知識を活用して、一番多く登場する人形文字が'E'であると推測しました。 +1通目は15個の人形のうち4個が同じで、ホームズはこれをEと見ました。 その後、旗は文字の区切りを意味することも突き止めます。 さらに推論を働かせて、暗号文を解読し、最終的に犯人に暗号文を送るに至るわけです。 @@ -350,16 +408,83 @@ modal.addEventListener('click', (e) => { --- #### 書籍 --『暗号解読事典』 P145 シャーロック・ホームズの『踊る人形』のサイファ --『暗号の話』P186 第9章 推理小説と暗号 +- 『暗号解読事典』P145 シャーロック・ホームズの『踊る人形』のサイファ +- 『暗号の話』P186 第9章 推理小説と暗号 + +--- +## 📁 ディレクトリー構造 + +```text +dancingmen-cipherlab/ # 「踊る人形」の暗号を体験するWebツール +├── .github/ # GitHubの設定 +│ └── workflows/ # GitHub Actionsのワークフロー +│ └── test.yml # pushとpull_requestでnpm testを実行 +├── .gitignore # Git管理から除外するファイルの指定 +├── .nojekyll # PagesのJekyll処理を無効化 +├── assets/ # 人形のSVG素材とREADMEに載せる画像 +│ ├── dancingmen_messages.png # 原作の暗号文6通。本ツールのPNG保存で出力 +│ ├── screenshot.png # 暗号化タブ。原作の5通目を2行で入力 +│ ├── screenshot2.png # 復号タブ。フォント用の文字列を貼り付けて復号 +│ ├── screenshot3.png # 置換表タブ +│ └── svg/ # GL-DancingMenフォントから生成した人形のSVG +│ ├── full/ # 左右5pt・上下2pt広げた52枚。暗号文の表示とPNG保存用 +│ ├── padded/ # 72×72ptの52枚。置換表と復号ボタン用 +│ └── tight/ # 余白を詰めた52枚。full版の生成元 +├── CLAUDE.md # AI向けの開発ガイド +├── dancingmen-logic.js # 画面に依存しない純粋なロジック +├── dancingmen-messages.js # 画面の文言の辞書とフォーマッター +├── index.html # 暗号化・復号・置換表の3タブ +├── LICENSE # 本ツールのMITライセンス +├── package.json # 依存なしのnpm test定義 +├── README.md # 本ドキュメント +├── script.js # 入力・タブ・描画・PNG保存・モーダル +├── style.css # CSS変数の配色とレスポンシブレイアウト +├── test/ # node --testの自動テスト +│ ├── assets.test.js # SVG156枚の対応・寸法・無改変・生成結果を検証 +│ ├── contrast.test.js # 文字色と面のコントラストを検証 +│ ├── format.test.js # 行長と読みやすさを検証 +│ ├── html.test.js # CSP・ARIA・インライン属性なしを検証 +│ ├── layout.test.js # 人形の寸法・viewBoxの拡張・PNG配置を検証 +│ ├── logic.test.js # 正規化・旗の規則・表記変換・往復を検証 +│ ├── messages.test.js # 文言の辞書と画面側のキーを検証 +│ ├── readme.test.js # 表・画像・ツリー・YAMLを検証 +│ ├── samples.test.js # 原作6通の既知解答を検証 +│ └── static.test.js # 純粋性・禁止した書き方・CI設定を検証 +└── tools/ # 公開ページから使わない開発用スクリプト + └── build-full-svg.js # tight版からfull版を生成。--checkで差分確認 +``` + +--- +## 💻 動作環境 + +静的HTML・CSS・JavaScriptだけで動作し、ビルドは不要です。 +WindowsのChromium 145で、HTTPとfile://の暗号化・復号・置換表・コピーを確認しました。 +コピーはブラウザーの権限設定に依存し、許可されない場合は手動コピーを案内します。 +Firefox・Safariは未検証です。 + +ローカル利用では、クローンした`index.html`をブラウザーで直接開けます。 +PNG保存だけはfile://では利用できないため、公開ページかローカルサーバーを使ってください。 + +```bash +git clone https://github.com/ipusiron/dancingmen-cipherlab.git +cd dancingmen-cipherlab +python -m http.server 8000 --bind 127.0.0.1 +``` + +その後、ブラウザーで`http://127.0.0.1:8000/`を開きます。 +このHTTP配信で「COME HERE AT ONCE」のPNGは1166×198、6通すべては1244×1502となることを確認しています。 --- ## 📄 ライセンス MIT License - 詳細は[LICENSE](LICENSE)をご覧ください。 +人形のSVGは、[GL-DancingMenフォント](https://github.com/Gutenberg-Labo/GL-DancingMen)(Copyright (C) 2007-2009 Das Ende der Wildnis、Copyright (C) 2008-2023 Gutenberg Labo)から生成したものです。 +同フォントは、改変の有無や商用・非商用を問わず、自由な利用・複製・再配布が認められています(無保証)。 +詳細はフォントの[LICENSE.txt](https://github.com/Gutenberg-Labo/GL-DancingMen/blob/main/LICENSE.txt)を参照してください。 + --- -## 🛠 このツールについて +## 🛠️ このツールについて 本ツールは、「生成AIで作るセキュリティツール100」プロジェクトの一環として開発されました。 このプロジェクトでは、AIの支援を活用しながら、セキュリティに関連するさまざまなツールを100日間にわたり制作・公開していく取り組みを行っています。 diff --git a/assets/ciphertext.png b/assets/ciphertext.png deleted file mode 100644 index 2c8b3e4..0000000 Binary files a/assets/ciphertext.png and /dev/null differ diff --git a/assets/dancingmen_messages.png b/assets/dancingmen_messages.png new file mode 100644 index 0000000..41be5ee Binary files /dev/null and b/assets/dancingmen_messages.png differ diff --git a/assets/decryption_example.png b/assets/decryption_example.png deleted file mode 100644 index c2812f1..0000000 Binary files a/assets/decryption_example.png and /dev/null differ diff --git a/assets/encryption_example.png b/assets/encryption_example.png deleted file mode 100644 index 1415058..0000000 Binary files a/assets/encryption_example.png and /dev/null differ diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000..ca271a4 Binary files /dev/null and b/assets/screenshot.png differ diff --git a/assets/screenshot2.png b/assets/screenshot2.png new file mode 100644 index 0000000..443fbf1 Binary files /dev/null and b/assets/screenshot2.png differ diff --git a/assets/screenshot3.png b/assets/screenshot3.png new file mode 100644 index 0000000..25e170f Binary files /dev/null and b/assets/screenshot3.png differ diff --git a/assets/substitution_table.png b/assets/substitution_table.png deleted file mode 100644 index a0d996c..0000000 Binary files a/assets/substitution_table.png and /dev/null differ diff --git a/assets/svg/full/A.svg b/assets/svg/full/A.svg new file mode 100644 index 0000000..aa065ba --- /dev/null +++ b/assets/svg/full/A.svg @@ -0,0 +1,114 @@ + + + + + + + + 2025-07-23T02:13:15.149412 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Af.svg b/assets/svg/full/Af.svg new file mode 100644 index 0000000..f346ee2 --- /dev/null +++ b/assets/svg/full/Af.svg @@ -0,0 +1,121 @@ + + + + + + + + 2025-07-23T02:13:15.173785 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/B.svg b/assets/svg/full/B.svg new file mode 100644 index 0000000..6f4bb83 --- /dev/null +++ b/assets/svg/full/B.svg @@ -0,0 +1,93 @@ + + + + + + + + 2025-07-23T02:13:15.202944 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Bf.svg b/assets/svg/full/Bf.svg new file mode 100644 index 0000000..1bbdee8 --- /dev/null +++ b/assets/svg/full/Bf.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:15.238970 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/C.svg b/assets/svg/full/C.svg new file mode 100644 index 0000000..0e099a7 --- /dev/null +++ b/assets/svg/full/C.svg @@ -0,0 +1,107 @@ + + + + + + + + 2025-07-23T02:13:15.268987 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Cf.svg b/assets/svg/full/Cf.svg new file mode 100644 index 0000000..fad29a6 --- /dev/null +++ b/assets/svg/full/Cf.svg @@ -0,0 +1,117 @@ + + + + + + + + 2025-07-23T02:13:15.298724 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/D.svg b/assets/svg/full/D.svg new file mode 100644 index 0000000..d853aa3 --- /dev/null +++ b/assets/svg/full/D.svg @@ -0,0 +1,97 @@ + + + + + + + + 2025-07-23T02:13:15.350419 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Df.svg b/assets/svg/full/Df.svg new file mode 100644 index 0000000..49820ee --- /dev/null +++ b/assets/svg/full/Df.svg @@ -0,0 +1,106 @@ + + + + + + + + 2025-07-23T02:13:15.376384 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/E.svg b/assets/svg/full/E.svg new file mode 100644 index 0000000..aae41ba --- /dev/null +++ b/assets/svg/full/E.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:15.404575 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Ef.svg b/assets/svg/full/Ef.svg new file mode 100644 index 0000000..a58b899 --- /dev/null +++ b/assets/svg/full/Ef.svg @@ -0,0 +1,112 @@ + + + + + + + + 2025-07-23T02:13:15.433606 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/F.svg b/assets/svg/full/F.svg new file mode 100644 index 0000000..a57a320 --- /dev/null +++ b/assets/svg/full/F.svg @@ -0,0 +1,112 @@ + + + + + + + + 2025-07-23T02:13:15.457667 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Ff.svg b/assets/svg/full/Ff.svg new file mode 100644 index 0000000..16b23c0 --- /dev/null +++ b/assets/svg/full/Ff.svg @@ -0,0 +1,123 @@ + + + + + + + + 2025-07-23T02:13:15.481690 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/G.svg b/assets/svg/full/G.svg new file mode 100644 index 0000000..c082a67 --- /dev/null +++ b/assets/svg/full/G.svg @@ -0,0 +1,96 @@ + + + + + + + + 2025-07-23T02:13:15.514475 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Gf.svg b/assets/svg/full/Gf.svg new file mode 100644 index 0000000..a8f75c4 --- /dev/null +++ b/assets/svg/full/Gf.svg @@ -0,0 +1,106 @@ + + + + + + + + 2025-07-23T02:13:15.544682 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/H.svg b/assets/svg/full/H.svg new file mode 100644 index 0000000..588a016 --- /dev/null +++ b/assets/svg/full/H.svg @@ -0,0 +1,100 @@ + + + + + + + + 2025-07-23T02:13:15.584795 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Hf.svg b/assets/svg/full/Hf.svg new file mode 100644 index 0000000..33d5867 --- /dev/null +++ b/assets/svg/full/Hf.svg @@ -0,0 +1,107 @@ + + + + + + + + 2025-07-23T02:13:15.611025 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/I.svg b/assets/svg/full/I.svg new file mode 100644 index 0000000..8ee0fa2 --- /dev/null +++ b/assets/svg/full/I.svg @@ -0,0 +1,105 @@ + + + + + + + + 2025-07-23T02:13:15.641730 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/If.svg b/assets/svg/full/If.svg new file mode 100644 index 0000000..c54c275 --- /dev/null +++ b/assets/svg/full/If.svg @@ -0,0 +1,113 @@ + + + + + + + + 2025-07-23T02:13:15.666338 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/J.svg b/assets/svg/full/J.svg new file mode 100644 index 0000000..42255f8 --- /dev/null +++ b/assets/svg/full/J.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:15.698680 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Jf.svg b/assets/svg/full/Jf.svg new file mode 100644 index 0000000..88d56a0 --- /dev/null +++ b/assets/svg/full/Jf.svg @@ -0,0 +1,111 @@ + + + + + + + + 2025-07-23T02:13:15.723324 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/K.svg b/assets/svg/full/K.svg new file mode 100644 index 0000000..3b0a4d3 --- /dev/null +++ b/assets/svg/full/K.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:15.761163 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Kf.svg b/assets/svg/full/Kf.svg new file mode 100644 index 0000000..1f616d2 --- /dev/null +++ b/assets/svg/full/Kf.svg @@ -0,0 +1,111 @@ + + + + + + + + 2025-07-23T02:13:15.784659 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/L.svg b/assets/svg/full/L.svg new file mode 100644 index 0000000..972f86b --- /dev/null +++ b/assets/svg/full/L.svg @@ -0,0 +1,122 @@ + + + + + + + + 2025-07-23T02:13:15.815528 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Lf.svg b/assets/svg/full/Lf.svg new file mode 100644 index 0000000..e254938 --- /dev/null +++ b/assets/svg/full/Lf.svg @@ -0,0 +1,130 @@ + + + + + + + + 2025-07-23T02:13:15.854127 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/M.svg b/assets/svg/full/M.svg new file mode 100644 index 0000000..e830425 --- /dev/null +++ b/assets/svg/full/M.svg @@ -0,0 +1,118 @@ + + + + + + + + 2025-07-23T02:13:15.878757 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Mf.svg b/assets/svg/full/Mf.svg new file mode 100644 index 0000000..237d834 --- /dev/null +++ b/assets/svg/full/Mf.svg @@ -0,0 +1,124 @@ + + + + + + + + 2025-07-23T02:13:15.903188 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/N.svg b/assets/svg/full/N.svg new file mode 100644 index 0000000..c126ff9 --- /dev/null +++ b/assets/svg/full/N.svg @@ -0,0 +1,116 @@ + + + + + + + + 2025-07-23T02:13:15.927882 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Nf.svg b/assets/svg/full/Nf.svg new file mode 100644 index 0000000..f01216b --- /dev/null +++ b/assets/svg/full/Nf.svg @@ -0,0 +1,123 @@ + + + + + + + + 2025-07-23T02:13:15.960938 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/O.svg b/assets/svg/full/O.svg new file mode 100644 index 0000000..44dcc70 --- /dev/null +++ b/assets/svg/full/O.svg @@ -0,0 +1,110 @@ + + + + + + + + 2025-07-23T02:13:15.992317 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Of.svg b/assets/svg/full/Of.svg new file mode 100644 index 0000000..ec3a8bc --- /dev/null +++ b/assets/svg/full/Of.svg @@ -0,0 +1,120 @@ + + + + + + + + 2025-07-23T02:13:16.032505 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/P.svg b/assets/svg/full/P.svg new file mode 100644 index 0000000..556f1fc --- /dev/null +++ b/assets/svg/full/P.svg @@ -0,0 +1,93 @@ + + + + + + + + 2025-07-23T02:13:16.219125 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Pf.svg b/assets/svg/full/Pf.svg new file mode 100644 index 0000000..8b51891 --- /dev/null +++ b/assets/svg/full/Pf.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:16.240864 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Q.svg b/assets/svg/full/Q.svg new file mode 100644 index 0000000..7f5a630 --- /dev/null +++ b/assets/svg/full/Q.svg @@ -0,0 +1,92 @@ + + + + + + + + 2025-07-23T02:13:16.262771 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Qf.svg b/assets/svg/full/Qf.svg new file mode 100644 index 0000000..eaba020 --- /dev/null +++ b/assets/svg/full/Qf.svg @@ -0,0 +1,103 @@ + + + + + + + + 2025-07-23T02:13:16.287268 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/R.svg b/assets/svg/full/R.svg new file mode 100644 index 0000000..46a34d3 --- /dev/null +++ b/assets/svg/full/R.svg @@ -0,0 +1,110 @@ + + + + + + + + 2025-07-23T02:13:16.310682 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Rf.svg b/assets/svg/full/Rf.svg new file mode 100644 index 0000000..d6ad6db --- /dev/null +++ b/assets/svg/full/Rf.svg @@ -0,0 +1,116 @@ + + + + + + + + 2025-07-23T02:13:16.332235 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/S.svg b/assets/svg/full/S.svg new file mode 100644 index 0000000..427ff4e --- /dev/null +++ b/assets/svg/full/S.svg @@ -0,0 +1,125 @@ + + + + + + + + 2025-07-23T02:13:16.354028 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Sf.svg b/assets/svg/full/Sf.svg new file mode 100644 index 0000000..777b00e --- /dev/null +++ b/assets/svg/full/Sf.svg @@ -0,0 +1,133 @@ + + + + + + + + 2025-07-23T02:13:16.374579 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/T.svg b/assets/svg/full/T.svg new file mode 100644 index 0000000..496264c --- /dev/null +++ b/assets/svg/full/T.svg @@ -0,0 +1,104 @@ + + + + + + + + 2025-07-23T02:13:16.395022 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Tf.svg b/assets/svg/full/Tf.svg new file mode 100644 index 0000000..505ef7a --- /dev/null +++ b/assets/svg/full/Tf.svg @@ -0,0 +1,116 @@ + + + + + + + + 2025-07-23T02:13:16.415436 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/U.svg b/assets/svg/full/U.svg new file mode 100644 index 0000000..2f8109d --- /dev/null +++ b/assets/svg/full/U.svg @@ -0,0 +1,116 @@ + + + + + + + + 2025-07-23T02:13:16.435194 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Uf.svg b/assets/svg/full/Uf.svg new file mode 100644 index 0000000..6949711 --- /dev/null +++ b/assets/svg/full/Uf.svg @@ -0,0 +1,126 @@ + + + + + + + + 2025-07-23T02:13:16.456425 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/V.svg b/assets/svg/full/V.svg new file mode 100644 index 0000000..3b3ecbc --- /dev/null +++ b/assets/svg/full/V.svg @@ -0,0 +1,94 @@ + + + + + + + + 2025-07-23T02:13:16.478333 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Vf.svg b/assets/svg/full/Vf.svg new file mode 100644 index 0000000..0fd91cc --- /dev/null +++ b/assets/svg/full/Vf.svg @@ -0,0 +1,105 @@ + + + + + + + + 2025-07-23T02:13:16.497885 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/W.svg b/assets/svg/full/W.svg new file mode 100644 index 0000000..d7d62d1 --- /dev/null +++ b/assets/svg/full/W.svg @@ -0,0 +1,93 @@ + + + + + + + + 2025-07-23T02:13:16.517708 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Wf.svg b/assets/svg/full/Wf.svg new file mode 100644 index 0000000..9b4fc1d --- /dev/null +++ b/assets/svg/full/Wf.svg @@ -0,0 +1,105 @@ + + + + + + + + 2025-07-23T02:13:16.540360 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/X.svg b/assets/svg/full/X.svg new file mode 100644 index 0000000..be59005 --- /dev/null +++ b/assets/svg/full/X.svg @@ -0,0 +1,108 @@ + + + + + + + + 2025-07-23T02:13:16.565461 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Xf.svg b/assets/svg/full/Xf.svg new file mode 100644 index 0000000..8b3fca6 --- /dev/null +++ b/assets/svg/full/Xf.svg @@ -0,0 +1,117 @@ + + + + + + + + 2025-07-23T02:13:16.589146 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Y.svg b/assets/svg/full/Y.svg new file mode 100644 index 0000000..79e0951 --- /dev/null +++ b/assets/svg/full/Y.svg @@ -0,0 +1,110 @@ + + + + + + + + 2025-07-23T02:13:16.609921 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Yf.svg b/assets/svg/full/Yf.svg new file mode 100644 index 0000000..df98c56 --- /dev/null +++ b/assets/svg/full/Yf.svg @@ -0,0 +1,116 @@ + + + + + + + + 2025-07-23T02:13:16.630317 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Z.svg b/assets/svg/full/Z.svg new file mode 100644 index 0000000..6f10521 --- /dev/null +++ b/assets/svg/full/Z.svg @@ -0,0 +1,107 @@ + + + + + + + + 2025-07-23T02:13:16.651290 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/full/Zf.svg b/assets/svg/full/Zf.svg new file mode 100644 index 0000000..4e3aa43 --- /dev/null +++ b/assets/svg/full/Zf.svg @@ -0,0 +1,118 @@ + + + + + + + + 2025-07-23T02:13:16.678074 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dancingmen-logic.js b/dancingmen-logic.js new file mode 100644 index 0000000..1ae5df5 --- /dev/null +++ b/dancingmen-logic.js @@ -0,0 +1,224 @@ +// Pure cipher operations shared by classic scripts and Node tests. +const DancingMenLogic = (() => { + const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const MAX_INPUT_LENGTH = 2000; + const FULL_PAD_X = 5; + const FULL_PAD_Y = 2; + const PX_PER_PT = 4 / 3; + + function sanitizeInput(raw) { + let text = ""; + const removed = new Set(); + let replacedSpaces = 0; + let replacedFullwidth = 0; + for (const char of raw.replace(/\r\n?/g, "\n")) { + if (/^[A-Za-z \n]$/.test(char)) { + text += char; + } else if (/^[\uFF21-\uFF3A\uFF41-\uFF5A]$/.test(char)) { + text += String.fromCodePoint(char.codePointAt(0) - 0xFEE0); + replacedFullwidth++; + } else if (/\s/.test(char)) { + text += " "; + replacedSpaces++; + } else { + removed.add(char); + } + } + const truncated = text.length > MAX_INPUT_LENGTH; + return { + text: text.slice(0, MAX_INPUT_LENGTH), removed: [...removed], + replacedSpaces, replacedFullwidth, truncated + }; + } + + function caretAfterSanitize(raw, caret) { + return sanitizeInput(raw.slice(0, caret)).text.length; + } + + function feedbackItems(result) { + const items = []; + if (result.removed.length) { + items.push({ key: "feedback.removed", values: { chars: [...result.removed] } }); + } + if (result.replacedFullwidth || result.replacedSpaces) { + items.push({ key: "feedback.replaced", values: {} }); + } + if (result.truncated) { + items.push({ key: "feedback.truncated", values: { max: "2,000" } }); + } + return items; + } + + function encryptText(raw) { + const rows = sanitizeInput(raw).text.split("\n"); + return rows.map((row, rowIndex) => { + const tokens = []; + for (let index = 0; index < row.length; index++) { + const char = row[index]; + if (!/[A-Za-z]/.test(char)) continue; + const flag = row[index + 1] === " " || + (index === row.length - 1 && /[A-Za-z]/.test(rows[rowIndex + 1] || "")); + tokens.push({ letter: char.toUpperCase(), flag }); + } + return tokens; + }); + } + + function countLetters(lines) { + return lines.reduce((total, row) => total + row.length, 0); + } + + function trimEmptyRows(lines) { + let first = 0; + let last = lines.length; + while (first < last && !lines[first].length) first++; + while (last > first && !lines[last - 1].length) last--; + return lines.slice(first, last); + } + + function tokenFileName(token) { + return `${token.letter}${token.flag ? "f" : ""}.svg`; + } + + function toFileNameText(lines) { + return trimEmptyRows(lines).map(row => row.map(tokenFileName).join(" ")).join("\n"); + } + + function textFromTokens(lines, font) { + return trimEmptyRows(lines).map(row => row.map((token, index) => { + const char = font && !token.flag ? token.letter.toLowerCase() : token.letter; + return char + (token.flag && index < row.length - 1 ? " " : ""); + }).join("")).join("\n"); + } + + function toFontText(lines) { + return textFromTokens(lines, true); + } + + function decodeTokens(lines) { + return textFromTokens(lines, false); + } + + function normalizePlain(raw) { + const rows = sanitizeInput(raw).text.toUpperCase().split("\n") + .map(row => row.replace(/ +/g, " ").trim()); + return trimEmptyRows(rows).join("\n"); + } + + function parseCipherText(raw) { + const notation = /\.svg/i.test(raw) ? "filenames" : "font"; + const invalid = new Set(); + const lines = raw.replace(/\r\n?/g, "\n").split("\n").map(row => { + const tokens = []; + if (notation === "filenames") { + for (const word of row.split(/\s+/).filter(Boolean)) { + const match = /^([A-Za-z])(f?)\.svg$/.exec(word); + if (match) tokens.push({ letter: match[1].toUpperCase(), flag: Boolean(match[2]) }); + else invalid.add(word); + } + } else { + for (const char of row) { + if (/[A-Za-z]/.test(char)) { + tokens.push({ letter: char.toUpperCase(), flag: /[A-Z]/.test(char) }); + } else if (!/\s/.test(char)) { + invalid.add(char); + } + } + } + return tokens; + }); + return { lines, invalid: [...invalid], notation }; + } + + const flagHeights = [ + 43.965, 43.2, 40.9425, 43.485, 42.0975, 41.6175, 42.0975, 44.445, 44.445, + 44.445, 42.0975, 41.2275, 39.5475, 39.5475, 42.0975, 46.0275, 45.5475, + 43.965, 40.7025, 43.485, 40.9425, 46.0275, 45.5475, 39.5475, 39.5475, 40.9425 + ]; + const TIGHT_VIEWBOX = {}; + [...LETTERS].forEach((letter, index) => { + TIGHT_VIEWBOX[letter] = Object.freeze([28.8, 38.9775]); + const width = letter === "I" ? 31.8225 : letter === "M" ? 28.845 : 28.8; + TIGHT_VIEWBOX[`${letter}f`] = Object.freeze([width, flagHeights[index]]); + }); + Object.freeze(TIGHT_VIEWBOX); + + function figureSize(token) { + const [width, height] = TIGHT_VIEWBOX[tokenFileName(token).replace(".svg", "")]; + return { + width: (width + 2 * FULL_PAD_X) * PX_PER_PT, + height: (height + 2 * FULL_PAD_Y) * PX_PER_PT, + advance: width * PX_PER_PT + }; + } + + function expandSvgViewBox(svgText, padX = FULL_PAD_X, padY = FULL_PAD_Y) { + return svgText.replace(/]*>/, tag => { + const viewBox = /\bviewBox="([^"]+)"/.exec(tag); + if (!viewBox) throw new Error("Missing root viewBox"); + const [, , width, height] = viewBox[1].split(/\s+/).map(Number); + const round = value => Number(value.toFixed(4)); + const expandedWidth = round(width + 2 * padX); + const expandedHeight = round(height + 2 * padY); + return tag.replace(/\bwidth="[^"]*"/, `width="${expandedWidth}pt"`) + .replace(/\bheight="[^"]*"/, `height="${expandedHeight}pt"`) + .replace(/\bviewBox="[^"]*"/, `viewBox="${-padX} ${-padY} ${expandedWidth} ${expandedHeight}"`); + }); + } + + function layoutCipher(lines) { + const MARGIN = 16; + const LINE_GAP = 12; + const BLANK_ROW_HEIGHT = 24; + const ROW_HEIGHT = (46.0275 + 2 * FULL_PAD_Y) * PX_PER_PT; + const rows = trimEmptyRows(lines); + const figures = []; + const round = value => Number(value.toFixed(2)); + let top = MARGIN; + let bottom = MARGIN; + let longest = 0; + for (const row of rows) { + bottom = top + (row.length ? ROW_HEIGHT : BLANK_ROW_HEIGHT); + let x = MARGIN; + for (const token of row) { + const size = figureSize(token); + figures.push({ + name: tokenFileName(token), x: round(x), y: round(bottom - size.height), + w: round(size.width), h: round(size.height) + }); + x += size.advance; + } + longest = Math.max(longest, x - MARGIN); + top = bottom + LINE_GAP; + } + return { + width: Math.ceil(longest + 2 * FULL_PAD_X * PX_PER_PT + 2 * MARGIN), + height: Math.ceil(bottom + MARGIN), rows: rows.length, figures + }; + } + + const SAMPLES = Object.freeze([ + { id: "m1", text: "AM HERE ABE SLANEY" }, + { id: "m2", text: "AT ELRIGES" }, + { id: "m3", text: "COME ELSIE" }, + { id: "m4", text: "NEVER" }, + { id: "m5", text: "ELSIE PREPARE TO\nMEET THY GOD" }, + { id: "m6", text: "COME HERE AT ONCE" } + ].map(Object.freeze)); + + function allSamplesText() { + return SAMPLES.map(sample => sample.text).join("\n\n"); + } + + return { + LETTERS, MAX_INPUT_LENGTH, sanitizeInput, caretAfterSanitize, feedbackItems, + encryptText, tokenFileName, toFileNameText, toFontText, parseCipherText, + decodeTokens, normalizePlain, countLetters, TIGHT_VIEWBOX, FULL_PAD_X, + FULL_PAD_Y, PX_PER_PT, figureSize, expandSvgViewBox, layoutCipher, SAMPLES, allSamplesText + }; +})(); + +globalThis.DancingMenLogic = DancingMenLogic; +if (typeof module === "object" && module.exports) { + module.exports = DancingMenLogic; +} diff --git a/dancingmen-messages.js b/dancingmen-messages.js new file mode 100644 index 0000000..6ac4eea --- /dev/null +++ b/dancingmen-messages.js @@ -0,0 +1,54 @@ +// UI-independent messages. Additional languages can share the same keys. +const DancingMenMessages = (() => { + const DEFAULT_LANG = "ja"; + const dictionaries = { + ja: { + "list.open": "「", + "list.close": "」", + "feedback.removed": "使えない文字を取り除きました: {chars}", + "feedback.replaced": "全角の英字・スペースやタブを半角に置き換えました", + "feedback.truncated": "{max}文字を超えた分は切り捨てました", + "feedback.separator": " / ", + "encrypt.count": "暗号化可能文字数: {count}", + "figure.letter": "{letter}", + "figure.flag": "{letter}(旗あり)", + "sample.label": "{number}通目 {text}", + "sample.lineSeparator": "/", + "sample.multiline": "(2行)", + "sample.all": "{count}通すべて(空行で区切る)", + "copy.fontSuccess": "フォント用の文字列をコピーしました", + "copy.failure": "コピーできませんでした。文字列を選択して手動でコピーしてください", + "save.tooLarge": "画像が大きすぎて保存できません。1行を短くするか、行数を減らしてください", + "save.unavailable": "このページをfile://で開いているときはPNGを保存できません。" + + "公開ページか、ローカルサーバー(python -m http.server など)で開いてください", + "save.success": "暗号文をPNGで保存しました", + "figure.plain": "{letter}(旗なし)", + "figure.zoom": "{figure}を拡大", + "decode.tooLong": "人形が{max}個を超える暗号文は復号できません。短くしてから貼り付けてください", + "decode.invalid": "読めなかった部分: {chars}", + "decode.more": " ほか{count}件", + "copy.decryptSuccess": "復号結果をコピーしました" + } + }; + + function format(lang, key, values = {}) { + const dictionary = dictionaries[lang]; + if (!dictionary || !Object.hasOwn(dictionary, key)) { + throw new Error(`Unknown message: ${lang}/${key}`); + } + return dictionary[key].replace(/\{([^}]+)\}/g, (_, name) => { + if (!Object.hasOwn(values, name)) throw new Error(`Missing message value: ${name}`); + const value = values[name]; + return Array.isArray(value) + ? value.map(item => dictionary["list.open"] + item + dictionary["list.close"]).join("") + : String(value); + }); + } + + return { DEFAULT_LANG, dictionaries, format }; +})(); + +globalThis.DancingMenMessages = DancingMenMessages; +if (typeof module === "object" && module.exports) { + module.exports = DancingMenMessages; +} diff --git a/index.html b/index.html index 2ab79ad..56b8fe4 100644 --- a/index.html +++ b/index.html @@ -3,89 +3,122 @@ + + + DancingMen CipherLab +

DancingMen CipherLab

シャーロック・ホームズの「踊る人形」暗号を体験できるツールです。

-
-
+

🔐 暗号化

平文を入力してください(A〜Zとスペース、改行)
- ※英字(A-Z)とスペースのみ入力可能です + A〜Zとスペース、改行が使えます。全角の英字とスペースは半角に置き換えます。 暗号化可能文字数: 0
-
- +
+
+ + + +
+
+ +

暗号文(ファイル名の列)


         
+
+

暗号文(GL-DancingMenフォント用の文字列)

+

+          

小文字が旗なし、大文字が旗あり。GL-DancingMenフォントを指定すると、そのまま踊る人形になります。復号タブへ貼り付けると元の文に戻せます。

+ +
+
-
+ -
+
- + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7ed24d --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "dancingmen-cipherlab", + "private": true, + "scripts": { + "test": "node --test" + } +} diff --git a/script.js b/script.js index 71451bd..7e47c2a 100644 --- a/script.js +++ b/script.js @@ -1,232 +1,332 @@ // script.js +const logic = DancingMenLogic; +const t = (key, values) => DancingMenMessages.format(DancingMenMessages.DEFAULT_LANG, key, values); +let compositionCommitValue = null; +let encryptedLines = [[]]; +let decryptionLines = [[]]; +let modalTrigger = null; +const exportImages = new Map(); +const EXPORT_SCALE = 2; + document.addEventListener("DOMContentLoaded", () => { - document.body.addEventListener("click", (e) => { - if (e.target.matches(".key-entry img")) { - const modal = document.getElementById("modal"); - const modalImg = document.getElementById("modal-img"); - modalImg.src = e.target.src; - modalImg.alt = e.target.alt; - modalImg.removeAttribute("width"); - modalImg.removeAttribute("height"); - modalImg.className = "modal-img zoomed"; - modal.classList.add("show"); - } + const modal = document.getElementById("modal"); + document.querySelector("#table-panel .key-table").addEventListener("click", event => { + const button = event.target.closest(".figure-zoom"); + if (!button) return; + modalTrigger = button; + const modalImg = document.getElementById("modal-img"); + modalImg.src = button.querySelector("img").src; + modalImg.alt = button.getAttribute("aria-label"); + modal.showModal(); }); - + modal.addEventListener("click", event => { + if (event.target === modal) closeModal(); + }); + modal.addEventListener("close", () => modalTrigger?.focus()); + document.getElementById("modal-close").addEventListener("click", closeModal); + document.querySelector(".tab-menu").addEventListener("click", event => { + const tab = event.target.closest('[role="tab"]'); + if (tab) switchTab(tab.dataset.tab); + }); + document.querySelector(".tab-menu").addEventListener("keydown", handleTabKey); generateDecryptButtons(); + generateTable(); + document.getElementById("decrypt-buttons").addEventListener("click", event => { + const button = event.target.closest("button[data-char]"); + if (button) appendDecryption(button.dataset.char, button.dataset.flag === "true"); + }); + document.getElementById("decrypt-clear").addEventListener("click", clearDecryption); + document.getElementById("decrypt-delete").addEventListener("click", removeLastDecryption); + document.getElementById("copy-decryption").addEventListener("click", copyDecryption); + document.getElementById("decode-paste").addEventListener("click", decodePaste); + const plaintext = document.getElementById("plaintext"); + plaintext.addEventListener("input", event => { + if (event.isComposing) return; + const duplicateCommit = compositionCommitValue === plaintext.value; + compositionCommitValue = null; + if (!duplicateCommit) encrypt(); + }); + plaintext.addEventListener("compositionend", () => { + encrypt(); + compositionCommitValue = plaintext.value; + }); + document.getElementById("encrypt-button").addEventListener("click", encrypt); + generateSamples(); + document.getElementById("sample-load").addEventListener("click", () => { + const id = document.getElementById("sample-select").value; + plaintext.value = id === "all" ? logic.allSamplesText() : logic.SAMPLES.find(sample => sample.id === id).text; + encrypt(); + }); + document.getElementById("copy-font").addEventListener("click", copyFontText); + document.getElementById("save-png").addEventListener("click", savePng); }); function closeModal() { - document.getElementById("modal").classList.remove("show"); + document.getElementById("modal").close(); } function switchTab(tabName) { const panels = document.querySelectorAll(".tab-panel"); const buttons = document.querySelectorAll(".tab-button"); - panels.forEach(panel => panel.classList.remove("active")); - buttons.forEach(btn => btn.classList.remove("active")); - - document.getElementById(`${tabName}-panel`).classList.add("active"); - document.querySelector(`.tab-button[onclick="switchTab('${tabName}')"]`).classList.add("active"); - - if (tabName === "table") generateTable(); + panels.forEach(panel => { + const selected = panel.id === `${tabName}-panel`; + panel.classList.toggle("active", selected); + panel.hidden = !selected; + }); + buttons.forEach(button => { + const selected = button.dataset.tab === tabName; + button.classList.toggle("active", selected); + button.setAttribute("aria-selected", String(selected)); + button.tabIndex = selected ? 0 : -1; + }); + document.querySelector(`[data-tab="${tabName}"]`).focus(); } -function validateAndSanitizeInput(input) { - // 許可する文字: A-Z, a-z, スペース、改行 - const allowedPattern = /[^A-Za-z\s\n\r]/g; - const invalidChars = input.match(allowedPattern); - - // 無効な文字を削除 - const sanitized = input.replace(allowedPattern, ''); - - return { - sanitized: sanitized, - hasInvalidChars: invalidChars !== null, - invalidChars: invalidChars ? [...new Set(invalidChars)] : [], - validCharCount: sanitized.replace(/[\s\n\r]/g, '').length - }; +function handleTabKey(event) { + const tabs = [...document.querySelectorAll('[role="tab"]')]; + const index = tabs.indexOf(event.target); + if (index < 0) return; + let next; + if (event.key === "ArrowRight") next = (index + 1) % tabs.length; + if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length; + if (event.key === "Home") next = 0; + if (event.key === "End") next = tabs.length - 1; + if (next !== undefined) { + event.preventDefault(); + switchTab(tabs[next].dataset.tab); + } } function showValidationFeedback(validationResult) { const feedbackElement = document.getElementById('validation-feedback'); - const charCountElement = document.getElementById('char-count'); - - if (!feedbackElement || !charCountElement) return; - - // 文字数の更新 - charCountElement.textContent = `暗号化可能文字数: ${validationResult.validCharCount}`; - - // エラーメッセージの表示/非表示 - if (validationResult.hasInvalidChars) { - feedbackElement.textContent = `無効な文字が削除されました: ${validationResult.invalidChars.join(', ')}`; - feedbackElement.style.display = 'block'; - feedbackElement.classList.add('show'); - - // 3秒後にフェードアウト - setTimeout(() => { - feedbackElement.classList.remove('show'); - setTimeout(() => { - feedbackElement.style.display = 'none'; - }, 300); - }, 3000); - } + const items = logic.feedbackItems(validationResult); + feedbackElement.textContent = items.map(item => t(item.key, item.values)).join(t("feedback.separator")); + feedbackElement.classList.toggle("show", items.length > 0); } function encrypt() { - const svgFolder = "assets/svg/tight/"; - const rawInput = document.getElementById("plaintext").value; + const plaintext = document.getElementById("plaintext"); + const rawInput = plaintext.value; + const caret = plaintext.selectionStart; const outputArea = document.getElementById("output"); - const textArea = document.getElementById("ciphertext"); - - // 入力をバリデーション - const validationResult = validateAndSanitizeInput(rawInput); - const input = validationResult.sanitized; - - // バリデーション結果を表示 - showValidationFeedback(validationResult); - - // 元の入力と異なる場合は、サニタイズされた値で更新 - if (rawInput !== input) { - document.getElementById("plaintext").value = input; - } - - outputArea.innerHTML = ""; - textArea.textContent = ""; + const validationResult = logic.sanitizeInput(rawInput); - const lines = input.split(/\r?\n/); - let cipherText = ""; + // 正規化した分だけキャレットの位置を補正する。 + if (rawInput !== validationResult.text) { + plaintext.value = validationResult.text; + const nextCaret = logic.caretAfterSanitize(rawInput, caret); + plaintext.setSelectionRange(nextCaret, nextCaret); + } + showValidationFeedback(validationResult); + const lines = logic.encryptText(validationResult.text); + encryptedLines = lines; + document.getElementById("char-count").textContent = t("encrypt.count", { count: logic.countLetters(lines) }); + document.getElementById("ciphertext").textContent = logic.toFileNameText(lines); + document.getElementById("fonttext").textContent = logic.toFontText(lines); + document.getElementById("font-copy-status").textContent = ""; + document.getElementById("save-status").textContent = ""; + document.getElementById("save-png").disabled = logic.countLetters(lines) === 0; + renderFigures(outputArea, lines); +} +function renderFigures(container, lines) { + container.replaceChildren(); for (const line of lines) { - const letters = line.split(""); const lineDiv = document.createElement("div"); lineDiv.className = "svg-line"; - outputArea.appendChild(lineDiv); - - for (let i = 0; i < letters.length; i++) { - const char = letters[i]; - const upperChar = char.toUpperCase(); - if ((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z')) { - const next = letters[i + 1]; - const useFlag = (next === " "); - const filename = useFlag ? `${upperChar}f.svg` : `${upperChar}.svg`; - - const img = document.createElement("img"); - img.src = `${svgFolder}${filename}`; - img.alt = upperChar; - img.title = char + (useFlag ? " (旗あり)" : ""); - lineDiv.appendChild(img); - - cipherText += filename + " "; - } + if (!line.length) lineDiv.classList.add("is-blank"); + container.appendChild(lineDiv); + for (const token of line) { + const img = document.createElement("img"); + img.src = "assets/svg/full/" + logic.tokenFileName(token); + img.alt = token.flag ? t("figure.flag", { letter: token.letter }) : t("figure.letter", { letter: token.letter }); + img.title = img.alt; + lineDiv.appendChild(img); } - cipherText += "\n"; } - textArea.textContent = cipherText.trim(); } -function generateTable() { - const container = document.querySelector(".key-table"); - if (!container) return; - const svgFolder = "assets/svg/padded/"; - container.innerHTML = ""; - for (let i = 0; i < 26; i++) { - const ch = String.fromCharCode(65 + i); - const div = document.createElement("div"); - div.className = "key-entry"; - div.innerHTML = ` -
${ch}
- ${ch} - ${ch}f - `; - container.appendChild(div); +async function exportImage(name) { + if (!exportImages.has(name)) { + const img = new Image(); + img.src = "assets/svg/full/" + name; + exportImages.set(name, img.decode().then(() => img).catch(error => { + exportImages.delete(name); + throw error; + })); } + return exportImages.get(name); } -function generateDecryptButtons() { - const container = document.getElementById("decrypt-buttons"); - if (!container) return; +async function savePng() { + const status = document.getElementById("save-status"); + const layout = logic.layoutCipher(encryptedLines); + if (!layout.figures.length) return; + if (layout.width > 8000 || layout.height > 8000) { + status.textContent = t("save.tooLarge"); + return; + } + if (location.protocol === "file:") { + status.textContent = t("save.unavailable"); + return; + } + let url; + try { + const names = [...new Set(layout.figures.map(figure => figure.name))]; + const images = new Map(await Promise.all(names.map(async name => [name, await exportImage(name)]))); + const canvas = document.createElement("canvas"); + canvas.width = layout.width * EXPORT_SCALE; + canvas.height = layout.height * EXPORT_SCALE; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas unavailable"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.globalCompositeOperation = "multiply"; + for (const figure of layout.figures) { + context.drawImage(images.get(figure.name), figure.x * EXPORT_SCALE, figure.y * EXPORT_SCALE, + figure.w * EXPORT_SCALE, figure.h * EXPORT_SCALE); + } + const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); + if (!blob) throw new Error("Empty PNG"); + url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "dancingmen-cipher.png"; + link.click(); + status.textContent = t("save.success"); + } catch { + status.textContent = t("save.unavailable"); + } finally { + if (url) URL.revokeObjectURL(url); + } +} - container.innerHTML = ""; - container.className = "decrypt-grid"; // ここが重要! +function generateSamples() { + const select = document.getElementById("sample-select"); + logic.SAMPLES.forEach((sample, index) => { + const option = document.createElement("option"); + option.value = sample.id; + const text = sample.text.split("\n").join(t("sample.lineSeparator")); + option.textContent = t("sample.label", { number: index + 1, text }) + + (sample.text.includes("\n") ? t("sample.multiline") : ""); + select.appendChild(option); + }); + const all = document.createElement("option"); + all.value = "all"; + all.textContent = t("sample.all", { count: logic.SAMPLES.length }); + select.appendChild(all); +} - // 上段:a〜z(旗なし) - for (let i = 0; i < 26; i++) { - const ch = String.fromCharCode(65 + i); - const div = document.createElement("div"); - div.className = "grid-cell"; - div.innerHTML = ` -
${ch}
- - `; - container.appendChild(div); +async function copyFontText() { + await copyText(document.getElementById("fonttext").textContent, + document.getElementById("font-copy-status"), t("copy.fontSuccess")); +} + +async function copyText(text, status, success) { + status.textContent = ""; + try { + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(text); + status.textContent = success; + } catch { + status.textContent = t("copy.failure"); } +} - // 下段:a〜z(旗あり) - for (let i = 0; i < 26; i++) { - const ch = String.fromCharCode(65 + i); +function generateTable() { + const container = document.querySelector("#table-panel .key-table"); + if (!container) return; + const svgFolder = "assets/svg/padded/"; + for (const ch of logic.LETTERS) { const div = document.createElement("div"); - div.className = "grid-cell"; - div.innerHTML = ` -
${ch}
- - `; + div.className = "key-entry"; + const label = document.createElement("div"); + label.className = "char-label"; + label.textContent = t("figure.letter", { letter: ch }); + div.appendChild(label); + for (const flag of [false, true]) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "figure-zoom"; + const figure = flag ? t("figure.flag", { letter: ch }) : t("figure.plain", { letter: ch }); + button.setAttribute("aria-label", t("figure.zoom", { figure })); + button.title = button.getAttribute("aria-label"); + const img = document.createElement("img"); + img.src = svgFolder + logic.tokenFileName({ letter: ch, flag }); + img.alt = ""; + button.appendChild(img); + div.appendChild(button); + } container.appendChild(div); } +} - // イベント登録(共通処理) - container.querySelectorAll("img").forEach(img => { - img.addEventListener("click", () => { - const char = img.dataset.char; - const flag = img.dataset.flag === "true"; - appendDecryption(char, flag); - }); - }); +function generateDecryptButtons() { + for (const flag of [false, true]) { + const container = document.getElementById(flag ? "decrypt-grid-flag" : "decrypt-grid-plain"); + for (const ch of logic.LETTERS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "grid-cell"; + button.dataset.char = ch; + button.dataset.flag = String(flag); + button.setAttribute("aria-label", flag ? t("figure.flag", { letter: ch }) : t("figure.plain", { letter: ch })); + const label = document.createElement("span"); + label.textContent = t("figure.letter", { letter: ch }); + const img = document.createElement("img"); + img.src = "assets/svg/padded/" + logic.tokenFileName({ letter: ch, flag }); + img.alt = ""; + button.append(label, img); + container.appendChild(button); + } + } } function appendDecryption(char, flag) { - const svgFolder = "assets/svg/tight/"; - const filename = flag ? `${char}f.svg` : `${char}.svg`; + if (logic.countLetters(decryptionLines) >= logic.MAX_INPUT_LENGTH) { + document.getElementById("decode-status").textContent = t("decode.tooLong", { max: "2,000" }); + return; + } + decryptionLines.at(-1).push({ letter: char, flag }); + updateDecryption(); +} - const img = document.createElement("img"); - img.src = `${svgFolder}${filename}`; - img.alt = char; - document.getElementById("decrypt-image-line").appendChild(img); +function updateDecryption() { + renderFigures(document.getElementById("decrypt-image-line"), decryptionLines); + document.getElementById("decrypt-output").textContent = logic.decodeTokens(decryptionLines); + document.getElementById("copy-toast").textContent = ""; + document.getElementById("decode-status").textContent = ""; +} - const output = document.getElementById("decrypt-output"); - output.textContent += char + (flag ? " " : ""); +function decodePaste() { + const parsed = logic.parseCipherText(document.getElementById("cipher-paste").value); + const status = document.getElementById("decode-status"); + if (logic.countLetters(parsed.lines) > logic.MAX_INPUT_LENGTH) { + status.textContent = t("decode.tooLong", { max: "2,000" }); + return; + } + decryptionLines = parsed.lines; + updateDecryption(); + if (parsed.invalid.length) { + status.textContent = t("decode.invalid", { chars: parsed.invalid.slice(0, 10) }) + + (parsed.invalid.length > 10 ? t("decode.more", { count: parsed.invalid.length - 10 }) : ""); + } } function clearDecryption() { - document.getElementById("decrypt-image-line").innerHTML = ""; - document.getElementById("decrypt-output").textContent = ""; + decryptionLines = [[]]; + document.getElementById("cipher-paste").value = ""; + updateDecryption(); } function removeLastDecryption() { - const imageLine = document.getElementById("decrypt-image-line"); - const output = document.getElementById("decrypt-output"); - if (imageLine.lastChild) imageLine.removeChild(imageLine.lastChild); - - // 現在の出力を取得 - let current = output.textContent; - - // 末尾が空白なら空白 + 直前の1文字削除、そうでなければ1文字だけ削除 - if (current.endsWith(" ")) { - output.textContent = current.slice(0, -2); - } else { - output.textContent = current.slice(0, -1); - } + if (!decryptionLines.at(-1).length && decryptionLines.length > 1) decryptionLines.pop(); + else decryptionLines.at(-1).pop(); + updateDecryption(); } function copyDecryption() { - const outputText = document.getElementById("decrypt-output").textContent; - navigator.clipboard.writeText(outputText).then(() => { - const toast = document.getElementById("copy-toast"); - toast.classList.add("show"); - setTimeout(() => { - toast.classList.remove("show"); - }, 2000); - }); + return copyText(logic.decodeTokens(decryptionLines), document.getElementById("copy-toast"), t("copy.decryptSuccess")); } diff --git a/style.css b/style.css index ed052df..2969696 100644 --- a/style.css +++ b/style.css @@ -1,16 +1,48 @@ /* style.css */ +*, *::before, *::after { + box-sizing: border-box; +} + +:root { + --color-text: #333333; + --color-bg: #f9f9f9; + --color-header-text: #ffffff; + --color-header-bg: #2c3e50; + --color-tab-text: #2c3e50; + --color-tab-bg: #bdc3c7; + --color-tab-hover-bg: #d0d0d0; + --color-tab-active-text: #000000; + --color-tab-active-bg: #ffffff; + --color-button-text: #ffffff; + --color-button-bg: #1f6fb2; + --color-button-hover-bg: #185a8c; + --color-accent: #1f6fb2; + --color-footer-bg: #f0f0f0; + --color-muted: #666666; + --color-code-bg: #f4f4f4; + --color-success-text: #ffffff; + --color-success-bg: #2e7d32; + --color-warning-text: #856404; + --color-warning-bg: #fff3cd; + --color-error-text: #8a1c1c; + --color-error-bg: #fdecea; + --color-paper: #ffffff; + --color-border-strong: #767676; +} + body { font-family: sans-serif; margin: 0; padding: 0; - background-color: #f9f9f9; - color: #333; + background-color: var(--color-bg); + color: var(--color-text); + overflow-wrap: anywhere; } header { - background-color: #2c3e50; - color: white; + background-color: var(--color-header-bg); + color: var(--color-header-text); padding: 1rem; text-align: center; } @@ -24,31 +56,33 @@ main { .tab-menu { display: flex; justify-content: center; - background-color: #ecf0f1; - border-bottom: 1px solid #ccc; + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-border-strong); } .tab-button { - padding: 1rem 2rem; + flex: 1 1 0; + padding: 0.75rem 0.5rem; + white-space: nowrap; border: none; - background: #bdc3c7; + background: var(--color-tab-bg); font-size: 1rem; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; - color: #2c3e50; + color: var(--color-tab-text); font-weight: bold; border-radius: 4px 4px 0 0; margin: 0 0.25rem; } .tab-button:hover { - background-color: #d0d0d0; + background-color: var(--color-tab-hover-bg); } .tab-button.active { - background-color: #ffffff; - color: #000; - border-bottom: 3px solid #3498db; + background-color: var(--color-tab-active-bg); + color: var(--color-tab-active-text); + border-bottom: 3px solid var(--color-accent); } .tab-panel { @@ -65,53 +99,96 @@ textarea { padding: 0.5rem; margin: 0.5rem 0 1rem; resize: vertical; - border: 1px solid #ccc; + border: 1px solid var(--color-border-strong); border-radius: 4px; } button { padding: 0.5rem 1rem; font-size: 1rem; - background-color: #3498db; - color: white; + background-color: var(--color-button-bg); + color: var(--color-button-text); border: none; cursor: pointer; margin-bottom: 1rem; border-radius: 4px; } +button, select, textarea, a { + min-height: 44px; +} + +button:hover { + background-color: var(--color-button-hover-bg); +} + +button:disabled { + cursor: not-allowed; +} + +:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +select { + min-width: 0; + max-width: 100%; + font: inherit; + color: var(--color-text); + background: var(--color-paper); + border: 1px solid var(--color-border-strong); +} + +.sample-controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-block: 1rem; +} + .svg-line { display: flex; flex-wrap: wrap; - align-items: center; - margin-bottom: 0.25rem; + align-items: flex-end; + padding-inline: 8px; + margin-bottom: 0.5rem; +} + +.svg-line.is-blank { + min-height: 1.5rem; } .svg-output { display: block; margin-bottom: 1rem; + background: var(--color-paper); + border: 1px solid var(--color-border-strong); + border-radius: 4px; + padding: 0.75rem; + min-height: 4.5rem; } -.svg-output img { - width: 48px; - height: 48px; - margin: 0; +.svg-line img { + width: auto; + height: auto; + margin-inline: -5pt; + mix-blend-mode: multiply; padding: 0; display: inline-block; vertical-align: top; } .cipher-output { - background-color: #f4f4f4; + background-color: var(--color-code-bg); padding: 1rem; font-family: monospace; white-space: pre-wrap; - border: 1px solid #ccc; + border: 1px solid var(--color-border-strong); border-radius: 4px; } -.key-table, -.decrypt-button-grid { +.key-table { display: flex; flex-wrap: wrap; gap: 1rem; @@ -124,8 +201,7 @@ button { font-size: 1rem; } -.key-entry img, -.decrypt-button-grid img { +.key-entry img { width: 48px; display: block; margin: 0.25rem auto; @@ -133,13 +209,21 @@ button { transition: transform 0.2s ease; } -.decrypt-button-grid img:hover { - transform: scale(1.2); +.figure-zoom { + display: block; + padding: 0; + margin: 0.25rem auto; + border: 1px solid var(--color-border-strong); + background: var(--color-paper); +} + +.figure-zoom:hover { + background: var(--color-code-bg); } .decrypt-grid { display: grid; - grid-template-columns: repeat(13, 1fr); + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 8px; justify-items: center; align-items: center; @@ -147,13 +231,16 @@ button { } .decrypt-grid .grid-cell { - width: 60px; - height: 80px; - border: 1px solid #ccc; + width: 100%; + min-height: 72px; + border: 1px solid var(--color-border-strong); + background: var(--color-paper); + color: var(--color-text); border-radius: 4px; text-align: center; font-size: 0.9rem; - padding: 4px; + padding: 4px 0; + margin: 0; display: flex; flex-direction: column; align-items: center; @@ -161,6 +248,18 @@ button { box-sizing: border-box; } +.grid-cell:hover { + background: var(--color-code-bg); +} + +.decrypt-control-area, .decrypt-output-area { + margin-top: 1rem; +} + +.decrypt-grid { + margin-bottom: 1rem; +} + .decrypt-grid .grid-cell img { width: 48px; height: 48px; @@ -168,7 +267,7 @@ button { } footer { - background-color: #f0f0f0; + background-color: var(--color-footer-bg); text-align: center; padding: 1rem; margin-top: 2rem; @@ -176,87 +275,93 @@ footer { } .modal { - display: none; - position: fixed; - top: 0; left: 0; - width: 100%; height: 100%; - background: rgba(0,0,0,0.7); - justify-content: center; - align-items: center; - z-index: 1000; - transition: background 0.3s ease; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); + padding: 1rem; + border: 1px solid var(--color-border-strong); + border-radius: 8px; + background: var(--color-paper); + color: var(--color-text); } -.modal.show { - display: flex !important; +.modal::backdrop { + background: rgba(0, 0, 0, 0.7); } .modal-img { - max-width: 80vw; - max-height: 80vh; - width: auto !important; - height: auto !important; - border: 4px solid white; - background: white; - border-radius: 8px; + display: block; + width: 300px; + max-width: 100%; + max-height: 65vh; + height: auto; + background: var(--color-paper); } -@media (max-width: 600px) { - .modal-img { - max-width: 95vw; - max-height: 80vh; - } +.sample-figure { + width: 48px; + vertical-align: middle; } -.modal-img.zoomed { - width: 300px !important; - height: auto !important; - max-width: none; - max-height: none; +footer a { + display: inline-flex; + align-items: center; + color: var(--color-accent); } -.toast { +@media (min-width: 640px) { + .sample-controls { + flex-direction: row; + align-items: center; + } + + .sample-controls button { + margin-bottom: 0; + } +} + +.status-message:not(:empty) { margin-top: 0.5rem; - background: #4caf50; - color: white; + background: var(--color-success-bg); + color: var(--color-success-text); padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.9rem; - display: none; text-align: center; } -.toast.show { - display: block; -} - .input-guide { display: flex; + flex-wrap: wrap; + gap: 0.5rem; justify-content: space-between; margin-top: 0.5rem; margin-bottom: 0.5rem; - color: #666; + color: var(--color-muted); font-size: 0.9rem; } .char-count { font-weight: bold; - color: #3498db; + color: var(--color-accent); } .validation-feedback { display: none; - background-color: #fff3cd; - color: #856404; + background-color: var(--color-warning-bg); + color: var(--color-warning-text); padding: 0.75rem; - border: 1px solid #ffeaa7; + border: 1px solid var(--color-border-strong); border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; - opacity: 0; - transition: opacity 0.3s ease; } .validation-feedback.show { - opacity: 1; + display: block; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition: none; + } } diff --git a/test/assets.test.js b/test/assets.test.js new file mode 100644 index 0000000..d9f921e --- /dev/null +++ b/test/assets.test.js @@ -0,0 +1,41 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { createHash } = require("node:crypto"); +const L = require("../dancingmen-logic.js"); +const root = path.join(__dirname, ".."); +const names = [...L.LETTERS].flatMap(letter => [`${letter}.svg`, `${letter}f.svg`]).sort(); +const read = name => fs.readFileSync(path.join(root, name), "utf8").replace(/\r\n/g, "\n"); + +test("156 assets have exactly the expected names, dimensions, comments and safe content", () => { + assert.equal(names.length, 52); + assert.equal(Object.keys(L.TIGHT_VIEWBOX).length, 52); + for (const folder of ["tight", "padded", "full"]) { + assert.deepEqual(fs.readdirSync(path.join(root, "assets/svg", folder)).sort(), names); + for (const name of names) { + const svg = read(`assets/svg/${folder}/${name}`); + const comment = name.includes("f.") ? name[0] : name[0].toLowerCase(); + assert.ok(svg.includes(``), `${folder}/${name}`); + assert.doesNotMatch(svg, / names.map(name => `assets/svg/${folder}/${name}`)).sort(); + for (const name of paths) hash.update(name + "\n" + read(name)); + return hash.digest("hex"); +} +test("104 original SVGs remain unchanged", () => { + assert.equal(manifest(["tight", "padded"]), "e1d99427499b8c1ebbaf37f7021c38dfdd4176a36fd4cd8561a7f5a7c3270c57"); +}); +test("provided full SVGs preserve their byte size and manifest", () => { + assert.equal(manifest(["full"]), "97ab4c22ebbbed6d78913d2b6a73227fd61cbf61b3a6f1a66fca9e79054dc69a"); + assert.equal(names.reduce((sum, name) => sum + fs.statSync(path.join(root, "assets/svg/full", name)).size, 0), 141374); +}); diff --git a/test/contrast.test.js b/test/contrast.test.js new file mode 100644 index 0000000..51aab37 --- /dev/null +++ b/test/contrast.test.js @@ -0,0 +1,31 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const css = fs.readFileSync(path.join(__dirname, "../style.css"), "utf8"); +const root = css.match(/:root\s*\{([^}]+)\}/)[1]; +const colors = Object.fromEntries([...root.matchAll(/--color-([\w-]+):\s*(#[a-f\d]{6});/gi)] + .map(match => [match[1], match[2]])); + +function luminance(hex) { + const rgb = hex.slice(1).match(/../g).map(part => parseInt(part, 16) / 255) + .map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722; +} + +test("all G-2 text and surface pairs meet WCAG contrast thresholds", () => { + const pairs = [ + ["text", "bg"], ["header-text", "header-bg"], ["tab-text", "tab-bg"], ["tab-text", "tab-hover-bg"], + ["tab-active-text", "tab-active-bg"], ["button-text", "button-bg"], ["button-text", "button-hover-bg"], + ["accent", "bg"], ["accent", "footer-bg"], ["muted", "bg"], ["text", "code-bg"], + ["success-text", "success-bg"], ["warning-text", "warning-bg"], ["error-text", "error-bg"], + ["text", "paper"], ["border-strong", "bg", 3] + ]; + for (const [foreground, background, minimum = 4.5] of pairs) { + assert.ok(colors[foreground] && colors[background]); + const a = luminance(colors[foreground]); + const b = luminance(colors[background]); + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + assert.ok(ratio >= minimum, `${foreground} on ${background}: ${ratio}`); + } +}); diff --git a/test/format.test.js b/test/format.test.js new file mode 100644 index 0000000..4e8f397 --- /dev/null +++ b/test/format.test.js @@ -0,0 +1,23 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const root = path.join(__dirname, ".."); + +test("readable line lengths and minimum source line counts", () => { + const minimums = { "index.html": 100, "script.js": 150, "style.css": 150, "dancingmen-logic.js": 150 }; + function inspect(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if ([".git", ".claude", "node_modules", "assets"].includes(entry.name)) continue; + const file = path.join(directory, entry.name); + if (entry.isDirectory()) inspect(file); + else if (/\.(?:js|css|html)$/.test(entry.name)) { + const lines = fs.readFileSync(file, "utf8").split(/\r?\n/); + const maximum = entry.name === "index.html" ? 250 : 160; + lines.forEach((line, index) => assert.ok([...line].length <= maximum, `${entry.name}:${index + 1} exceeds ${maximum}`)); + if (minimums[entry.name]) assert.ok(lines.length >= minimums[entry.name], entry.name); + } + } + } + inspect(root); +}); diff --git a/test/html.test.js b/test/html.test.js new file mode 100644 index 0000000..fd5eab1 --- /dev/null +++ b/test/html.test.js @@ -0,0 +1,53 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const html = fs.readFileSync(path.join(__dirname, "../index.html"), "utf8"); + +test("HTML security, local classic script order and no inline code", () => { + const csp = html.match(/]*http-equiv="Content-Security-Policy"[^>]*>/i)?.[0]; + assert.ok(csp); + assert.match(csp, /default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self';/); + assert.match(csp, /base-uri 'none'; form-action 'none'; object-src 'none'/); + assert.doesNotMatch(csp, /frame-ancestors|blob:|data:|unsafe-/); + assert.match(html, /[^<]+<\/noscript>/); + assert.match(html, /]*type=["']module["']/i); + assert.deepEqual([...html.matchAll(/]*src="([^"]+)"/g)].map(match => match[1]), [ + "dancingmen-messages.js", "dancingmen-logic.js", "script.js" + ]); + assert.doesNotMatch(html, /<(?:script|link|img)\b[^>]*(?:src|href)=["']https?:\/\//i); + for (const match of html.matchAll(/]*target="_blank"[^>]*>/g)) { + assert.match(match[0], /rel="noopener noreferrer"/); + } +}); + +test("accessible tabs, dialog, textareas and all required element IDs", () => { + const ids = [ + "plaintext", "validation-feedback", "char-count", "sample-select", "sample-load", "output", "ciphertext", + "fonttext", "copy-font", "save-png", "save-status", "decrypt-buttons", "decrypt-grid-plain", "decrypt-grid-flag", + "decrypt-image-line", "cipher-paste", "decode-paste", "decrypt-output", "modal", "modal-img", "modal-close" + ]; + for (const id of ids) assert.equal([...html.matchAll(new RegExp(`id="${id}"`, "g"))].length, 1, id); + assert.match(html, /]*aria-label=/); + assert.doesNotMatch(html.match(/]*>/)[0], /\bsrc=/); + for (const [role, count] of [["tablist", 1], ["tab", 3], ["tabpanel", 3]]) { + assert.equal([...html.matchAll(new RegExp(`role="${role}"`, "g"))].length, count); + } + for (const match of html.matchAll(/]*role="tab"[^>]*>/g)) { + assert.match(match[0], /data-tab=/); + assert.match(match[0], /aria-controls=/); + assert.match(match[0], /aria-selected=/); + assert.match(match[0], /tabindex="(?:0|-1)"/); + } + for (const match of html.matchAll(/]*>/g)) { + assert.match(match[0], /maxlength="(?:2000|20000)"/); + assert.match(match[0], /spellcheck="false"/); + } + assert.match(html, /id="validation-feedback"[^>]*role="status"/); + assert.match(html, /id="save-status"[^>]*role="status"/); + assert.doesNotMatch(html, /\u{1F3F1}/u); +}); diff --git a/test/layout.test.js b/test/layout.test.js new file mode 100644 index 0000000..2a1809f --- /dev/null +++ b/test/layout.test.js @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const L = require("../dancingmen-logic.js"); +const near = (actual, expected) => assert.ok(Math.abs(actual - expected) <= 0.01, `${actual} != ${expected}`); + +test("A-8 intrinsic figure dimensions retain a common scale", () => { + assert.equal(L.FULL_PAD_X, 5); + assert.equal(L.FULL_PAD_Y, 2); + assert.equal(L.PX_PER_PT, 4 / 3); + for (const letter of L.LETTERS) { + const size = L.figureSize({ letter, flag: false }); + near(size.width, 51.73); + near(size.height, 57.30); + near(size.advance, 38.40); + } + for (const [letter, expected] of [ + ["E", { height: 61.46 }], ["T", { height: 63.31 }], + ["M", { width: 51.79, height: 58.06, advance: 38.46 }], + ["I", { width: 55.76, height: 64.59, advance: 42.43 }], ["P", { height: 66.70 }] + ]) { + const size = L.figureSize({ letter, flag: true }); + for (const [key, value] of Object.entries(expected)) near(size[key], value); + } +}); + +test("A-9 only the root dimensions and viewBox change", () => { + for (const [name, width, height] of [ + ["A", "38.8", "42.9775"], ["Af", "38.8", "47.965"], + ["If", "41.8225", "48.445"], ["Mf", "38.845", "43.5475"] + ]) { + const original = fs.readFileSync(path.join(__dirname, `../assets/svg/tight/${name}.svg`), "utf8"); + const expanded = L.expandSvgViewBox(original); + const rootTag = /]*>/; + const expected = ''; + assert.equal(expanded.match(rootTag)[0], expected); + assert.equal(expanded.replace(rootTag, ""), original.replace(rootTag, "")); + } +}); + +test("A-10 exact layouts, empty rows and PNG size boundaries", () => { + const cases = [ + ["A", 84, 99, 1, 1, { name: "A.svg", x: 16, y: 25.4, w: 51.73, h: 57.3 }], + ["COME HERE AT ONCE", 583, 99, 1, 14, { name: "E.svg", x: 515.2, y: 25.4 }], + ["ELSIE PREPARE TO\nMEET THY GOD", 583, 178, 2, 24, { name: "D.svg", x: 361.6, y: 104.1 }], + [L.allSamplesText(), 622, 751, 12, 76, { name: "E.svg", x: 515.2, y: 677.62 }], + ["", 46, 32, 0, 0], + ["A".repeat(207), 7995, 99, 1, 207], + ["A".repeat(208), 8033, 99, 1, 208], + [Array(101).fill("A").join("\n"), 84, 7970, 101, 101], + [Array(102).fill("A").join("\n"), 84, 8048, 102, 102] + ]; + for (const [raw, width, height, rows, count, last] of cases) { + const layout = L.layoutCipher(L.encryptText(raw)); + assert.deepEqual([layout.width, layout.height, layout.rows, layout.figures.length], [width, height, rows, count]); + if (last) { + for (const [key, value] of Object.entries(last)) { + if (typeof value === "number") near(layout.figures.at(-1)[key], value); + else assert.equal(layout.figures.at(-1)[key], value); + } + } + } + assert.deepEqual(L.layoutCipher(L.encryptText("\nA\n")), L.layoutCipher(L.encryptText("A"))); +}); diff --git a/test/logic.test.js b/test/logic.test.js new file mode 100644 index 0000000..cb3e05c --- /dev/null +++ b/test/logic.test.js @@ -0,0 +1,123 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const L = require("../dancingmen-logic.js"); + +const sanitizeCases = [ + ["HELLO WORLD", "HELLO WORLD", [], 1, 0], + ["HELLO\tWORLD", "HELLO WORLD", [], 1, 0], + ["HELLO\u00a0WORLD", "HELLO WORLD", [], 1, 0], + ["HELLO\r\nWORLD", "HELLO\nWORLD", [], 0, 0], + ["HELLO\rWORLD", "HELLO\nWORLD", [], 0, 0], + ["Am here, Abe Slaney!", "Am here Abe Slaney", [",", "!"], 0, 0], + ["HELLO world", "HELLO world", [], 1, 10], + ["A1 あB", "A B", ["1", "あ"], 0, 0], + ["A😀B😀C", "ABC", ["😀"], 0, 0], + ["A,,B!!", "AB", [",", "!"], 0, 0], + [" ", " ", [], 0, 0] +]; +for (const [raw, text, removed, replacedSpaces, replacedFullwidth] of sanitizeCases) { + test(`sanitize ${JSON.stringify(raw)}`, () => { + assert.deepEqual(L.sanitizeInput(raw), { text, removed, replacedSpaces, replacedFullwidth, truncated: false }); + }); +} +test("2,000-character limit applies after normalization", () => { + assert.equal(L.MAX_INPUT_LENGTH, 2000); + assert.equal(L.sanitizeInput("X".repeat(2001)).text, "X".repeat(2000)); + assert.equal(L.sanitizeInput("X".repeat(2001)).truncated, true); + assert.equal(L.sanitizeInput("X".repeat(2000)).truncated, false); +}); + +test("caret offsets A-3", () => { + for (const [raw, caret, expected] of [ + ["HE1LLO WORLD", 3, 2], ["HE1LLO WORLD", 2, 2], ["HE", 2, 2], + ["A B", 2, 2], ["1234", 4, 0], ["AB", 0, 0], ["A😀B", 3, 1] + ]) assert.equal(L.caretAfterSanitize(raw, caret), expected); +}); + +const helloFiles = "H.svg E.svg L.svg L.svg Of.svg W.svg O.svg R.svg L.svg D.svg"; +const newlineFiles = "H.svg E.svg L.svg L.svg Of.svg\nW.svg O.svg R.svg L.svg D.svg"; +const blankFiles = "H.svg E.svg L.svg L.svg O.svg\n\nW.svg O.svg R.svg L.svg D.svg"; +const cases = [ + ...["HELLO WORLD", "hello world", "HELLO WORLD", "HELLO\tWORLD", "HELLO WORLD"] + .map(raw => [raw, "hellO world", helloFiles, "HELLO WORLD", 10, 1]), + ...["HELLO\nWORLD", "HELLO \nWORLD"] + .map(raw => [raw, "hellO\nworld", newlineFiles, "HELLO\nWORLD", 10, 2]), + ...["HELLO\n\nWORLD", "HELLO\n \nWORLD"] + .map(raw => [raw, "hello\n\nworld", blankFiles, "HELLO\n\nWORLD", 10, 3]), + [" HELLO", "hello", "H.svg E.svg L.svg L.svg O.svg", "HELLO", 5, 1], + ["HELLO ", "hellO", "H.svg E.svg L.svg L.svg Of.svg", "HELLO", 5, 1], + ["HELLO\n", "hello", "H.svg E.svg L.svg L.svg O.svg", "HELLO", 5, 2], + ["\nHELLO", "hello", "H.svg E.svg L.svg L.svg O.svg", "HELLO", 5, 2], + ["A B C", "A B c", "Af.svg Bf.svg C.svg", "A B C", 3, 1], + ["MEET ME\nAT NOON", "meeT mE\naT noon", + "M.svg E.svg E.svg Tf.svg M.svg Ef.svg\nA.svg Tf.svg N.svg O.svg O.svg N.svg", "MEET ME\nAT NOON", 12, 2], + ["AB\n CD", "aB\ncd", "A.svg Bf.svg\nC.svg D.svg", "AB\nCD", 4, 2], + ["AB \n\nCD", "aB\n\ncd", "A.svg Bf.svg\n\nC.svg D.svg", "AB\n\nCD", 4, 3], + ["Am here, Abe Slaney!", "aM herE abE slaney", + "A.svg Mf.svg H.svg E.svg R.svg Ef.svg A.svg B.svg Ef.svg S.svg L.svg A.svg N.svg E.svg Y.svg", + "AM HERE ABE SLANEY", 15, 1], + ["", "", "", "", 0, 1], [" ", "", "", "", 0, 1], ["\n\n", "", "", "", 0, 3] +]; + +function assertRoundTrip(raw) { + const lines = L.encryptText(raw); + const before = JSON.stringify(lines); + const plain = L.decodeTokens(lines); + assert.equal(plain, L.normalizePlain(raw), JSON.stringify(raw)); + for (const encode of [L.toFontText, L.toFileNameText]) { + const parsed = L.parseCipherText(encode(lines)); + assert.deepEqual(parsed.invalid, []); + assert.equal(L.decodeTokens(parsed.lines), plain); + } + L.layoutCipher(lines); + assert.equal(JSON.stringify(lines), before); +} +for (const [raw, font, files, plain, count, rows] of cases) { + test(`flag rules and round trip ${JSON.stringify(raw)}`, () => { + const lines = L.encryptText(raw); + assert.equal(L.toFontText(lines), font); + assert.equal(L.toFileNameText(lines), files); + assert.equal(L.decodeTokens(lines), plain); + assert.equal(L.countLetters(lines), count); + assert.equal(lines.length, rows); + assertRoundTrip(raw); + }); +} + +test("parse A-7 including filename ambiguity and invalid input", () => { + const examples = [ + ["hellO world", "font", "HELLO WORLD", []], + ["hellOworld", "font", "HELLO WORLD", []], + ["H.svg E.svg L.svg L.svg Of.svg W.svg", "filenames", "HELLO W", []], + ["Ff.svg f.svg ff.svg F.svg", "filenames", "F FF F", []], + ["aM herE!\n\nnever 1", "font", "AM HERE\n\nNEVER", ["!", "1"]], + ["A.svg Bx.svg .svg AA.svg", "filenames", "A", ["Bx.svg", ".svg", "AA.svg"]], + ["am here", "font", "AMHERE", []], + ["elsiE preparE tO\nmeeT thY god", "font", "ELSIE PREPARE TO\nMEET THY GOD", []] + ]; + for (const [raw, notation, plain, invalid] of examples) { + const parsed = L.parseCipherText(raw); + assert.equal(parsed.notation, notation); + assert.equal(L.decodeTokens(parsed.lines), plain); + assert.deepEqual(parsed.invalid, invalid); + } + assert.equal(L.toFileNameText(L.parseCipherText("Ff.svg f.svg ff.svg F.svg").lines), "Ff.svg F.svg Ff.svg F.svg"); + assert.deepEqual(L.parseCipherText("\n\n").lines, [[], [], []]); + assert.deepEqual(L.parseCipherText("A.SVG A.SVG").invalid, ["A.SVG"]); +}); + +test("300 deterministic generated strings round trip without mutation", () => { + const alphabet = [..."ABZ az\n \t,"]; + let seed = 20260921; + function next() { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed; + } + for (let index = 0; index < 300; index++) { + const length = next() % 41; + const raw = Array.from({ length }, () => alphabet[next() % alphabet.length]).join(""); + const before = raw; + assertRoundTrip(raw); + assert.equal(raw, before); + } +}); diff --git a/test/messages.test.js b/test/messages.test.js new file mode 100644 index 0000000..afeb42e --- /dev/null +++ b/test/messages.test.js @@ -0,0 +1,43 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const L = require("../dancingmen-logic.js"); +const M = require("../dancingmen-messages.js"); + +test("A-4 feedback keys, order and Japanese formatting", () => { + const examples = [ + ["Am here, Abe Slaney!", ["使えない文字を取り除きました: 「,」「!」"]], + ["HELLO WORLD", ["全角の英字・スペースやタブを半角に置き換えました"]], + ["Hi, 1", ["使えない文字を取り除きました: 「,」「1」", "全角の英字・スペースやタブを半角に置き換えました"]], + ["X".repeat(2001), ["2,000文字を超えた分は切り捨てました"]], + ["HELLO WORLD", []] + ]; + for (const [raw, expected] of examples) { + assert.deepEqual(L.feedbackItems(L.sanitizeInput(raw)).map(item => M.format("ja", item.key, item.values)), expected); + } + assert.deepEqual(L.feedbackItems(L.sanitizeInput("H," + "X".repeat(2001))), [ + { key: "feedback.removed", values: { chars: [","] } }, + { key: "feedback.replaced", values: {} }, + { key: "feedback.truncated", values: { max: "2,000" } } + ]); +}); + +test("dictionary values are complete and unknown keys throw", () => { + assert.equal(M.DEFAULT_LANG, "ja"); + assert.throws(() => M.format("ja", "unknown")); + assert.throws(() => M.format("xx", "feedback.removed")); + assert.throws(() => M.format("ja", "feedback.removed")); + for (const [key, template] of Object.entries(M.dictionaries.ja)) { + assert.ok(template.length > 0); + const values = Object.fromEntries([...template.matchAll(/\{([^}]+)\}/g)].map(match => [match[1], "test"])); + assert.doesNotMatch(M.format("ja", key, values), /\{/); + } +}); + +test("UI message keys exist in the Japanese dictionary", () => { + const source = fs.readFileSync(path.join(__dirname, "../script.js"), "utf8"); + const keys = [...source.matchAll(/\bt\(["']([^"']+)["']/g)].map(match => match[1]); + assert.ok(keys.length > 0); + for (const key of keys) assert.ok(Object.hasOwn(M.dictionaries.ja, key), key); +}); diff --git a/test/readme.test.js b/test/readme.test.js new file mode 100644 index 0000000..89f322e --- /dev/null +++ b/test/readme.test.js @@ -0,0 +1,83 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { encryptText, toFontText, SAMPLES } = require("../dancingmen-logic.js"); +const root = path.join(__dirname, ".."); +const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); +const symbols = text => text.replaceAll("⏎", "\n").replaceAll("␣", " "); + +test("README original six messages exactly match samples and generated notation", () => { + const rows = [...readme.matchAll(/^\| ([1-6]) \| ([^|]+) \| ([^|]+) \|$/gm)]; + assert.equal(rows.length, 6); + rows.forEach(([, number, plain, font], index) => { + assert.equal(Number(number), index + 1); + assert.equal(symbols(plain), SAMPLES[index].text); + assert.equal(symbols(font), toFontText(encryptText(symbols(plain)))); + }); +}); + +test("README five flag examples are present and match generated notation", () => { + const section = readme.split("## 🚩 旗の規則")[1].split("\n## ")[0]; + const rows = [...section.matchAll(/^\| ([A-Z][^|]*) \| ([^|]+) \| ([^|]+) \|$/gm)]; + assert.equal(rows.length, 5); + assert.deepEqual(rows.map(row => symbols(row[1])), [ + "HELLO WORLD", "HELLO WORLD", "MEET ME\nAT NOON", "HELLO\n\nWORLD", "COME HERE AT ONCE " + ]); + for (const [, input, font] of rows) assert.equal(symbols(font), toFontText(encryptText(symbols(input)))); +}); + +test("README YAML remains an HTML comment with block lists and original identity", () => { + const metadata = /^/.exec(readme); + assert.ok(metadata); + for (const key of ["category_ja", "category_en", "tags"]) { + assert.match(metadata[1], new RegExp(`^${key}:\\r?\\n - \\S`, "m")); + } + for (const expected of [ + "id: day022", "slug: dancingmen-cipherlab", 'title: "DancingMen CipherLab"', + 'repo_url: "https://github.com/ipusiron/dancingmen-cipherlab"', + 'demo_url: "https://ipusiron.github.io/dancingmen-cipherlab/"', "hub: true" + ]) assert.ok(metadata[1].split(/\r?\n/).includes(expected), expected); +}); + +test("README relative images exist and every root PNG is referenced", () => { + const images = [...readme.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)] + .map(match => match[1]).filter(file => !/^https?:/.test(file)); + assert.equal(images.length, 4); + for (const file of images) assert.ok(fs.existsSync(path.join(root, file)), file); + const actual = fs.readdirSync(path.join(root, "assets")).filter(file => file.endsWith(".png")); + assert.deepEqual(actual.sort(), images.map(file => path.basename(file)).sort()); +}); + +test("README tree covers every directory and file with an aligned description", () => { + const section = readme.split("## 📁 ディレクトリー構造")[1]; + const block = /```text\n([\s\S]*?)\n```/.exec(section); + assert.ok(block); + const lines = block[1].split("\n"); + const expected = ["dancingmen-cipherlab/"]; + function walk(directory, prefix = "") { + const entries = fs.readdirSync(directory, { withFileTypes: true }) + .filter(entry => ![".git", ".claude", "node_modules"].includes(entry.name)) + .sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1); + entries.forEach((entry, index) => { + const last = index === entries.length - 1; + expected.push(prefix + (last ? "└── " : "├── ") + entry.name + (entry.isDirectory() ? "/" : "")); + const file = path.join(directory, entry.name); + const relative = path.relative(root, file).replaceAll("\\", "/"); + if (entry.isDirectory() && !/^assets\/svg\/(?:full|padded|tight)$/.test(relative)) { + walk(file, prefix + (last ? " " : "│ ")); + } + }); + } + walk(root); + assert.ok(lines.length > 30); + assert.equal(new Set(lines.map(line => line.indexOf("#"))).size, 1); + for (const line of lines) assert.match(line, /\S +# [^\s].*$/); + assert.deepEqual(lines.map(line => line.split("#")[0].trimEnd()), expected); +}); + +test("README obsolete claims and non-existent code examples are absent", () => { + for (const obsolete of ["Elsei", "the God", "5つの暗号文", "EOFなら旗付き", "script.js:15-30"]) { + assert.ok(!readme.includes(obsolete), obsolete); + } +}); diff --git a/test/samples.test.js b/test/samples.test.js new file mode 100644 index 0000000..2d9f0f1 --- /dev/null +++ b/test/samples.test.js @@ -0,0 +1,30 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const L = require("../dancingmen-logic.js"); + +const expected = [ + ["m1", "AM HERE ABE SLANEY", "aM herE abE slaney"], + ["m2", "AT ELRIGES", "aT elriges"], + ["m3", "COME ELSIE", "comE elsie"], + ["m4", "NEVER", "never"], + ["m5", "ELSIE PREPARE TO\nMEET THY GOD", "elsiE preparE tO\nmeeT thY god"], + ["m6", "COME HERE AT ONCE", "comE herE aT once"] +]; +test("six original messages and the explicit trailing-space exception", () => { + assert.equal(L.SAMPLES.length, 6); + assert.deepEqual(L.SAMPLES.map(sample => [sample.id, sample.text, L.toFontText(L.encryptText(sample.text))]), expected); + assert.equal(L.toFontText(L.encryptText("COME HERE AT ONCE ")), "comE herE aT oncE"); +}); +test("all messages: twelve rows, 76 figures, eight absent letters", () => { + const lines = L.encryptText(L.allSamplesText()); + const font = "aM herE abE slaney\n\naT elriges\n\ncomE elsie\n\nnever\n\nelsiE preparE tO\nmeeT thY god\n\ncomE herE aT once"; + assert.equal(L.toFontText(lines), font); + assert.equal(lines.length, 12); + assert.equal(L.countLetters(lines), 76); + const used = new Set(lines.flat().map(token => token.letter)); + assert.deepEqual([...L.LETTERS].filter(letter => !used.has(letter)), [..."FJKQUWXZ"]); + const first = L.encryptText(L.SAMPLES[0].text).flat(); + assert.equal(first.length, 15); + assert.equal(first.filter(token => token.letter === "E").length, 4); + assert.equal(first.filter(token => token.letter === "A").length, 3); +}); diff --git a/test/static.test.js b/test/static.test.js new file mode 100644 index 0000000..9a19950 --- /dev/null +++ b/test/static.test.js @@ -0,0 +1,40 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const read = name => fs.readFileSync(path.join(__dirname, "..", name), "utf8"); + +test("pure classic scripts also provide conditional CommonJS exports", () => { + for (const name of ["dancingmen-logic.js", "dancingmen-messages.js"]) { + const source = read(name); + assert.doesNotMatch(source, /\b(?:document|window|navigator|localStorage|console)\b|\bfetch\s*\(/); + assert.doesNotMatch(source, /^\s*(?:export|import)\s/m); + assert.match(source, /if \(typeof module === "object" && module\.exports\) \{\s*module\.exports = \w+;\s*\}\s*$/); + assert.doesNotMatch(source, /innerHTML|console\.|alert\s*\(|setAttribute\(["']style|\.cssText/); + } + const logic = read("dancingmen-logic.js").replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""); + assert.doesNotMatch(logic, /[\u3040-\u30ff\u3400-\u9fff]/); +}); + +test("dependency-free Node 22 CI runs tests on pushes and pull requests", () => { + const pkg = JSON.parse(read("package.json")); + assert.deepEqual(pkg, { name: "dancingmen-cipherlab", private: true, scripts: { test: "node --test" } }); + const workflow = read(".github/workflows/test.yml"); + assert.match(workflow, /^\s+push:/m); + assert.match(workflow, /^\s+pull_request:/m); + assert.match(workflow, /node-version: ["']?22/); + assert.match(workflow, /run: npm test/); + assert.doesNotMatch(read(".gitignore"), /\.github\/workflows/); +}); + +test("UI avoids unsafe DOM, external communication and non-dictionary Japanese literals", () => { + const source = read("script.js"); + assert.doesNotMatch(source, /innerHTML|console\.|alert\s*\(|setAttribute\(["']style|\.cssText/); + assert.doesNotMatch(source, /\bfetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|image\/svg\+xml/); + assert.doesNotMatch(source, /\.style\b|document\.execCommand/); + const withoutComments = source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""); + assert.doesNotMatch(withoutComments, /[\u3040-\u30ff\u3400-\u9fff]/); + assert.match(source, /querySelectorAll\('\[role="tab"\]'\)/); + assert.match(source, /new Map\(\)/); + assert.match(source, /globalCompositeOperation = "multiply"/); +}); diff --git a/tools/build-full-svg.js b/tools/build-full-svg.js new file mode 100644 index 0000000..c2a5063 --- /dev/null +++ b/tools/build-full-svg.js @@ -0,0 +1,25 @@ +// Regenerate only full/; tight/ and padded/ are immutable source assets. +const fs = require("node:fs"); +const path = require("node:path"); +const { LETTERS, expandSvgViewBox } = require("../dancingmen-logic.js"); +const root = path.join(__dirname, "..", "assets", "svg"); +const check = process.argv.includes("--check"); +const names = [...LETTERS].flatMap(letter => [`${letter}.svg`, `${letter}f.svg`]).sort(); +const normalize = text => text.replace(/\r\n/g, "\n"); +let differences = 0; + +if (!check) fs.mkdirSync(path.join(root, "full"), { recursive: true }); +for (const name of names) { + const expanded = expandSvgViewBox(fs.readFileSync(path.join(root, "tight", name), "utf8")); + const target = path.join(root, "full", name); + if (check) { + if (!fs.existsSync(target) || normalize(fs.readFileSync(target, "utf8")) !== normalize(expanded)) { + process.stdout.write(`${name}\n`); + differences++; + } + } else { + fs.writeFileSync(target, expanded, "utf8"); + } +} +if (check && !differences) process.stdout.write("差分なし\n"); +process.exitCode = differences ? 1 : 0;