From 02f4d7655b2cc940099288fc1986d917195b2785 Mon Sep 17 00:00:00 2001 From: ipusiron Date: Mon, 21 Sep 2026 22:46:48 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E6=AE=B5=E9=9A=8E1:=20=E6=9A=97=E5=8F=B7?= =?UTF-8?q?=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E3=81=A8SVG=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=83=BB=E3=83=86=E3=82=B9=E3=83=88=E5=9F=BA=E7=9B=A4?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 18 +++ .gitignore | 2 - assets/svg/full/A.svg | 114 +++++++++++++++++++ assets/svg/full/Af.svg | 121 ++++++++++++++++++++ assets/svg/full/B.svg | 93 +++++++++++++++ assets/svg/full/Bf.svg | 104 +++++++++++++++++ assets/svg/full/C.svg | 107 ++++++++++++++++++ assets/svg/full/Cf.svg | 117 +++++++++++++++++++ assets/svg/full/D.svg | 97 ++++++++++++++++ assets/svg/full/Df.svg | 106 ++++++++++++++++++ assets/svg/full/E.svg | 104 +++++++++++++++++ assets/svg/full/Ef.svg | 112 +++++++++++++++++++ assets/svg/full/F.svg | 112 +++++++++++++++++++ assets/svg/full/Ff.svg | 123 ++++++++++++++++++++ assets/svg/full/G.svg | 96 ++++++++++++++++ assets/svg/full/Gf.svg | 106 ++++++++++++++++++ assets/svg/full/H.svg | 100 +++++++++++++++++ assets/svg/full/Hf.svg | 107 ++++++++++++++++++ assets/svg/full/I.svg | 105 +++++++++++++++++ assets/svg/full/If.svg | 113 +++++++++++++++++++ assets/svg/full/J.svg | 104 +++++++++++++++++ assets/svg/full/Jf.svg | 111 ++++++++++++++++++ assets/svg/full/K.svg | 104 +++++++++++++++++ assets/svg/full/Kf.svg | 111 ++++++++++++++++++ assets/svg/full/L.svg | 122 ++++++++++++++++++++ assets/svg/full/Lf.svg | 130 +++++++++++++++++++++ assets/svg/full/M.svg | 118 +++++++++++++++++++ assets/svg/full/Mf.svg | 124 ++++++++++++++++++++ assets/svg/full/N.svg | 116 +++++++++++++++++++ assets/svg/full/Nf.svg | 123 ++++++++++++++++++++ assets/svg/full/O.svg | 110 ++++++++++++++++++ assets/svg/full/Of.svg | 120 ++++++++++++++++++++ assets/svg/full/P.svg | 93 +++++++++++++++ assets/svg/full/Pf.svg | 104 +++++++++++++++++ assets/svg/full/Q.svg | 92 +++++++++++++++ assets/svg/full/Qf.svg | 103 +++++++++++++++++ assets/svg/full/R.svg | 110 ++++++++++++++++++ assets/svg/full/Rf.svg | 116 +++++++++++++++++++ assets/svg/full/S.svg | 125 +++++++++++++++++++++ assets/svg/full/Sf.svg | 133 ++++++++++++++++++++++ assets/svg/full/T.svg | 104 +++++++++++++++++ assets/svg/full/Tf.svg | 116 +++++++++++++++++++ assets/svg/full/U.svg | 116 +++++++++++++++++++ assets/svg/full/Uf.svg | 126 +++++++++++++++++++++ assets/svg/full/V.svg | 94 ++++++++++++++++ assets/svg/full/Vf.svg | 105 +++++++++++++++++ assets/svg/full/W.svg | 93 +++++++++++++++ assets/svg/full/Wf.svg | 105 +++++++++++++++++ assets/svg/full/X.svg | 108 ++++++++++++++++++ assets/svg/full/Xf.svg | 117 +++++++++++++++++++ assets/svg/full/Y.svg | 110 ++++++++++++++++++ assets/svg/full/Yf.svg | 116 +++++++++++++++++++ assets/svg/full/Z.svg | 107 ++++++++++++++++++ assets/svg/full/Zf.svg | 118 +++++++++++++++++++ dancingmen-logic.js | 224 +++++++++++++++++++++++++++++++++++++ dancingmen-messages.js | 34 ++++++ package.json | 7 ++ test/assets.test.js | 41 +++++++ test/layout.test.js | 67 +++++++++++ test/logic.test.js | 123 ++++++++++++++++++++ test/messages.test.js | 34 ++++++ test/samples.test.js | 30 +++++ test/static.test.js | 28 +++++ tools/build-full-svg.js | 25 +++++ 64 files changed, 6372 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 assets/svg/full/A.svg create mode 100644 assets/svg/full/Af.svg create mode 100644 assets/svg/full/B.svg create mode 100644 assets/svg/full/Bf.svg create mode 100644 assets/svg/full/C.svg create mode 100644 assets/svg/full/Cf.svg create mode 100644 assets/svg/full/D.svg create mode 100644 assets/svg/full/Df.svg create mode 100644 assets/svg/full/E.svg create mode 100644 assets/svg/full/Ef.svg create mode 100644 assets/svg/full/F.svg create mode 100644 assets/svg/full/Ff.svg create mode 100644 assets/svg/full/G.svg create mode 100644 assets/svg/full/Gf.svg create mode 100644 assets/svg/full/H.svg create mode 100644 assets/svg/full/Hf.svg create mode 100644 assets/svg/full/I.svg create mode 100644 assets/svg/full/If.svg create mode 100644 assets/svg/full/J.svg create mode 100644 assets/svg/full/Jf.svg create mode 100644 assets/svg/full/K.svg create mode 100644 assets/svg/full/Kf.svg create mode 100644 assets/svg/full/L.svg create mode 100644 assets/svg/full/Lf.svg create mode 100644 assets/svg/full/M.svg create mode 100644 assets/svg/full/Mf.svg create mode 100644 assets/svg/full/N.svg create mode 100644 assets/svg/full/Nf.svg create mode 100644 assets/svg/full/O.svg create mode 100644 assets/svg/full/Of.svg create mode 100644 assets/svg/full/P.svg create mode 100644 assets/svg/full/Pf.svg create mode 100644 assets/svg/full/Q.svg create mode 100644 assets/svg/full/Qf.svg create mode 100644 assets/svg/full/R.svg create mode 100644 assets/svg/full/Rf.svg create mode 100644 assets/svg/full/S.svg create mode 100644 assets/svg/full/Sf.svg create mode 100644 assets/svg/full/T.svg create mode 100644 assets/svg/full/Tf.svg create mode 100644 assets/svg/full/U.svg create mode 100644 assets/svg/full/Uf.svg create mode 100644 assets/svg/full/V.svg create mode 100644 assets/svg/full/Vf.svg create mode 100644 assets/svg/full/W.svg create mode 100644 assets/svg/full/Wf.svg create mode 100644 assets/svg/full/X.svg create mode 100644 assets/svg/full/Xf.svg create mode 100644 assets/svg/full/Y.svg create mode 100644 assets/svg/full/Yf.svg create mode 100644 assets/svg/full/Z.svg create mode 100644 assets/svg/full/Zf.svg create mode 100644 dancingmen-logic.js create mode 100644 dancingmen-messages.js create mode 100644 package.json create mode 100644 test/assets.test.js create mode 100644 test/layout.test.js create mode 100644 test/logic.test.js create mode 100644 test/messages.test.js create mode 100644 test/samples.test.js create mode 100644 test/static.test.js create mode 100644 tools/build-full-svg.js 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/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..07b4dbe --- /dev/null +++ b/dancingmen-messages.js @@ -0,0 +1,34 @@ +// 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}文字を超えた分は切り捨てました" + } + }; + + 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/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/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/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..5947682 --- /dev/null +++ b/test/messages.test.js @@ -0,0 +1,34 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +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), /\{/); + } +}); 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..0290d35 --- /dev/null +++ b/test/static.test.js @@ -0,0 +1,28 @@ +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/); +}); 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; From 205ade7825075735b8096f87e5c7b3aebbf68cff Mon Sep 17 00:00:00 2001 From: ipusiron Date: Mon, 21 Sep 2026 22:51:07 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E6=AE=B5=E9=9A=8E2:=20IME=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=AE=E5=85=A5=E5=8A=9B=E6=AD=A3=E8=A6=8F=E5=8C=96?= =?UTF-8?q?=E3=81=A8=E5=8E=9F=E4=BD=9C=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dancingmen-messages.js | 12 +++- index.html | 27 ++++++-- script.js | 152 ++++++++++++++++++++--------------------- style.css | 1 + test/messages.test.js | 9 +++ 5 files changed, 119 insertions(+), 82 deletions(-) diff --git a/dancingmen-messages.js b/dancingmen-messages.js index 07b4dbe..d3072bd 100644 --- a/dancingmen-messages.js +++ b/dancingmen-messages.js @@ -7,7 +7,17 @@ const DancingMenMessages = (() => { "list.close": "」", "feedback.removed": "使えない文字を取り除きました: {chars}", "feedback.replaced": "全角の英字・スペースやタブを半角に置き換えました", - "feedback.truncated": "{max}文字を超えた分は切り捨てました" + "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": "コピーできませんでした。文字列を選択して手動でコピーしてください" } }; diff --git a/index.html b/index.html index 2ab79ad..33009c1 100644 --- a/index.html +++ b/index.html @@ -24,25 +24,40 @@

