windsor-bot 0.1.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/README.md +128 -0
- package/SPEC.md +179 -0
- package/assets/pokemon-sprites/1.png +0 -0
- package/assets/pokemon-sprites/10.png +0 -0
- package/assets/pokemon-sprites/100.png +0 -0
- package/assets/pokemon-sprites/101.png +0 -0
- package/assets/pokemon-sprites/102.png +0 -0
- package/assets/pokemon-sprites/103.png +0 -0
- package/assets/pokemon-sprites/104.png +0 -0
- package/assets/pokemon-sprites/105.png +0 -0
- package/assets/pokemon-sprites/106.png +0 -0
- package/assets/pokemon-sprites/107.png +0 -0
- package/assets/pokemon-sprites/108.png +0 -0
- package/assets/pokemon-sprites/109.png +0 -0
- package/assets/pokemon-sprites/11.png +0 -0
- package/assets/pokemon-sprites/110.png +0 -0
- package/assets/pokemon-sprites/111.png +0 -0
- package/assets/pokemon-sprites/112.png +0 -0
- package/assets/pokemon-sprites/113.png +0 -0
- package/assets/pokemon-sprites/114.png +0 -0
- package/assets/pokemon-sprites/115.png +0 -0
- package/assets/pokemon-sprites/116.png +0 -0
- package/assets/pokemon-sprites/117.png +0 -0
- package/assets/pokemon-sprites/118.png +0 -0
- package/assets/pokemon-sprites/119.png +0 -0
- package/assets/pokemon-sprites/12.png +0 -0
- package/assets/pokemon-sprites/120.png +0 -0
- package/assets/pokemon-sprites/121.png +0 -0
- package/assets/pokemon-sprites/122.png +0 -0
- package/assets/pokemon-sprites/123.png +0 -0
- package/assets/pokemon-sprites/124.png +0 -0
- package/assets/pokemon-sprites/125.png +0 -0
- package/assets/pokemon-sprites/126.png +0 -0
- package/assets/pokemon-sprites/127.png +0 -0
- package/assets/pokemon-sprites/128.png +0 -0
- package/assets/pokemon-sprites/129.png +0 -0
- package/assets/pokemon-sprites/13.png +0 -0
- package/assets/pokemon-sprites/130.png +0 -0
- package/assets/pokemon-sprites/131.png +0 -0
- package/assets/pokemon-sprites/132.png +0 -0
- package/assets/pokemon-sprites/133.png +0 -0
- package/assets/pokemon-sprites/134.png +0 -0
- package/assets/pokemon-sprites/135.png +0 -0
- package/assets/pokemon-sprites/136.png +0 -0
- package/assets/pokemon-sprites/137.png +0 -0
- package/assets/pokemon-sprites/138.png +0 -0
- package/assets/pokemon-sprites/139.png +0 -0
- package/assets/pokemon-sprites/14.png +0 -0
- package/assets/pokemon-sprites/140.png +0 -0
- package/assets/pokemon-sprites/141.png +0 -0
- package/assets/pokemon-sprites/142.png +0 -0
- package/assets/pokemon-sprites/143.png +0 -0
- package/assets/pokemon-sprites/144.png +0 -0
- package/assets/pokemon-sprites/145.png +0 -0
- package/assets/pokemon-sprites/146.png +0 -0
- package/assets/pokemon-sprites/147.png +0 -0
- package/assets/pokemon-sprites/148.png +0 -0
- package/assets/pokemon-sprites/149.png +0 -0
- package/assets/pokemon-sprites/15.png +0 -0
- package/assets/pokemon-sprites/150.png +0 -0
- package/assets/pokemon-sprites/151.png +0 -0
- package/assets/pokemon-sprites/16.png +0 -0
- package/assets/pokemon-sprites/17.png +0 -0
- package/assets/pokemon-sprites/18.png +0 -0
- package/assets/pokemon-sprites/19.png +0 -0
- package/assets/pokemon-sprites/2.png +0 -0
- package/assets/pokemon-sprites/20.png +0 -0
- package/assets/pokemon-sprites/21.png +0 -0
- package/assets/pokemon-sprites/22.png +0 -0
- package/assets/pokemon-sprites/23.png +0 -0
- package/assets/pokemon-sprites/24.png +0 -0
- package/assets/pokemon-sprites/25.png +0 -0
- package/assets/pokemon-sprites/26.png +0 -0
- package/assets/pokemon-sprites/27.png +0 -0
- package/assets/pokemon-sprites/28.png +0 -0
- package/assets/pokemon-sprites/29.png +0 -0
- package/assets/pokemon-sprites/3.png +0 -0
- package/assets/pokemon-sprites/30.png +0 -0
- package/assets/pokemon-sprites/31.png +0 -0
- package/assets/pokemon-sprites/32.png +0 -0
- package/assets/pokemon-sprites/33.png +0 -0
- package/assets/pokemon-sprites/34.png +0 -0
- package/assets/pokemon-sprites/35.png +0 -0
- package/assets/pokemon-sprites/36.png +0 -0
- package/assets/pokemon-sprites/37.png +0 -0
- package/assets/pokemon-sprites/38.png +0 -0
- package/assets/pokemon-sprites/39.png +0 -0
- package/assets/pokemon-sprites/4.png +0 -0
- package/assets/pokemon-sprites/40.png +0 -0
- package/assets/pokemon-sprites/41.png +0 -0
- package/assets/pokemon-sprites/42.png +0 -0
- package/assets/pokemon-sprites/43.png +0 -0
- package/assets/pokemon-sprites/44.png +0 -0
- package/assets/pokemon-sprites/45.png +0 -0
- package/assets/pokemon-sprites/46.png +0 -0
- package/assets/pokemon-sprites/47.png +0 -0
- package/assets/pokemon-sprites/48.png +0 -0
- package/assets/pokemon-sprites/49.png +0 -0
- package/assets/pokemon-sprites/5.png +0 -0
- package/assets/pokemon-sprites/50.png +0 -0
- package/assets/pokemon-sprites/51.png +0 -0
- package/assets/pokemon-sprites/52.png +0 -0
- package/assets/pokemon-sprites/53.png +0 -0
- package/assets/pokemon-sprites/54.png +0 -0
- package/assets/pokemon-sprites/55.png +0 -0
- package/assets/pokemon-sprites/56.png +0 -0
- package/assets/pokemon-sprites/57.png +0 -0
- package/assets/pokemon-sprites/58.png +0 -0
- package/assets/pokemon-sprites/59.png +0 -0
- package/assets/pokemon-sprites/6.png +0 -0
- package/assets/pokemon-sprites/60.png +0 -0
- package/assets/pokemon-sprites/61.png +0 -0
- package/assets/pokemon-sprites/62.png +0 -0
- package/assets/pokemon-sprites/63.png +0 -0
- package/assets/pokemon-sprites/64.png +0 -0
- package/assets/pokemon-sprites/65.png +0 -0
- package/assets/pokemon-sprites/66.png +0 -0
- package/assets/pokemon-sprites/67.png +0 -0
- package/assets/pokemon-sprites/68.png +0 -0
- package/assets/pokemon-sprites/69.png +0 -0
- package/assets/pokemon-sprites/7.png +0 -0
- package/assets/pokemon-sprites/70.png +0 -0
- package/assets/pokemon-sprites/71.png +0 -0
- package/assets/pokemon-sprites/72.png +0 -0
- package/assets/pokemon-sprites/73.png +0 -0
- package/assets/pokemon-sprites/74.png +0 -0
- package/assets/pokemon-sprites/75.png +0 -0
- package/assets/pokemon-sprites/76.png +0 -0
- package/assets/pokemon-sprites/77.png +0 -0
- package/assets/pokemon-sprites/78.png +0 -0
- package/assets/pokemon-sprites/79.png +0 -0
- package/assets/pokemon-sprites/8.png +0 -0
- package/assets/pokemon-sprites/80.png +0 -0
- package/assets/pokemon-sprites/81.png +0 -0
- package/assets/pokemon-sprites/82.png +0 -0
- package/assets/pokemon-sprites/83.png +0 -0
- package/assets/pokemon-sprites/84.png +0 -0
- package/assets/pokemon-sprites/85.png +0 -0
- package/assets/pokemon-sprites/86.png +0 -0
- package/assets/pokemon-sprites/87.png +0 -0
- package/assets/pokemon-sprites/88.png +0 -0
- package/assets/pokemon-sprites/89.png +0 -0
- package/assets/pokemon-sprites/9.png +0 -0
- package/assets/pokemon-sprites/90.png +0 -0
- package/assets/pokemon-sprites/91.png +0 -0
- package/assets/pokemon-sprites/92.png +0 -0
- package/assets/pokemon-sprites/93.png +0 -0
- package/assets/pokemon-sprites/94.png +0 -0
- package/assets/pokemon-sprites/95.png +0 -0
- package/assets/pokemon-sprites/96.png +0 -0
- package/assets/pokemon-sprites/97.png +0 -0
- package/assets/pokemon-sprites/98.png +0 -0
- package/assets/pokemon-sprites/99.png +0 -0
- package/dist/ai.js +193 -0
- package/dist/bot.d.ts +22 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +547 -0
- package/dist/bot.js.map +1 -0
- package/dist/commands/debug-fail.js +8 -0
- package/dist/commands/hello-world.js +12 -0
- package/dist/commands/hello.d.ts +3 -0
- package/dist/commands/hello.d.ts.map +1 -0
- package/dist/commands/hello.js +8 -0
- package/dist/commands/hello.js.map +1 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +12 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/pokemon.js +132 -0
- package/dist/commands/print.d.ts +9 -0
- package/dist/commands/print.d.ts.map +1 -0
- package/dist/commands/print.js +46 -0
- package/dist/commands/print.js.map +1 -0
- package/dist/commands/sudoku.js +79 -0
- package/dist/commands/util.js +13 -0
- package/dist/commands/wordsearch.js +109 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +149 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/dist/printer.js +245 -0
- package/dist/printing/cups.js +89 -0
- package/dist/printing/escp.js +147 -0
- package/dist/printing/imagefeed.js +212 -0
- package/dist/printing/index.js +54 -0
- package/dist/reactions.js +5 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +312 -0
- package/dist/server.js.map +1 -0
- package/dist/tests/config.test.js +78 -0
- package/dist/tests/urls.test.js +54 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/dist/web/app.js +325 -0
- package/package.json +45 -0
- package/scripts/download-pokemon-sprites.mjs +38 -0
- package/src/ai.ts +214 -0
- package/src/bot.ts +657 -0
- package/src/commands/debug-fail.ts +11 -0
- package/src/commands/hello-world.ts +15 -0
- package/src/commands/index.ts +35 -0
- package/src/commands/pokemon.ts +146 -0
- package/src/commands/sudoku.ts +94 -0
- package/src/commands/util.ts +14 -0
- package/src/commands/wordsearch.ts +118 -0
- package/src/config.ts +166 -0
- package/src/index.ts +98 -0
- package/src/printer.ts +268 -0
- package/src/printing/cups.ts +104 -0
- package/src/printing/escp.ts +158 -0
- package/src/printing/imagefeed.ts +246 -0
- package/src/printing/index.ts +63 -0
- package/src/reactions.ts +6 -0
- package/src/server.ts +355 -0
- package/src/tests/config.test.ts +88 -0
- package/src/tests/urls.test.ts +64 -0
- package/src/types.ts +120 -0
- package/src/web/app.tsx +690 -0
- package/tsconfig.json +25 -0
package/dist/printer.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const ESC = '\x1B';
|
|
7
|
+
const GS = '\x1D';
|
|
8
|
+
const INIT = `${ESC}@`;
|
|
9
|
+
const BOLD_ON = `${ESC}E\x01`;
|
|
10
|
+
const BOLD_OFF = `${ESC}E\x00`;
|
|
11
|
+
// Double width + double height
|
|
12
|
+
const FONT_2X = `${GS}!\x11`;
|
|
13
|
+
// Double height only
|
|
14
|
+
const FONT_H2 = `${GS}!\x10`;
|
|
15
|
+
// Normal
|
|
16
|
+
const FONT_NORMAL = `${GS}!\x00`;
|
|
17
|
+
// Partial cut with 3mm feed
|
|
18
|
+
const CUT = `${GS}V\x41\x03`;
|
|
19
|
+
// Center align
|
|
20
|
+
const ALIGN_CENTER = `${ESC}a\x01`;
|
|
21
|
+
// Left align
|
|
22
|
+
const ALIGN_LEFT = `${ESC}a\x00`;
|
|
23
|
+
// Line feed
|
|
24
|
+
const LF = '\n';
|
|
25
|
+
// Characters per line at normal font on 80mm paper
|
|
26
|
+
const CHARS_NORMAL = 48;
|
|
27
|
+
const CHARS_DOUBLE = 24;
|
|
28
|
+
function wrapText(text, width) {
|
|
29
|
+
const words = text.split(' ');
|
|
30
|
+
const lines = [];
|
|
31
|
+
let current = '';
|
|
32
|
+
for (const word of words) {
|
|
33
|
+
if (current.length === 0) {
|
|
34
|
+
current = word;
|
|
35
|
+
}
|
|
36
|
+
else if (current.length + 1 + word.length <= width) {
|
|
37
|
+
current += ' ' + word;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
lines.push(current);
|
|
41
|
+
current = word;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (current)
|
|
45
|
+
lines.push(current);
|
|
46
|
+
return lines.length > 0 ? lines : [''];
|
|
47
|
+
}
|
|
48
|
+
function chooseFontForLines(totalLines) {
|
|
49
|
+
if (totalLines <= 4) {
|
|
50
|
+
return { fontSize: FONT_2X, charsPerLine: CHARS_DOUBLE };
|
|
51
|
+
}
|
|
52
|
+
else if (totalLines <= 8) {
|
|
53
|
+
return { fontSize: FONT_H2, charsPerLine: CHARS_DOUBLE };
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
return { fontSize: FONT_NORMAL, charsPerLine: CHARS_NORMAL };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function getConnectedPrinter() {
|
|
60
|
+
// Try CUPS first
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execAsync('lpstat -p 2>/dev/null');
|
|
63
|
+
const lines = stdout.split('\n');
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const match = /^printer\s+(\S+)/.exec(line);
|
|
66
|
+
if (match?.[1])
|
|
67
|
+
return match[1];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// CUPS not available or no printers
|
|
72
|
+
}
|
|
73
|
+
// Try USB device files
|
|
74
|
+
try {
|
|
75
|
+
const { stdout } = await execAsync('ls /dev/usb/lp* 2>/dev/null || ls /dev/lp* 2>/dev/null || true');
|
|
76
|
+
const devices = stdout.trim().split('\n').filter(Boolean);
|
|
77
|
+
if (devices.length > 0)
|
|
78
|
+
return devices[0] ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/** Decode a simple PNG into raw RGBA pixels using Node built-in. Returns null if can't decode. */
|
|
86
|
+
async function loadImageForPrinting(path) {
|
|
87
|
+
// Node doesn't have a built-in PNG decoder; we'd need a library.
|
|
88
|
+
// For now, skip image printing and return null.
|
|
89
|
+
// This is structured so QR/image printing can be added later.
|
|
90
|
+
void path;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
export async function formatPrintJob(job) {
|
|
94
|
+
const parts = [];
|
|
95
|
+
parts.push(INIT);
|
|
96
|
+
// Determine content lines count for font sizing
|
|
97
|
+
const rawLines = job.lines;
|
|
98
|
+
const previewFontLines = rawLines.length <= 2 ? 2 : rawLines.length;
|
|
99
|
+
const { fontSize, charsPerLine } = chooseFontForLines(previewFontLines);
|
|
100
|
+
// Header
|
|
101
|
+
if (job.header) {
|
|
102
|
+
parts.push(ALIGN_CENTER);
|
|
103
|
+
parts.push(FONT_2X);
|
|
104
|
+
parts.push(BOLD_ON);
|
|
105
|
+
const headerLines = wrapText(job.header, CHARS_DOUBLE);
|
|
106
|
+
parts.push(headerLines.join(LF));
|
|
107
|
+
parts.push(BOLD_OFF);
|
|
108
|
+
parts.push(FONT_NORMAL);
|
|
109
|
+
parts.push(ALIGN_LEFT);
|
|
110
|
+
parts.push(LF);
|
|
111
|
+
}
|
|
112
|
+
// Primary text
|
|
113
|
+
if (rawLines.length > 0) {
|
|
114
|
+
parts.push(fontSize);
|
|
115
|
+
const wrapped = [];
|
|
116
|
+
for (const line of rawLines) {
|
|
117
|
+
wrapped.push(...wrapText(line, charsPerLine));
|
|
118
|
+
}
|
|
119
|
+
parts.push(wrapped.join(LF));
|
|
120
|
+
parts.push(FONT_NORMAL);
|
|
121
|
+
parts.push(LF);
|
|
122
|
+
}
|
|
123
|
+
// Icon
|
|
124
|
+
if (job.iconPath) {
|
|
125
|
+
const img = await loadImageForPrinting(job.iconPath);
|
|
126
|
+
if (img) {
|
|
127
|
+
// Future: raster print via ESC/P
|
|
128
|
+
parts.push(`[icon: ${job.iconPath}]${LF}`);
|
|
129
|
+
}
|
|
130
|
+
// else silently skip
|
|
131
|
+
}
|
|
132
|
+
// URLs / QR codes
|
|
133
|
+
if (job.urls.length > 0) {
|
|
134
|
+
parts.push(LF);
|
|
135
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
136
|
+
const url = job.urls[i];
|
|
137
|
+
if (!url)
|
|
138
|
+
continue;
|
|
139
|
+
const label = job.urls.length === 1 ? '[link]' : `[link ${i + 1}]`;
|
|
140
|
+
// Print URL in a box since we don't have a QR library
|
|
141
|
+
const boxWidth = Math.min(CHARS_NORMAL, url.length + 4);
|
|
142
|
+
const top = '┌' + '─'.repeat(boxWidth - 2) + '┐';
|
|
143
|
+
const bottom = '└' + '─'.repeat(boxWidth - 2) + '┘';
|
|
144
|
+
const urlLines = wrapText(url, boxWidth - 4);
|
|
145
|
+
parts.push(`${label}${LF}`);
|
|
146
|
+
parts.push(`${top}${LF}`);
|
|
147
|
+
for (const l of urlLines) {
|
|
148
|
+
parts.push(`│ ${l.padEnd(boxWidth - 4)} │${LF}`);
|
|
149
|
+
}
|
|
150
|
+
parts.push(`${bottom}${LF}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Footer
|
|
154
|
+
if (job.footer) {
|
|
155
|
+
parts.push(LF);
|
|
156
|
+
parts.push(BOLD_ON);
|
|
157
|
+
const footerLines = wrapText(job.footer, CHARS_NORMAL);
|
|
158
|
+
parts.push(footerLines.join(LF));
|
|
159
|
+
parts.push(BOLD_OFF);
|
|
160
|
+
parts.push(LF);
|
|
161
|
+
}
|
|
162
|
+
// Metadata footer
|
|
163
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
164
|
+
parts.push(LF);
|
|
165
|
+
for (const line of job.metadataLines) {
|
|
166
|
+
parts.push(`${line}${LF}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Feed and cut
|
|
170
|
+
parts.push('\n\n\n');
|
|
171
|
+
parts.push(CUT);
|
|
172
|
+
return Buffer.from(parts.join(''), 'utf8');
|
|
173
|
+
}
|
|
174
|
+
export async function printJob(job, printerName) {
|
|
175
|
+
const printer = printerName ?? await getConnectedPrinter();
|
|
176
|
+
if (!printer) {
|
|
177
|
+
throw new Error('No printer available');
|
|
178
|
+
}
|
|
179
|
+
const data = await formatPrintJob(job);
|
|
180
|
+
if (printer.startsWith('/dev/')) {
|
|
181
|
+
// Direct USB - write via dd or direct file write
|
|
182
|
+
const { writeFile } = await import('fs/promises');
|
|
183
|
+
await writeFile(printer, data);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// CUPS printer — use `lp`
|
|
187
|
+
await new Promise((resolve, reject) => {
|
|
188
|
+
const child = execFile('lp', ['-d', printer, '-o', 'raw', '-'], (err) => {
|
|
189
|
+
if (err)
|
|
190
|
+
reject(err);
|
|
191
|
+
else
|
|
192
|
+
resolve();
|
|
193
|
+
});
|
|
194
|
+
child.stdin?.write(data);
|
|
195
|
+
child.stdin?.end();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export async function printTestPage(printerName) {
|
|
200
|
+
const testJob = {
|
|
201
|
+
header: 'Windsor Test Page',
|
|
202
|
+
lines: [
|
|
203
|
+
'Normal size text - The quick brown fox jumps over the lazy dog.',
|
|
204
|
+
'',
|
|
205
|
+
'--- Font Demo ---',
|
|
206
|
+
],
|
|
207
|
+
urls: ['https://github.com/SeaRyanC/d2p'],
|
|
208
|
+
footer: 'Footer text here',
|
|
209
|
+
metadataLines: [
|
|
210
|
+
`Printed at: ${formatTimestamp(new Date())}`,
|
|
211
|
+
`Printer: ${printerName}`,
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
const data = await formatPrintJob(testJob);
|
|
215
|
+
if (printerName.startsWith('/dev/')) {
|
|
216
|
+
const { writeFile } = await import('fs/promises');
|
|
217
|
+
await writeFile(printerName, data);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
const child = execFile('lp', ['-d', printerName, '-o', 'raw', '-'], (err) => {
|
|
222
|
+
if (err)
|
|
223
|
+
reject(err);
|
|
224
|
+
else
|
|
225
|
+
resolve();
|
|
226
|
+
});
|
|
227
|
+
child.stdin?.write(data);
|
|
228
|
+
child.stdin?.end();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export function formatTimestamp(date) {
|
|
233
|
+
const year = date.getFullYear();
|
|
234
|
+
const month = date.getMonth() + 1;
|
|
235
|
+
const day = date.getDate();
|
|
236
|
+
let hours = date.getHours();
|
|
237
|
+
const minutes = date.getMinutes();
|
|
238
|
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
239
|
+
hours = hours % 12 || 12;
|
|
240
|
+
const mm = String(minutes).padStart(2, '0');
|
|
241
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${hours}:${mm} ${ampm}`;
|
|
242
|
+
}
|
|
243
|
+
// suppress unused import warning
|
|
244
|
+
void readFile;
|
|
245
|
+
void execFileAsync;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { writeFile, unlink } from 'fs/promises';
|
|
5
|
+
import PDFDocument from 'pdfkit';
|
|
6
|
+
const MARGIN = 36; // 0.5 inch
|
|
7
|
+
const FONT_HEADER = 18;
|
|
8
|
+
const FONT_BODY_LARGE = 14;
|
|
9
|
+
const FONT_BODY_NORMAL = 10;
|
|
10
|
+
const FONT_FOOTER = 9;
|
|
11
|
+
const LINE_GAP = 4;
|
|
12
|
+
function chooseFontSize(totalLines) {
|
|
13
|
+
if (totalLines <= 4)
|
|
14
|
+
return FONT_BODY_LARGE;
|
|
15
|
+
return FONT_BODY_NORMAL;
|
|
16
|
+
}
|
|
17
|
+
export async function buildPdfBuffer(job, paperSize) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
const doc = new PDFDocument({
|
|
21
|
+
size: paperSize,
|
|
22
|
+
margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN },
|
|
23
|
+
autoFirstPage: true,
|
|
24
|
+
bufferPages: false,
|
|
25
|
+
});
|
|
26
|
+
doc.on('data', (chunk) => chunks.push(chunk));
|
|
27
|
+
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
28
|
+
doc.on('error', reject);
|
|
29
|
+
const bodyFontSize = chooseFontSize(job.lines.length <= 2 ? 2 : job.lines.length);
|
|
30
|
+
// Header
|
|
31
|
+
if (job.header) {
|
|
32
|
+
doc.fontSize(FONT_HEADER).font('Helvetica-Bold').text(job.header, { align: 'center', lineGap: LINE_GAP });
|
|
33
|
+
doc.moveDown(0.5);
|
|
34
|
+
}
|
|
35
|
+
// Primary text
|
|
36
|
+
if (job.lines.length > 0) {
|
|
37
|
+
doc.fontSize(bodyFontSize).font('Helvetica');
|
|
38
|
+
for (const line of job.lines) {
|
|
39
|
+
doc.text(line || ' ', { lineGap: LINE_GAP });
|
|
40
|
+
}
|
|
41
|
+
doc.moveDown(0.5);
|
|
42
|
+
}
|
|
43
|
+
// URLs
|
|
44
|
+
if (job.urls.length > 0) {
|
|
45
|
+
doc.moveDown(0.25);
|
|
46
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
47
|
+
const url = job.urls[i];
|
|
48
|
+
if (!url)
|
|
49
|
+
continue;
|
|
50
|
+
const label = job.urls.length === 1 ? 'Link:' : `Link ${i + 1}:`;
|
|
51
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica-Bold').text(label, { continued: true });
|
|
52
|
+
doc.font('Helvetica').text(` ${url}`, { link: url, underline: true, lineGap: LINE_GAP });
|
|
53
|
+
}
|
|
54
|
+
doc.moveDown(0.25);
|
|
55
|
+
}
|
|
56
|
+
// Footer
|
|
57
|
+
if (job.footer) {
|
|
58
|
+
doc.moveDown(0.25);
|
|
59
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica-Bold').text(job.footer, { lineGap: LINE_GAP });
|
|
60
|
+
}
|
|
61
|
+
// Metadata footer
|
|
62
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
63
|
+
doc.moveDown(0.25);
|
|
64
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica');
|
|
65
|
+
for (const line of job.metadataLines) {
|
|
66
|
+
doc.text(line, { lineGap: 2 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
doc.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function printJobCups(job, config) {
|
|
73
|
+
const pdfBuf = await buildPdfBuffer(job, config.paperSize);
|
|
74
|
+
const tmpPath = join(tmpdir(), `windsor-${Date.now()}.pdf`);
|
|
75
|
+
await writeFile(tmpPath, pdfBuf);
|
|
76
|
+
try {
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
execFile('lp', ['-d', config.printerName, '-o', `media=${config.paperSize}`, tmpPath], (err) => {
|
|
79
|
+
if (err)
|
|
80
|
+
reject(err);
|
|
81
|
+
else
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await unlink(tmpPath).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Printer, InMemory, Style, Align, Cut, Image } from 'escpos-buffer';
|
|
2
|
+
import { SerialPort } from 'serialport';
|
|
3
|
+
import { Jimp, ResizeStrategy } from 'jimp';
|
|
4
|
+
const COLS_NORMAL = 48;
|
|
5
|
+
const COLS_DOUBLE = 24;
|
|
6
|
+
function wrapText(text, width) {
|
|
7
|
+
const words = text.split(' ');
|
|
8
|
+
const lines = [];
|
|
9
|
+
let current = '';
|
|
10
|
+
for (const word of words) {
|
|
11
|
+
if (current.length === 0) {
|
|
12
|
+
current = word;
|
|
13
|
+
}
|
|
14
|
+
else if (current.length + 1 + word.length <= width) {
|
|
15
|
+
current += ' ' + word;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
lines.push(current);
|
|
19
|
+
current = word;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (current)
|
|
23
|
+
lines.push(current);
|
|
24
|
+
return lines.length > 0 ? lines : [''];
|
|
25
|
+
}
|
|
26
|
+
function chooseFontForLines(totalLines) {
|
|
27
|
+
if (totalLines <= 4) {
|
|
28
|
+
return { fontSize: 'double', cols: COLS_DOUBLE };
|
|
29
|
+
}
|
|
30
|
+
else if (totalLines <= 8) {
|
|
31
|
+
return { fontSize: 'tall', cols: COLS_DOUBLE };
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
return { fontSize: 'normal', cols: COLS_NORMAL };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function buildEscpBuffer(job) {
|
|
38
|
+
const conn = new InMemory();
|
|
39
|
+
const printer = await Printer.CONNECT('TM-T20', conn);
|
|
40
|
+
const { fontSize, cols } = chooseFontForLines(job.lines.length <= 2 ? 2 : job.lines.length);
|
|
41
|
+
// Header
|
|
42
|
+
if (job.header) {
|
|
43
|
+
const headerLines = wrapText(job.header, COLS_DOUBLE);
|
|
44
|
+
for (const line of headerLines) {
|
|
45
|
+
await printer.writeln(line, Style.Bold | Style.DoubleWidth | Style.DoubleHeight, Align.Center);
|
|
46
|
+
}
|
|
47
|
+
await printer.feed(1);
|
|
48
|
+
}
|
|
49
|
+
// Icon / sprite image
|
|
50
|
+
if (job.iconPath) {
|
|
51
|
+
try {
|
|
52
|
+
const img = await Jimp.read(job.iconPath);
|
|
53
|
+
const PRINT_WIDTH = 576; // ~80mm at 180dpi
|
|
54
|
+
const scale = img.width <= PRINT_WIDTH
|
|
55
|
+
? Math.floor(PRINT_WIDTH / img.width)
|
|
56
|
+
: PRINT_WIDTH / img.width;
|
|
57
|
+
img.resize({ w: Math.round(img.width * scale), h: Math.round(img.height * scale), mode: ResizeStrategy.NEAREST_NEIGHBOR });
|
|
58
|
+
const escImage = new Image(img.bitmap);
|
|
59
|
+
await printer.draw(escImage);
|
|
60
|
+
await printer.feed(1);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// silently skip if image can't be loaded/printed
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Primary text
|
|
67
|
+
if (job.lines.length > 0) {
|
|
68
|
+
const widthStyle = fontSize === 'double'
|
|
69
|
+
? Style.DoubleWidth | Style.DoubleHeight
|
|
70
|
+
: fontSize === 'tall'
|
|
71
|
+
? Style.DoubleHeight
|
|
72
|
+
: 0;
|
|
73
|
+
for (const line of job.lines) {
|
|
74
|
+
const wrapped = wrapText(line, cols);
|
|
75
|
+
for (const l of wrapped) {
|
|
76
|
+
await printer.writeln(l, widthStyle);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await printer.feed(1);
|
|
80
|
+
}
|
|
81
|
+
// URLs (printed as labelled text; QR code can be added later)
|
|
82
|
+
if (job.urls.length > 0) {
|
|
83
|
+
await printer.feed(1);
|
|
84
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
85
|
+
const url = job.urls[i];
|
|
86
|
+
if (!url)
|
|
87
|
+
continue;
|
|
88
|
+
const label = job.urls.length === 1 ? '[link]' : `[link ${i + 1}]`;
|
|
89
|
+
await printer.writeln(label, Style.Bold);
|
|
90
|
+
const urlLines = wrapText(url, COLS_NORMAL);
|
|
91
|
+
for (const l of urlLines) {
|
|
92
|
+
await printer.writeln(l);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Footer
|
|
97
|
+
if (job.footer) {
|
|
98
|
+
await printer.feed(1);
|
|
99
|
+
const footerLines = wrapText(job.footer, COLS_NORMAL);
|
|
100
|
+
for (const line of footerLines) {
|
|
101
|
+
await printer.writeln(line, Style.Bold);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Metadata footer
|
|
105
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
106
|
+
await printer.feed(1);
|
|
107
|
+
for (const line of job.metadataLines) {
|
|
108
|
+
await printer.writeln(line);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
await printer.feed(3);
|
|
112
|
+
await printer.cutter(Cut.Partial);
|
|
113
|
+
await printer.close();
|
|
114
|
+
return conn.buffer();
|
|
115
|
+
}
|
|
116
|
+
export async function printJobEscp(job, portPath) {
|
|
117
|
+
const data = await buildEscpBuffer(job);
|
|
118
|
+
// USB printer devices (e.g. /dev/usb/lp0) don't support serial ioctl;
|
|
119
|
+
// write directly to the device file instead.
|
|
120
|
+
if (/\/lp\d+$/.test(portPath)) {
|
|
121
|
+
const { writeFile } = await import('fs/promises');
|
|
122
|
+
await writeFile(portPath, data);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
const port = new SerialPort({ path: portPath, baudRate: 115200 });
|
|
127
|
+
port.on('error', reject);
|
|
128
|
+
port.on('open', () => {
|
|
129
|
+
port.write(data, (err) => {
|
|
130
|
+
if (err) {
|
|
131
|
+
reject(err);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
port.drain((drainErr) => {
|
|
135
|
+
port.close((closeErr) => {
|
|
136
|
+
if (drainErr)
|
|
137
|
+
reject(drainErr);
|
|
138
|
+
else if (closeErr)
|
|
139
|
+
reject(closeErr);
|
|
140
|
+
else
|
|
141
|
+
resolve();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Jimp, loadFont, measureText, JimpMime, ResizeStrategy } from 'jimp';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
3
|
+
const FONT_DIR = new URL('../../node_modules/@jimp/plugin-print/dist/fonts/open-sans/', import.meta.url).pathname;
|
|
4
|
+
const FONT_16 = `${FONT_DIR}open-sans-16-black/open-sans-16-black.fnt`;
|
|
5
|
+
const FONT_32 = `${FONT_DIR}open-sans-32-black/open-sans-32-black.fnt`;
|
|
6
|
+
const MAX_FEED_ENTRIES = 50;
|
|
7
|
+
const feed = [];
|
|
8
|
+
export function getImageFeed() {
|
|
9
|
+
return feed;
|
|
10
|
+
}
|
|
11
|
+
export function clearImageFeed() {
|
|
12
|
+
feed.length = 0;
|
|
13
|
+
}
|
|
14
|
+
const PAGE_WIDTH = 560; // ~80mm at 180dpi
|
|
15
|
+
const MARGIN = 20;
|
|
16
|
+
const LINE_HEIGHT_NORMAL = 20;
|
|
17
|
+
const LINE_HEIGHT_LARGE = 36;
|
|
18
|
+
const TEXT_WIDTH = PAGE_WIDTH - MARGIN * 2;
|
|
19
|
+
function chooseLineHeight(totalLines) {
|
|
20
|
+
if (totalLines <= 4)
|
|
21
|
+
return { lineHeight: LINE_HEIGHT_LARGE, fontPath: FONT_32 };
|
|
22
|
+
return { lineHeight: LINE_HEIGHT_NORMAL, fontPath: FONT_16 };
|
|
23
|
+
}
|
|
24
|
+
function wrapText(text, font, maxWidth) {
|
|
25
|
+
if (!text)
|
|
26
|
+
return [''];
|
|
27
|
+
const words = text.split(' ');
|
|
28
|
+
const lines = [];
|
|
29
|
+
let current = '';
|
|
30
|
+
for (const word of words) {
|
|
31
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
32
|
+
if (measureText(font, candidate) <= maxWidth) {
|
|
33
|
+
current = candidate;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (current)
|
|
37
|
+
lines.push(current);
|
|
38
|
+
current = word;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (current)
|
|
42
|
+
lines.push(current);
|
|
43
|
+
return lines.length > 0 ? lines : [''];
|
|
44
|
+
}
|
|
45
|
+
const QR_SIZE = 120; // pixels per QR code
|
|
46
|
+
async function renderQrCode(url) {
|
|
47
|
+
const pngBuf = await QRCode.toBuffer(url, { type: 'png', width: QR_SIZE, margin: 1 });
|
|
48
|
+
return Jimp.fromBuffer(pngBuf);
|
|
49
|
+
}
|
|
50
|
+
export async function renderJobToPng(job) {
|
|
51
|
+
const totalLines = job.lines.length <= 2 ? 2 : job.lines.length;
|
|
52
|
+
const { lineHeight, fontPath } = chooseLineHeight(totalLines);
|
|
53
|
+
const fontNormal = await loadFont(FONT_16);
|
|
54
|
+
const fontLarge = await loadFont(FONT_32);
|
|
55
|
+
const bodyFont = lineHeight === LINE_HEIGHT_LARGE ? fontLarge : fontNormal;
|
|
56
|
+
// Pre-load and scale the icon so we know its exact dimensions before sizing the canvas
|
|
57
|
+
let scaledSprite = null;
|
|
58
|
+
if (job.iconPath) {
|
|
59
|
+
try {
|
|
60
|
+
const sprite = await Jimp.read(job.iconPath);
|
|
61
|
+
// For small pixel-art sprites: scale up by integer factor for crisp pixels.
|
|
62
|
+
// For large icons: scale down to fit within TEXT_WIDTH.
|
|
63
|
+
const scale = sprite.width <= TEXT_WIDTH
|
|
64
|
+
? Math.floor(TEXT_WIDTH / sprite.width)
|
|
65
|
+
: TEXT_WIDTH / sprite.width;
|
|
66
|
+
sprite.resize({ w: Math.round(sprite.width * scale), h: Math.round(sprite.height * scale), mode: ResizeStrategy.NEAREST_NEIGHBOR });
|
|
67
|
+
scaledSprite = sprite;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// silently skip
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Measure total height now that icon dimensions are known
|
|
74
|
+
let estimatedHeight = MARGIN * 2;
|
|
75
|
+
if (job.header)
|
|
76
|
+
estimatedHeight += LINE_HEIGHT_LARGE * 2 + 12;
|
|
77
|
+
if (scaledSprite)
|
|
78
|
+
estimatedHeight += scaledSprite.height + 8;
|
|
79
|
+
for (const line of job.lines) {
|
|
80
|
+
const wrapped = wrapText(line || ' ', bodyFont, TEXT_WIDTH);
|
|
81
|
+
estimatedHeight += wrapped.length * lineHeight;
|
|
82
|
+
}
|
|
83
|
+
if (job.urls.length > 0)
|
|
84
|
+
estimatedHeight += job.urls.length * (QR_SIZE + LINE_HEIGHT_NORMAL + 8) + 8;
|
|
85
|
+
if (job.footer)
|
|
86
|
+
estimatedHeight += LINE_HEIGHT_NORMAL * 2 + 8;
|
|
87
|
+
if (job.metadataLines)
|
|
88
|
+
estimatedHeight += job.metadataLines.length * LINE_HEIGHT_NORMAL + 8;
|
|
89
|
+
estimatedHeight += 20; // bottom padding
|
|
90
|
+
const img = new Jimp({ width: PAGE_WIDTH, height: Math.max(estimatedHeight, 100), color: 0xffffffff });
|
|
91
|
+
let y = MARGIN;
|
|
92
|
+
const printLine = (text, font, lh) => {
|
|
93
|
+
img.print({ font, x: MARGIN, y, text, maxWidth: TEXT_WIDTH });
|
|
94
|
+
y += lh;
|
|
95
|
+
};
|
|
96
|
+
// Header
|
|
97
|
+
if (job.header) {
|
|
98
|
+
const headerLines = wrapText(job.header, fontLarge, TEXT_WIDTH);
|
|
99
|
+
for (const line of headerLines) {
|
|
100
|
+
img.print({ font: fontLarge, x: MARGIN, y, text: line, maxWidth: TEXT_WIDTH });
|
|
101
|
+
y += LINE_HEIGHT_LARGE;
|
|
102
|
+
}
|
|
103
|
+
y += 12;
|
|
104
|
+
}
|
|
105
|
+
// Icon / sprite image
|
|
106
|
+
if (scaledSprite) {
|
|
107
|
+
img.composite(scaledSprite, MARGIN, y);
|
|
108
|
+
y += scaledSprite.height + 8;
|
|
109
|
+
}
|
|
110
|
+
// Primary text
|
|
111
|
+
for (const line of job.lines) {
|
|
112
|
+
const wrapped = wrapText(line || ' ', bodyFont, TEXT_WIDTH);
|
|
113
|
+
for (const l of wrapped) {
|
|
114
|
+
printLine(l, bodyFont, lineHeight);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
y += 8;
|
|
118
|
+
// URLs — render each as a QR code with a label beneath it
|
|
119
|
+
if (job.urls.length > 0) {
|
|
120
|
+
y += 8;
|
|
121
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
122
|
+
const url = job.urls[i];
|
|
123
|
+
if (!url)
|
|
124
|
+
continue;
|
|
125
|
+
const label = job.urls.length === 1 ? '🔗 Scan to open link' : `🔗 Link ${i + 1}`;
|
|
126
|
+
printLine(label, fontNormal, LINE_HEIGHT_NORMAL);
|
|
127
|
+
const qrImg = await renderQrCode(url);
|
|
128
|
+
img.composite(qrImg, MARGIN, y);
|
|
129
|
+
y += QR_SIZE + 4;
|
|
130
|
+
// Print the URL text in small font next to/below the QR
|
|
131
|
+
const urlLines = wrapText(url, fontNormal, TEXT_WIDTH);
|
|
132
|
+
for (const l of urlLines) {
|
|
133
|
+
printLine(l, fontNormal, LINE_HEIGHT_NORMAL);
|
|
134
|
+
}
|
|
135
|
+
y += 4;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Footer
|
|
139
|
+
if (job.footer) {
|
|
140
|
+
y += 8;
|
|
141
|
+
const footerLines = wrapText(job.footer, fontNormal, TEXT_WIDTH);
|
|
142
|
+
for (const line of footerLines) {
|
|
143
|
+
printLine(line, fontNormal, LINE_HEIGHT_NORMAL);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Metadata
|
|
147
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
148
|
+
y += 8;
|
|
149
|
+
for (const line of job.metadataLines) {
|
|
150
|
+
printLine(line, fontNormal, LINE_HEIGHT_NORMAL);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Draw a dashed separator line at the bottom to simulate a receipt cut
|
|
154
|
+
const cutY = y + 10;
|
|
155
|
+
if (cutY < img.height) {
|
|
156
|
+
for (let x = MARGIN; x < PAGE_WIDTH - MARGIN; x += 8) {
|
|
157
|
+
img.setPixelColor(0x888888ff, x, cutY);
|
|
158
|
+
img.setPixelColor(0x888888ff, x + 1, cutY);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return img.getBuffer(JimpMime.png);
|
|
162
|
+
}
|
|
163
|
+
export async function printJobImageFeed(job) {
|
|
164
|
+
const png = await renderJobToPng(job);
|
|
165
|
+
const label = job.header ?? job.lines[0] ?? 'print';
|
|
166
|
+
feed.unshift({ timestamp: new Date(), label, png });
|
|
167
|
+
if (feed.length > MAX_FEED_ENTRIES)
|
|
168
|
+
feed.length = MAX_FEED_ENTRIES;
|
|
169
|
+
}
|
|
170
|
+
export async function printTestJobImageFeed(job) {
|
|
171
|
+
return printJobImageFeed(job);
|
|
172
|
+
}
|
|
173
|
+
export function renderFeedHtml() {
|
|
174
|
+
const items = feed.map((entry, i) => {
|
|
175
|
+
const b64 = entry.png.toString('base64');
|
|
176
|
+
const ts = entry.timestamp.toLocaleString();
|
|
177
|
+
return `
|
|
178
|
+
<div style="margin-bottom:32px; border-bottom: 1px solid #333; padding-bottom:24px;">
|
|
179
|
+
<p style="color:#888; margin:0 0 8px;">#${i + 1} — ${ts} — <em>${escHtml(entry.label)}</em></p>
|
|
180
|
+
<img src="data:image/png;base64,${b64}" style="max-width:100%; border:1px solid #444;" alt="print job ${i + 1}"/>
|
|
181
|
+
</div>`;
|
|
182
|
+
}).join('\n');
|
|
183
|
+
const emptyMsg = feed.length === 0
|
|
184
|
+
? '<p style="color:#888">No print jobs yet. Switch to "Image Feed" mode and trigger a print.</p>'
|
|
185
|
+
: '';
|
|
186
|
+
return `<!DOCTYPE html>
|
|
187
|
+
<html>
|
|
188
|
+
<head>
|
|
189
|
+
<meta charset="utf-8"/>
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
191
|
+
<title>Windsor Image Feed</title>
|
|
192
|
+
<style>
|
|
193
|
+
:root { color-scheme: dark; }
|
|
194
|
+
body { margin: 0; padding: 24px; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #0f1115; color: #f5f7fa; max-width: 700px; }
|
|
195
|
+
h1 { margin-top: 0; }
|
|
196
|
+
button { background: #2e7dff; color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; margin-bottom: 24px; }
|
|
197
|
+
</style>
|
|
198
|
+
</head>
|
|
199
|
+
<body>
|
|
200
|
+
<h1>🖨️ Image Feed</h1>
|
|
201
|
+
<form method="POST" action="/feed/clear" style="display:inline">
|
|
202
|
+
<button type="submit">Clear Feed</button>
|
|
203
|
+
</form>
|
|
204
|
+
<p style="color:#888; margin-top:0;">Newest first. Refresh the page to see new jobs. ${feed.length} job(s).</p>
|
|
205
|
+
${emptyMsg}
|
|
206
|
+
${items}
|
|
207
|
+
</body>
|
|
208
|
+
</html>`;
|
|
209
|
+
}
|
|
210
|
+
function escHtml(str) {
|
|
211
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
212
|
+
}
|