qr-terminal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * ╔══════════════════════════════════════════════════════════════╗
3
+ * ║ HIGH-DENSITY HALF-BLOCK RENDERING ENGINE ║
4
+ * ║ Compresses 2 vertical QR pixels → 1 Unicode character ║
5
+ * ║ 2D per-character color: diagonal · wave · 4-corner · radial║
6
+ * ╚══════════════════════════════════════════════════════════════╝
7
+ */
8
+
9
+ import QRCode from 'qrcode';
10
+ import chalk from 'chalk';
11
+
12
+ // Unicode half-block set
13
+ const BLOCK = {
14
+ FULL: '█',
15
+ UPPER: '▀',
16
+ LOWER: '▄',
17
+ EMPTY: ' ',
18
+ };
19
+
20
+ /**
21
+ * Gradient presets.
22
+ * Each has a `type` that controls 2D color distribution,
23
+ * and `stops` — an array of [R,G,B] color points.
24
+ *
25
+ * Types:
26
+ * solid — single color
27
+ * diagonal — top-left → bottom-right blend
28
+ * vertical — top → bottom
29
+ * wave — sinusoidal ripple across x+y
30
+ * radial — center → edge bloom
31
+ * 4corner — bilinear blend between 4 corner colors [tl, tr, bl, br]
32
+ */
33
+ export const GRADIENT_PRESETS = {
34
+ none: { type: 'solid', stops: [[220, 220, 220]] },
35
+ retro: { type: 'diagonal', stops: [[235, 77, 75], [255, 184, 64]] },
36
+ ocean: { type: 'diagonal', stops: [[0, 48, 200], [0, 210, 255]] },
37
+ neon: { type: 'wave', stops: [[57, 255, 20], [0, 200, 255], [255, 0, 200]] },
38
+ sunset: { type: 'diagonal', stops: [[255, 40, 40], [255, 140, 40], [255, 220, 60]] },
39
+ galaxy: { type: '4corner', stops: [[160, 32, 240], [59, 130, 246], [255, 0, 128], [0, 200, 255]] },
40
+ cherry: { type: 'radial', stops: [[255, 255, 200],[255, 80, 130], [200, 0, 80]] },
41
+ matrix: { type: 'vertical', stops: [[0, 60, 0], [0, 200, 50], [160, 255, 160]] },
42
+ fire: { type: 'diagonal', stops: [[160, 0, 0], [255, 80, 0], [255, 210, 0]] },
43
+ vapor: { type: '4corner', stops: [[255, 0, 255], [0, 220, 255], [180, 0, 180], [80, 80, 255]] },
44
+ };
45
+
46
+ /**
47
+ * Generate QR bit-matrix, bypassing qrcode's own renderer.
48
+ */
49
+ export function createMatrix(text, { errorLevel = 'M' } = {}) {
50
+ if (!text || text.length === 0) throw new Error('Empty input');
51
+ return QRCode.create(text, { errorCorrectionLevel: errorLevel }).modules;
52
+ }
53
+
54
+ /**
55
+ * Core renderer with 2D per-character color.
56
+ *
57
+ * For each character position (col, row), we compute tx ∈ [0,1] and ty ∈ [0,1]
58
+ * then apply the gradient type formula to get a unique RGB for that character.
59
+ * This creates diagonal sweeps, waves, and corner blooms across the QR matrix.
60
+ *
61
+ * @returns {string[]} — chalk-colored strings, one per display row
62
+ */
63
+ export function renderHalfBlock(modules, options = {}) {
64
+ const { gradient = 'none', quietZone = 2 } = options;
65
+ const { size, data } = modules;
66
+
67
+ const colStart = -quietZone;
68
+ const colEnd = size + quietZone;
69
+ const rowStart = -quietZone;
70
+ const rowEnd = size + quietZone;
71
+ const totalCols = colEnd - colStart;
72
+ const totalRows = rowEnd - rowStart;
73
+ const displayRows = Math.ceil(totalRows / 2);
74
+
75
+ const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
76
+
77
+ const px = (r, c) => {
78
+ if (r < 0 || r >= size || c < 0 || c >= size) return 0;
79
+ return data[r * size + c] ? 1 : 0;
80
+ };
81
+
82
+ const output = [];
83
+
84
+ for (let di = 0; di < displayRows; di++) {
85
+ const row = rowStart + di * 2;
86
+ const ty = displayRows > 1 ? di / (displayRows - 1) : 0;
87
+
88
+ let coloredLine = '';
89
+
90
+ // Track current run for grouping consecutive same-color chars
91
+ let runColor = null;
92
+ let runChars = '';
93
+
94
+ const flushRun = () => {
95
+ if (!runChars) return;
96
+ if (runColor) {
97
+ coloredLine += chalk.rgb(runColor[0], runColor[1], runColor[2])(runChars);
98
+ } else {
99
+ coloredLine += runChars;
100
+ }
101
+ runChars = '';
102
+ runColor = null;
103
+ };
104
+
105
+ for (let ci = 0; ci < totalCols; ci++) {
106
+ const col = colStart + ci;
107
+ const tx = totalCols > 1 ? ci / (totalCols - 1) : 0;
108
+
109
+ const bitmask = (px(row, col) << 1) | px(row + 1, col);
110
+ let char;
111
+ switch (bitmask) {
112
+ case 0b11: char = BLOCK.FULL; break;
113
+ case 0b10: char = BLOCK.UPPER; break;
114
+ case 0b01: char = BLOCK.LOWER; break;
115
+ default: char = BLOCK.EMPTY;
116
+ }
117
+
118
+ if (char === BLOCK.EMPTY) {
119
+ // Light module: transparent space — flush colored run first
120
+ flushRun();
121
+ coloredLine += char;
122
+ } else {
123
+ // Dark module: compute 2D color for this exact position
124
+ const color = computeColor(preset, tx, ty);
125
+
126
+ // Group with previous char if colors are close enough (Δ < 8 per channel)
127
+ if (runColor && colorsClose(runColor, color, 8)) {
128
+ runChars += char;
129
+ } else {
130
+ flushRun();
131
+ runColor = color;
132
+ runChars = char;
133
+ }
134
+ }
135
+ }
136
+
137
+ flushRun();
138
+ output.push(coloredLine);
139
+ }
140
+
141
+ return output;
142
+ }
143
+
144
+ /**
145
+ * Render QR as plain Unicode half-blocks (no ANSI).
146
+ * For clipboard / README / text file export.
147
+ */
148
+ export function renderASCII(modules, { quietZone = 2 } = {}) {
149
+ const { size, data } = modules;
150
+ const px = (r, c) => (r < 0 || r >= size || c < 0 || c >= size)
151
+ ? 0 : (data[r * size + c] ? 1 : 0);
152
+
153
+ const rows = [];
154
+ for (let di = 0; di < Math.ceil((size + quietZone * 2) / 2); di++) {
155
+ const row = -quietZone + di * 2;
156
+ let line = '';
157
+ for (let col = -quietZone; col < size + quietZone; col++) {
158
+ const bitmask = (px(row, col) << 1) | px(row + 1, col);
159
+ switch (bitmask) {
160
+ case 0b11: line += BLOCK.FULL; break;
161
+ case 0b10: line += BLOCK.UPPER; break;
162
+ case 0b01: line += BLOCK.LOWER; break;
163
+ default: line += BLOCK.EMPTY;
164
+ }
165
+ }
166
+ rows.push(line);
167
+ }
168
+ return rows.join('\n');
169
+ }
170
+
171
+ export function getQrVersion(size) {
172
+ return Math.round((size - 17) / 4);
173
+ }
174
+
175
+ // ── Color computation ─────────────────────────────────────────────────────────
176
+
177
+ export function computeColor({ type, stops }, tx, ty) {
178
+ switch (type) {
179
+ case 'solid':
180
+ return stops[0];
181
+
182
+ case 'diagonal': {
183
+ const t = clamp((tx + ty) / 2);
184
+ return lerpStops(stops, t);
185
+ }
186
+
187
+ case 'vertical': {
188
+ return lerpStops(stops, ty);
189
+ }
190
+
191
+ case 'wave': {
192
+ // Sinusoidal ripple — creates a shimmering interference pattern
193
+ const raw = Math.sin((tx * 3.5 + ty * 2.5) * Math.PI);
194
+ const t = clamp((raw + 1) / 2);
195
+ return lerpStops(stops, t);
196
+ }
197
+
198
+ case 'radial': {
199
+ // Bloom from center outward
200
+ const dx = tx - 0.5;
201
+ const dy = ty - 0.5;
202
+ const t = clamp(Math.sqrt(dx * dx + dy * dy) * Math.SQRT2);
203
+ return lerpStops(stops, t);
204
+ }
205
+
206
+ case '4corner': {
207
+ // Bilinear blend: [top-left, top-right, bottom-left, bottom-right]
208
+ const [tl, tr, bl, br] = stops;
209
+ const top = lerp3(tl, tr, tx);
210
+ const bottom = lerp3(bl, br, tx);
211
+ return lerp3(top, bottom, ty);
212
+ }
213
+
214
+ default:
215
+ return [220, 220, 220];
216
+ }
217
+ }
218
+
219
+ // Multi-stop linear gradient interpolation
220
+ function lerpStops(stops, t) {
221
+ if (stops.length === 1) return stops[0];
222
+ const seg = 1 / (stops.length - 1);
223
+ const i = Math.min(Math.floor(t / seg), stops.length - 2);
224
+ const lt = clamp((t - i * seg) / seg);
225
+ return lerp3(stops[i], stops[i + 1], lt);
226
+ }
227
+
228
+ // Lerp between two [R,G,B] triplets
229
+ function lerp3(a, b, t) {
230
+ return [
231
+ Math.round(a[0] + (b[0] - a[0]) * t),
232
+ Math.round(a[1] + (b[1] - a[1]) * t),
233
+ Math.round(a[2] + (b[2] - a[2]) * t),
234
+ ];
235
+ }
236
+
237
+ function clamp(v, lo = 0, hi = 1) {
238
+ return Math.max(lo, Math.min(hi, v));
239
+ }
240
+
241
+ // Check if two colors are close enough to group into the same chalk call
242
+ function colorsClose([r1, g1, b1], [r2, g2, b2], threshold) {
243
+ return Math.abs(r1 - r2) < threshold &&
244
+ Math.abs(g1 - g2) < threshold &&
245
+ Math.abs(b1 - b2) < threshold;
246
+ }
247
+
248
+ /**
249
+ * Build particle data for the fly-in animation.
250
+ * Returns an array of { targetRow, targetCol, char, color } objects
251
+ * and the framebuffer dimensions { displayRows, totalCols }.
252
+ */
253
+ export function buildParticleData(modules, { gradient = 'none', quietZone = 2 } = {}) {
254
+ const { size, data } = modules;
255
+ const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
256
+ const colStart = -quietZone, colEnd = size + quietZone;
257
+ const rowStart = -quietZone, rowEnd = size + quietZone;
258
+ const totalCols = colEnd - colStart;
259
+ const totalRows = rowEnd - rowStart;
260
+ const displayRows = Math.ceil(totalRows / 2);
261
+
262
+ const px = (r, c) => {
263
+ if (r < 0 || r >= size || c < 0 || c >= size) return 0;
264
+ return data[r * size + c] ? 1 : 0;
265
+ };
266
+
267
+ const particles = [];
268
+ for (let di = 0; di < displayRows; di++) {
269
+ const row = rowStart + di * 2;
270
+ const ty = displayRows > 1 ? di / (displayRows - 1) : 0;
271
+ for (let ci = 0; ci < totalCols; ci++) {
272
+ const col = colStart + ci;
273
+ const tx = totalCols > 1 ? ci / (totalCols - 1) : 0;
274
+ const bitmask = (px(row, col) << 1) | px(row + 1, col);
275
+ if (bitmask === 0) continue;
276
+ let char;
277
+ switch (bitmask) {
278
+ case 0b11: char = '█'; break;
279
+ case 0b10: char = '▀'; break;
280
+ default: char = '▄';
281
+ }
282
+ particles.push({ targetRow: di, targetCol: ci, char, color: computeColor(preset, tx, ty) });
283
+ }
284
+ }
285
+ return { particles, displayRows, totalCols };
286
+ }
package/src/export.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Export module: save QR codes as PNG, SVG, or plain ASCII text.
3
+ * Leverages qrcode's built-in renderers for pixel-perfect file output.
4
+ */
5
+
6
+ import QRCode from 'qrcode';
7
+ import { writeFile } from 'fs/promises';
8
+ import { createMatrix, renderASCII } from './engine.js';
9
+
10
+ /**
11
+ * Export as high-resolution PNG using qrcode's canvas renderer.
12
+ *
13
+ * @param {string} text - Data to encode
14
+ * @param {string} filePath - Output path (e.g. "qr-code.png")
15
+ * @param {object} opts
16
+ * @param {string} opts.errorLevel - QR error correction level
17
+ * @param {number} opts.width - Image width in pixels (default 600)
18
+ * @param {string} opts.darkColor - Hex color for dark modules
19
+ * @param {string} opts.lightColor - Hex color for light modules
20
+ */
21
+ export async function exportPNG(text, filePath, {
22
+ errorLevel = 'M',
23
+ width = 600,
24
+ darkColor = '#000000',
25
+ lightColor = '#FFFFFF',
26
+ } = {}) {
27
+ await QRCode.toFile(filePath, text, {
28
+ type: 'png',
29
+ width,
30
+ margin: 4,
31
+ errorCorrectionLevel: errorLevel,
32
+ color: {
33
+ dark: darkColor,
34
+ light: lightColor,
35
+ },
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Export as clean, scalable SVG vector.
41
+ * SVG output is ideal for print, presentations, and web embedding.
42
+ *
43
+ * @param {string} text - Data to encode
44
+ * @param {string} filePath - Output path (e.g. "qr-code.svg")
45
+ * @param {object} opts
46
+ */
47
+ export async function exportSVG(text, filePath, {
48
+ errorLevel = 'M',
49
+ darkColor = '#000000',
50
+ lightColor = '#FFFFFF',
51
+ } = {}) {
52
+ const svg = await QRCode.toString(text, {
53
+ type: 'svg',
54
+ margin: 4,
55
+ errorCorrectionLevel: errorLevel,
56
+ color: {
57
+ dark: darkColor,
58
+ light: lightColor,
59
+ },
60
+ });
61
+ await writeFile(filePath, svg, 'utf8');
62
+ }
63
+
64
+ /**
65
+ * Export as plain Unicode half-block text (no ANSI codes).
66
+ * Paste into README files, Markdown, or any plain-text environment.
67
+ *
68
+ * @param {string} text - Data to encode
69
+ * @param {string} filePath - Output path (e.g. "qr-code.txt")
70
+ * @param {object} opts
71
+ */
72
+ export async function exportTXT(text, filePath, { errorLevel = 'M' } = {}) {
73
+ const modules = createMatrix(text, { errorLevel });
74
+ const ascii = renderASCII(modules);
75
+ await writeFile(filePath, ascii, 'utf8');
76
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Data formatters + UI metadata for prompts
3
+ */
4
+
5
+ export function formatWifi({ ssid, password, security = 'WPA', hidden = false }) {
6
+ const esc = s => String(s).replace(/([\\;,":])/, '\\$1');
7
+ if (security === 'nopass') return `WIFI:T:nopass;S:${esc(ssid)};;`;
8
+ return `WIFI:T:${security};S:${esc(ssid)};P:${esc(password)};H:${hidden};;`;
9
+ }
10
+
11
+ export function formatVCard({ firstName = '', lastName = '', phone = '', email = '', org = '', url = '' }) {
12
+ const lines = [
13
+ 'BEGIN:VCARD',
14
+ 'VERSION:3.0',
15
+ `N:${lastName};${firstName};;;`,
16
+ `FN:${[firstName, lastName].filter(Boolean).join(' ')}`,
17
+ ];
18
+ if (org) lines.push(`ORG:${org}`);
19
+ if (phone) lines.push(`TEL;TYPE=CELL:${phone}`);
20
+ if (email) lines.push(`EMAIL;TYPE=INTERNET:${email}`);
21
+ if (url) lines.push(`URL:${url}`);
22
+ lines.push('END:VCARD');
23
+ return lines.join('\r\n');
24
+ }
25
+
26
+ export const ERROR_LEVELS = {
27
+ L: { label: 'L — Low (7% recovery)', desc: 'Smallest matrix. Best for pristine digital screens.', recovery: '7%' },
28
+ M: { label: 'M — Medium (15% recovery)', desc: 'Balanced. Recommended for URLs and general use.', recovery: '15%' },
29
+ Q: { label: 'Q — Quartile (25% recovery)', desc: 'Good tolerance for printed/folded materials.', recovery: '25%' },
30
+ H: { label: 'H — High (30% recovery)', desc: 'Maximum redundancy — logos can overlay up to 30% area.', recovery: '30%' },
31
+ };
32
+
33
+ export const GRADIENT_OPTIONS = [
34
+ { name: ' ◆ Classic — Crisp white, terminal-native', value: 'none' },
35
+ { name: ' ◈ Retro — Diagonal: coral red → warm amber', value: 'retro' },
36
+ { name: ' ◈ Ocean — Diagonal: deep navy → electric cyan', value: 'ocean' },
37
+ { name: ' ◈ Neon — Sine-wave: green · cyan · magenta ripple', value: 'neon' },
38
+ { name: ' ◈ Sunset — Diagonal: hot red → orange → gold', value: 'sunset' },
39
+ { name: ' ◈ Galaxy — 4-corner bilinear: purple · blue · pink · teal', value: 'galaxy' },
40
+ { name: ' ◈ Cherry — Radial bloom: soft white center → deep rose', value: 'cherry' },
41
+ { name: ' ◈ Matrix — Vertical cascade: dark → electric green', value: 'matrix' },
42
+ { name: ' ◈ Fire — Diagonal: blood red → ember orange → gold', value: 'fire' },
43
+ { name: ' ◈ Vaporwave — 4-corner: magenta · cyan · purple · blue', value: 'vapor' },
44
+ ];
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Terminal-to-Mobile WebSocket Handshake
3
+ *
4
+ * Starts an HTTP + WebSocket server. The QR code encodes the server URL.
5
+ * When scanned, the phone opens a mobile-optimized page and connects.
6
+ * The terminal detects the connection and streams any text the user types
7
+ * on their phone directly into the terminal session.
8
+ */
9
+
10
+ import http from 'http';
11
+ import { WebSocketServer } from 'ws';
12
+ import { networkInterfaces } from 'os';
13
+
14
+ // Minimal mobile web UI — served to the phone browser after scan
15
+ const MOBILE_PAGE = `<!DOCTYPE html>
16
+ <html>
17
+ <head>
18
+ <meta name="viewport" content="width=device-width,initial-scale=1">
19
+ <title>Terminal Link</title>
20
+ <style>
21
+ *{box-sizing:border-box;margin:0;padding:0}
22
+ body{background:#0d0d0d;color:#00ff41;font-family:monospace;padding:20px;min-height:100vh}
23
+ h1{font-size:14px;letter-spacing:3px;color:#444;margin-bottom:20px}
24
+ #st{font-size:18px;font-weight:bold;padding:14px;border:1px solid;border-radius:4px;text-align:center;margin-bottom:20px}
25
+ .on{border-color:#00ff41;background:#0a1a0a}.off{border-color:#ff0040;color:#ff0040;background:#1a000a}
26
+ textarea{width:100%;height:160px;background:#111;color:#00ff41;border:1px solid #00ff41;padding:12px;font-size:15px;font-family:monospace;outline:none;resize:none;border-radius:4px}
27
+ button{display:block;width:100%;margin-top:12px;padding:18px;background:#00ff41;color:#000;border:none;font-size:18px;font-family:monospace;font-weight:bold;border-radius:4px;cursor:pointer;letter-spacing:2px}
28
+ button:active{background:#00cc33}
29
+ #log{margin-top:14px;font-size:12px;color:#444;min-height:20px}
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>◈ QR TERMINAL LINK</h1>
34
+ <div id="st" class="off">○ CONNECTING...</div>
35
+ <textarea id="inp" placeholder="Type here — press TRANSMIT to send to the terminal"></textarea>
36
+ <button onclick="send()">▶ TRANSMIT TO TERMINAL</button>
37
+ <div id="log"></div>
38
+ <script>
39
+ var ws=new WebSocket('ws://'+location.host);
40
+ var st=document.getElementById('st');
41
+ var log=document.getElementById('log');
42
+ ws.onopen=function(){st.textContent='● DEVICE LINKED';st.className='on'};
43
+ ws.onclose=function(){st.textContent='○ DISCONNECTED';st.className='off'};
44
+ function send(){
45
+ var t=document.getElementById('inp').value.trim();
46
+ if(!t||ws.readyState!==1)return;
47
+ ws.send(t);
48
+ log.textContent='✓ Sent '+t.length+' chars';
49
+ document.getElementById('inp').value='';
50
+ }
51
+ document.getElementById('inp').addEventListener('keydown',function(e){
52
+ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send()}
53
+ });
54
+ </script>
55
+ </body>
56
+ </html>`;
57
+
58
+ function getLocalIP() {
59
+ const nets = networkInterfaces();
60
+ for (const name of Object.keys(nets)) {
61
+ for (const iface of nets[name]) {
62
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
63
+ }
64
+ }
65
+ return '127.0.0.1';
66
+ }
67
+
68
+ /**
69
+ * Start the handshake server.
70
+ *
71
+ * @returns {Promise<{url, waitForDevice, onMessage, close}>}
72
+ * url — The URL to encode in the QR code
73
+ * waitForDevice — Promise that resolves when the first phone connects
74
+ * onMessage — Register a callback for incoming phone messages
75
+ * close — Shut down the server
76
+ */
77
+ export function startHandshakeServer() {
78
+ return new Promise((resolve, reject) => {
79
+ const port = 3847 + Math.floor(Math.random() * 100);
80
+ const ip = getLocalIP();
81
+ const url = `http://${ip}:${port}`;
82
+
83
+ const server = http.createServer((req, res) => {
84
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
85
+ res.end(MOBILE_PAGE);
86
+ });
87
+
88
+ const wss = new WebSocketServer({ server });
89
+ const messageHandlers = [];
90
+ let deviceConnectResolve = null;
91
+ const deviceConnected = new Promise(r => { deviceConnectResolve = r; });
92
+
93
+ wss.on('connection', (ws) => {
94
+ deviceConnectResolve(); // signal first connection
95
+ ws.on('message', (raw) => {
96
+ const text = raw.toString();
97
+ messageHandlers.forEach(h => h(text));
98
+ });
99
+ });
100
+
101
+ server.on('error', reject);
102
+
103
+ server.listen(port, () => {
104
+ resolve({
105
+ url,
106
+ waitForDevice: deviceConnected,
107
+ onMessage: (handler) => messageHandlers.push(handler),
108
+ close: () => { wss.close(); server.close(); },
109
+ });
110
+ });
111
+ });
112
+ }