DancingMen CipherLab

🔐 暗号化

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

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


         
+
+

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

+

+          

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

+ +
+
@@ -115,6 +130,8 @@

🏱 旗ありの意味

+ + diff --git a/script.js b/script.js index 71451bd..8c0ff01 100644 --- a/script.js +++ b/script.js @@ -1,5 +1,9 @@ // script.js +const logic = DancingMenLogic; +const t = (key, values) => DancingMenMessages.format(DancingMenMessages.DEFAULT_LANG, key, values); +let compositionCommitValue = null; + document.addEventListener("DOMContentLoaded", () => { document.body.addEventListener("click", (e) => { if (e.target.matches(".key-entry img")) { @@ -15,6 +19,25 @@ document.addEventListener("DOMContentLoaded", () => { }); generateDecryptButtons(); + 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); }); function closeModal() { @@ -34,97 +57,74 @@ function switchTab(tabName) { if (tabName === "table") generateTable(); } -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 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); + 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 = ""; + outputArea.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 + " "; - } + for (const token of line) { + const img = document.createElement("img"); + img.src = svgFolder + 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 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); +} + +async function copyFontText() { + const status = document.getElementById("font-copy-status"); + try { + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(document.getElementById("fonttext").textContent); + status.textContent = t("copy.fontSuccess"); + } catch { + status.textContent = t("copy.failure"); + } } function generateTable() { diff --git a/style.css b/style.css index ed052df..59f0dfe 100644 --- a/style.css +++ b/style.css @@ -258,5 +258,6 @@ footer { } .validation-feedback.show { + display: block; opacity: 1; } diff --git a/test/messages.test.js b/test/messages.test.js index 5947682..afeb42e 100644 --- a/test/messages.test.js +++ b/test/messages.test.js @@ -1,5 +1,7 @@ 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"); @@ -32,3 +34,10 @@ test("dictionary values are complete and unknown keys throw", () => { 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); +}); From ba9c8006a7a8a057284d5ad6e578beb3c01585d8 Mon Sep 17 00:00:00 2001 From: ipusiron Date: Mon, 21 Sep 2026 22:54:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E6=AE=B5=E9=9A=8E3:=20=E4=BA=BA=E5=BD=A2?= =?UTF-8?q?=E3=81=AE=E7=B8=AE=E5=B0=BA=E3=81=A8=E9=80=81=E3=82=8A=E5=B9=85?= =?UTF-8?q?=E3=82=92=E7=B5=B1=E4=B8=80=E3=81=97=E3=81=A6PNG=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dancingmen-messages.js | 6 ++- index.html | 4 +- script.js | 90 +++++++++++++++++++++++++++++++++++------- style.css | 23 ++++++++--- 4 files changed, 101 insertions(+), 22 deletions(-) diff --git a/dancingmen-messages.js b/dancingmen-messages.js index d3072bd..3ac3ad4 100644 --- a/dancingmen-messages.js +++ b/dancingmen-messages.js @@ -17,7 +17,11 @@ const DancingMenMessages = (() => { "sample.multiline": "(2行)", "sample.all": "{count}通すべて(空行で区切る)", "copy.fontSuccess": "フォント用の文字列をコピーしました", - "copy.failure": "コピーできませんでした。文字列を選択して手動でコピーしてください" + "copy.failure": "コピーできませんでした。文字列を選択して手動でコピーしてください", + "save.tooLarge": "画像が大きすぎて保存できません。1行を短くするか、行数を減らしてください", + "save.unavailable": "このページをfile://で開いているときはPNGを保存できません。" + + "公開ページか、ローカルサーバー(python -m http.server など)で開いてください", + "save.success": "暗号文をPNGで保存しました" } }; diff --git a/index.html b/index.html index 33009c1..878ad51 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,8 @@

🔐 暗号化

+ +

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


@@ -71,7 +73,7 @@ 

🔓 復号

入力した人形:

-
+
diff --git a/script.js b/script.js index 8c0ff01..9ebd0f1 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,10 @@ const logic = DancingMenLogic; const t = (key, values) => DancingMenMessages.format(DancingMenMessages.DEFAULT_LANG, key, values); let compositionCommitValue = null; +let encryptedLines = [[]]; +const decryptionFigures = []; +const exportImages = new Map(); +const EXPORT_SCALE = 2; document.addEventListener("DOMContentLoaded", () => { document.body.addEventListener("click", (e) => { @@ -38,6 +42,7 @@ document.addEventListener("DOMContentLoaded", () => { encrypt(); }); document.getElementById("copy-font").addEventListener("click", copyFontText); + document.getElementById("save-png").addEventListener("click", savePng); }); function closeModal() { @@ -65,7 +70,6 @@ function showValidationFeedback(validationResult) { } function encrypt() { - const svgFolder = "assets/svg/tight/"; const plaintext = document.getElementById("plaintext"); const rawInput = plaintext.value; const caret = plaintext.selectionStart; @@ -80,19 +84,26 @@ function encrypt() { } 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 = ""; - outputArea.replaceChildren(); + 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 lineDiv = document.createElement("div"); lineDiv.className = "svg-line"; - outputArea.appendChild(lineDiv); + if (!line.length) lineDiv.classList.add("is-blank"); + container.appendChild(lineDiv); for (const token of line) { const img = document.createElement("img"); - img.src = svgFolder + logic.tokenFileName(token); + 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); @@ -100,6 +111,61 @@ function encrypt() { } } +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); +} + +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); + } +} + function generateSamples() { const select = document.getElementById("sample-select"); logic.SAMPLES.forEach((sample, index) => { @@ -187,27 +253,23 @@ function generateDecryptButtons() { } function appendDecryption(char, flag) { - const svgFolder = "assets/svg/tight/"; - const filename = flag ? `${char}f.svg` : `${char}.svg`; - - const img = document.createElement("img"); - img.src = `${svgFolder}${filename}`; - img.alt = char; - document.getElementById("decrypt-image-line").appendChild(img); + decryptionFigures.push({ letter: char, flag }); + renderFigures(document.getElementById("decrypt-image-line"), [decryptionFigures]); const output = document.getElementById("decrypt-output"); output.textContent += char + (flag ? " " : ""); } function clearDecryption() { - document.getElementById("decrypt-image-line").innerHTML = ""; + decryptionFigures.length = 0; + renderFigures(document.getElementById("decrypt-image-line"), [decryptionFigures]); document.getElementById("decrypt-output").textContent = ""; } function removeLastDecryption() { - const imageLine = document.getElementById("decrypt-image-line"); const output = document.getElementById("decrypt-output"); - if (imageLine.lastChild) imageLine.removeChild(imageLine.lastChild); + decryptionFigures.pop(); + renderFigures(document.getElementById("decrypt-image-line"), [decryptionFigures]); // 現在の出力を取得 let current = output.textContent; diff --git a/style.css b/style.css index 59f0dfe..8ab4a31 100644 --- a/style.css +++ b/style.css @@ -83,19 +83,30 @@ button { .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: #ffffff; + border: 1px solid #767676; + 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; From bda32cb4463d5d73d5c8218e7653e30433571123 Mon Sep 17 00:00:00 2001 From: ipusiron Date: Mon, 21 Sep 2026 23:05:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=E6=AE=B5=E9=9A=8E4:=20=E5=BE=A9=E5=8F=B7?= =?UTF-8?q?=E3=81=A8=E3=82=AD=E3=83=BC=E3=83=9C=E3=83=BC=E3=83=89=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E3=83=BBCSP=E3=83=BB=E3=83=A2=E3=83=90=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E8=A1=A8=E7=A4=BA=E3=82=92=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dancingmen-messages.js | 8 +- index.html | 76 ++++++++----- script.js | 230 ++++++++++++++++++++++---------------- style.css | 245 ++++++++++++++++++++++++++++------------- test/contrast.test.js | 31 ++++++ test/format.test.js | 23 ++++ test/html.test.js | 53 +++++++++ test/static.test.js | 12 ++ 8 files changed, 476 insertions(+), 202 deletions(-) create mode 100644 test/contrast.test.js create mode 100644 test/format.test.js create mode 100644 test/html.test.js diff --git a/dancingmen-messages.js b/dancingmen-messages.js index 3ac3ad4..6ac4eea 100644 --- a/dancingmen-messages.js +++ b/dancingmen-messages.js @@ -21,7 +21,13 @@ const DancingMenMessages = (() => { "save.tooLarge": "画像が大きすぎて保存できません。1行を短くするか、行数を減らしてください", "save.unavailable": "このページをfile://で開いているときはPNGを保存できません。" + "公開ページか、ローカルサーバー(python -m http.server など)で開いてください", - "save.success": "暗号文をPNGで保存しました" + "save.success": "暗号文をPNGで保存しました", + "figure.plain": "{letter}(旗なし)", + "figure.zoom": "{figure}を拡大", + "decode.tooLong": "人形が{max}個を超える暗号文は復号できません。短くしてから貼り付けてください", + "decode.invalid": "読めなかった部分: {chars}", + "decode.more": " ほか{count}件", + "copy.decryptSuccess": "復号結果をコピーしました" } }; diff --git a/index.html b/index.html index 878ad51..56b8fe4 100644 --- a/index.html +++ b/index.html @@ -3,25 +3,32 @@ + + + DancingMen CipherLab +

DancingMen CipherLab

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

-
-
+

🔐 暗号化

🔐 暗号化 spellcheck="false" autocomplete="off" autocapitalize="characters" + aria-describedby="input-hint validation-feedback" >
- A〜Zとスペース、改行が使えます。全角の英字とスペースは半角に置き換えます。 + A〜Zとスペース、改行が使えます。全角の英字とスペースは半角に置き換えます。 暗号化可能文字数: 0
@@ -58,51 +66,59 @@

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


           

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

-
+
-
+ -
+