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/src/printer.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import type { PrintJob } from './types.ts';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const ESC = '\x1B';
|
|
11
|
+
const GS = '\x1D';
|
|
12
|
+
|
|
13
|
+
const INIT = `${ESC}@`;
|
|
14
|
+
const BOLD_ON = `${ESC}E\x01`;
|
|
15
|
+
const BOLD_OFF = `${ESC}E\x00`;
|
|
16
|
+
// Double width + double height
|
|
17
|
+
const FONT_2X = `${GS}!\x11`;
|
|
18
|
+
// Double height only
|
|
19
|
+
const FONT_H2 = `${GS}!\x10`;
|
|
20
|
+
// Normal
|
|
21
|
+
const FONT_NORMAL = `${GS}!\x00`;
|
|
22
|
+
// Partial cut with 3mm feed
|
|
23
|
+
const CUT = `${GS}V\x41\x03`;
|
|
24
|
+
// Center align
|
|
25
|
+
const ALIGN_CENTER = `${ESC}a\x01`;
|
|
26
|
+
// Left align
|
|
27
|
+
const ALIGN_LEFT = `${ESC}a\x00`;
|
|
28
|
+
// Line feed
|
|
29
|
+
const LF = '\n';
|
|
30
|
+
|
|
31
|
+
// Characters per line at normal font on 80mm paper
|
|
32
|
+
const CHARS_NORMAL = 48;
|
|
33
|
+
const CHARS_DOUBLE = 24;
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
function wrapText(text: string, width: number): string[] {
|
|
37
|
+
const words = text.split(' ');
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
let current = '';
|
|
40
|
+
for (const word of words) {
|
|
41
|
+
if (current.length === 0) {
|
|
42
|
+
current = word;
|
|
43
|
+
} else if (current.length + 1 + word.length <= width) {
|
|
44
|
+
current += ' ' + word;
|
|
45
|
+
} else {
|
|
46
|
+
lines.push(current);
|
|
47
|
+
current = word;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (current) lines.push(current);
|
|
51
|
+
return lines.length > 0 ? lines : [''];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
function chooseFontForLines(totalLines: number): { fontSize: string; charsPerLine: number } {
|
|
56
|
+
if (totalLines <= 4) {
|
|
57
|
+
return { fontSize: FONT_2X, charsPerLine: CHARS_DOUBLE };
|
|
58
|
+
} else if (totalLines <= 8) {
|
|
59
|
+
return { fontSize: FONT_H2, charsPerLine: CHARS_DOUBLE };
|
|
60
|
+
} else {
|
|
61
|
+
return { fontSize: FONT_NORMAL, charsPerLine: CHARS_NORMAL };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export async function getConnectedPrinter(): Promise<string | null> {
|
|
67
|
+
// Try CUPS first
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execAsync('lpstat -p 2>/dev/null');
|
|
70
|
+
const lines = stdout.split('\n');
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const match = /^printer\s+(\S+)/.exec(line);
|
|
73
|
+
if (match?.[1]) return match[1];
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// CUPS not available or no printers
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Try USB device files
|
|
80
|
+
try {
|
|
81
|
+
const { stdout } = await execAsync('ls /dev/usb/lp* 2>/dev/null || ls /dev/lp* 2>/dev/null || true');
|
|
82
|
+
const devices = stdout.trim().split('\n').filter(Boolean);
|
|
83
|
+
if (devices.length > 0) return devices[0] ?? null;
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/** Decode a simple PNG into raw RGBA pixels using Node built-in. Returns null if can't decode. */
|
|
93
|
+
async function loadImageForPrinting(path: string): Promise<{ width: number; height: number; pixels: Buffer } | null> {
|
|
94
|
+
// Node doesn't have a built-in PNG decoder; we'd need a library.
|
|
95
|
+
// For now, skip image printing and return null.
|
|
96
|
+
// This is structured so QR/image printing can be added later.
|
|
97
|
+
void path;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
export async function formatPrintJob(job: PrintJob): Promise<Buffer> {
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
|
|
105
|
+
parts.push(INIT);
|
|
106
|
+
|
|
107
|
+
// Determine content lines count for font sizing
|
|
108
|
+
const rawLines = job.lines;
|
|
109
|
+
const previewFontLines = rawLines.length <= 2 ? 2 : rawLines.length;
|
|
110
|
+
const { fontSize, charsPerLine } = chooseFontForLines(previewFontLines);
|
|
111
|
+
|
|
112
|
+
// Header
|
|
113
|
+
if (job.header) {
|
|
114
|
+
parts.push(ALIGN_CENTER);
|
|
115
|
+
parts.push(FONT_2X);
|
|
116
|
+
parts.push(BOLD_ON);
|
|
117
|
+
const headerLines = wrapText(job.header, CHARS_DOUBLE);
|
|
118
|
+
parts.push(headerLines.join(LF));
|
|
119
|
+
parts.push(BOLD_OFF);
|
|
120
|
+
parts.push(FONT_NORMAL);
|
|
121
|
+
parts.push(ALIGN_LEFT);
|
|
122
|
+
parts.push(LF);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Primary text
|
|
126
|
+
if (rawLines.length > 0) {
|
|
127
|
+
parts.push(fontSize);
|
|
128
|
+
const wrapped: string[] = [];
|
|
129
|
+
for (const line of rawLines) {
|
|
130
|
+
wrapped.push(...wrapText(line, charsPerLine));
|
|
131
|
+
}
|
|
132
|
+
parts.push(wrapped.join(LF));
|
|
133
|
+
parts.push(FONT_NORMAL);
|
|
134
|
+
parts.push(LF);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Icon
|
|
138
|
+
if (job.iconPath) {
|
|
139
|
+
const img = await loadImageForPrinting(job.iconPath);
|
|
140
|
+
if (img) {
|
|
141
|
+
// Future: raster print via ESC/P
|
|
142
|
+
parts.push(`[icon: ${job.iconPath}]${LF}`);
|
|
143
|
+
}
|
|
144
|
+
// else silently skip
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// URLs / QR codes
|
|
148
|
+
if (job.urls.length > 0) {
|
|
149
|
+
parts.push(LF);
|
|
150
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
151
|
+
const url = job.urls[i];
|
|
152
|
+
if (!url) continue;
|
|
153
|
+
const label = job.urls.length === 1 ? '[link]' : `[link ${i + 1}]`;
|
|
154
|
+
// Print URL in a box since we don't have a QR library
|
|
155
|
+
const boxWidth = Math.min(CHARS_NORMAL, url.length + 4);
|
|
156
|
+
const top = '┌' + '─'.repeat(boxWidth - 2) + '┐';
|
|
157
|
+
const bottom = '└' + '─'.repeat(boxWidth - 2) + '┘';
|
|
158
|
+
const urlLines = wrapText(url, boxWidth - 4);
|
|
159
|
+
parts.push(`${label}${LF}`);
|
|
160
|
+
parts.push(`${top}${LF}`);
|
|
161
|
+
for (const l of urlLines) {
|
|
162
|
+
parts.push(`│ ${l.padEnd(boxWidth - 4)} │${LF}`);
|
|
163
|
+
}
|
|
164
|
+
parts.push(`${bottom}${LF}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Footer
|
|
169
|
+
if (job.footer) {
|
|
170
|
+
parts.push(LF);
|
|
171
|
+
parts.push(BOLD_ON);
|
|
172
|
+
const footerLines = wrapText(job.footer, CHARS_NORMAL);
|
|
173
|
+
parts.push(footerLines.join(LF));
|
|
174
|
+
parts.push(BOLD_OFF);
|
|
175
|
+
parts.push(LF);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Metadata footer
|
|
179
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
180
|
+
parts.push(LF);
|
|
181
|
+
for (const line of job.metadataLines) {
|
|
182
|
+
parts.push(`${line}${LF}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Feed and cut
|
|
187
|
+
parts.push('\n\n\n');
|
|
188
|
+
parts.push(CUT);
|
|
189
|
+
|
|
190
|
+
return Buffer.from(parts.join(''), 'utf8');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
export async function printJob(job: PrintJob, printerName?: string): Promise<void> {
|
|
195
|
+
const printer = printerName ?? await getConnectedPrinter();
|
|
196
|
+
if (!printer) {
|
|
197
|
+
throw new Error('No printer available');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const data = await formatPrintJob(job);
|
|
201
|
+
|
|
202
|
+
if (printer.startsWith('/dev/')) {
|
|
203
|
+
// Direct USB - write via dd or direct file write
|
|
204
|
+
const { writeFile } = await import('fs/promises');
|
|
205
|
+
await writeFile(printer, data);
|
|
206
|
+
} else {
|
|
207
|
+
// CUPS printer — use `lp`
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
const child = execFile('lp', ['-d', printer, '-o', 'raw', '-'], (err) => {
|
|
210
|
+
if (err) reject(err);
|
|
211
|
+
else resolve();
|
|
212
|
+
});
|
|
213
|
+
child.stdin?.write(data);
|
|
214
|
+
child.stdin?.end();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
export async function printTestPage(printerName: string): Promise<void> {
|
|
221
|
+
const testJob: PrintJob = {
|
|
222
|
+
header: 'Windsor Test Page',
|
|
223
|
+
lines: [
|
|
224
|
+
'Normal size text - The quick brown fox jumps over the lazy dog.',
|
|
225
|
+
'',
|
|
226
|
+
'--- Font Demo ---',
|
|
227
|
+
],
|
|
228
|
+
urls: ['https://github.com/SeaRyanC/d2p'],
|
|
229
|
+
footer: 'Footer text here',
|
|
230
|
+
metadataLines: [
|
|
231
|
+
`Printed at: ${formatTimestamp(new Date())}`,
|
|
232
|
+
`Printer: ${printerName}`,
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const data = await formatPrintJob(testJob);
|
|
237
|
+
|
|
238
|
+
if (printerName.startsWith('/dev/')) {
|
|
239
|
+
const { writeFile } = await import('fs/promises');
|
|
240
|
+
await writeFile(printerName, data);
|
|
241
|
+
} else {
|
|
242
|
+
await new Promise<void>((resolve, reject) => {
|
|
243
|
+
const child = execFile('lp', ['-d', printerName, '-o', 'raw', '-'], (err) => {
|
|
244
|
+
if (err) reject(err);
|
|
245
|
+
else resolve();
|
|
246
|
+
});
|
|
247
|
+
child.stdin?.write(data);
|
|
248
|
+
child.stdin?.end();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
export function formatTimestamp(date: Date): string {
|
|
255
|
+
const year = date.getFullYear();
|
|
256
|
+
const month = date.getMonth() + 1;
|
|
257
|
+
const day = date.getDate();
|
|
258
|
+
let hours = date.getHours();
|
|
259
|
+
const minutes = date.getMinutes();
|
|
260
|
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
261
|
+
hours = hours % 12 || 12;
|
|
262
|
+
const mm = String(minutes).padStart(2, '0');
|
|
263
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${hours}:${mm} ${ampm}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// suppress unused import warning
|
|
267
|
+
void readFile;
|
|
268
|
+
void execFileAsync;
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import type { PrintJob } from '../types.ts';
|
|
7
|
+
import type { CupsPrintConfig } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const MARGIN = 36; // 0.5 inch
|
|
11
|
+
const FONT_HEADER = 18;
|
|
12
|
+
const FONT_BODY_LARGE = 14;
|
|
13
|
+
const FONT_BODY_NORMAL = 10;
|
|
14
|
+
const FONT_FOOTER = 9;
|
|
15
|
+
const LINE_GAP = 4;
|
|
16
|
+
|
|
17
|
+
function chooseFontSize(totalLines: number): number {
|
|
18
|
+
if (totalLines <= 4) return FONT_BODY_LARGE;
|
|
19
|
+
return FONT_BODY_NORMAL;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export async function buildPdfBuffer(job: PrintJob, paperSize: string): Promise<Buffer> {
|
|
24
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
25
|
+
const chunks: Buffer[] = [];
|
|
26
|
+
|
|
27
|
+
const doc = new PDFDocument({
|
|
28
|
+
size: paperSize,
|
|
29
|
+
margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN },
|
|
30
|
+
autoFirstPage: true,
|
|
31
|
+
bufferPages: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
35
|
+
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
36
|
+
doc.on('error', reject);
|
|
37
|
+
|
|
38
|
+
const bodyFontSize = chooseFontSize(job.lines.length <= 2 ? 2 : job.lines.length);
|
|
39
|
+
|
|
40
|
+
// Header
|
|
41
|
+
if (job.header) {
|
|
42
|
+
doc.fontSize(FONT_HEADER).font('Helvetica-Bold').text(job.header, { align: 'center', lineGap: LINE_GAP });
|
|
43
|
+
doc.moveDown(0.5);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Primary text
|
|
47
|
+
if (job.lines.length > 0) {
|
|
48
|
+
doc.fontSize(bodyFontSize).font('Helvetica');
|
|
49
|
+
for (const line of job.lines) {
|
|
50
|
+
doc.text(line || ' ', { lineGap: LINE_GAP });
|
|
51
|
+
}
|
|
52
|
+
doc.moveDown(0.5);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// URLs
|
|
56
|
+
if (job.urls.length > 0) {
|
|
57
|
+
doc.moveDown(0.25);
|
|
58
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
59
|
+
const url = job.urls[i];
|
|
60
|
+
if (!url) continue;
|
|
61
|
+
const label = job.urls.length === 1 ? 'Link:' : `Link ${i + 1}:`;
|
|
62
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica-Bold').text(label, { continued: true });
|
|
63
|
+
doc.font('Helvetica').text(` ${url}`, { link: url, underline: true, lineGap: LINE_GAP });
|
|
64
|
+
}
|
|
65
|
+
doc.moveDown(0.25);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Footer
|
|
69
|
+
if (job.footer) {
|
|
70
|
+
doc.moveDown(0.25);
|
|
71
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica-Bold').text(job.footer, { lineGap: LINE_GAP });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Metadata footer
|
|
75
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
76
|
+
doc.moveDown(0.25);
|
|
77
|
+
doc.fontSize(FONT_FOOTER).font('Helvetica');
|
|
78
|
+
for (const line of job.metadataLines) {
|
|
79
|
+
doc.text(line, { lineGap: 2 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
doc.end();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
export async function printJobCups(job: PrintJob, config: CupsPrintConfig): Promise<void> {
|
|
89
|
+
const pdfBuf = await buildPdfBuffer(job, config.paperSize);
|
|
90
|
+
|
|
91
|
+
const tmpPath = join(tmpdir(), `windsor-${Date.now()}.pdf`);
|
|
92
|
+
await writeFile(tmpPath, pdfBuf);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await new Promise<void>((resolve, reject) => {
|
|
96
|
+
execFile('lp', ['-d', config.printerName, '-o', `media=${config.paperSize}`, tmpPath], (err) => {
|
|
97
|
+
if (err) reject(err);
|
|
98
|
+
else resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
await unlink(tmpPath).catch(() => { /* ignore cleanup errors */ });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Printer, InMemory, Style, Align, Cut, Image } from 'escpos-buffer';
|
|
2
|
+
import { SerialPort } from 'serialport';
|
|
3
|
+
import { Jimp, ResizeStrategy } from 'jimp';
|
|
4
|
+
import type { PrintJob } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
const COLS_NORMAL = 48;
|
|
7
|
+
const COLS_DOUBLE = 24;
|
|
8
|
+
|
|
9
|
+
function wrapText(text: string, width: number): string[] {
|
|
10
|
+
const words = text.split(' ');
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
for (const word of words) {
|
|
14
|
+
if (current.length === 0) {
|
|
15
|
+
current = word;
|
|
16
|
+
} else if (current.length + 1 + word.length <= width) {
|
|
17
|
+
current += ' ' + word;
|
|
18
|
+
} else {
|
|
19
|
+
lines.push(current);
|
|
20
|
+
current = word;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (current) lines.push(current);
|
|
24
|
+
return lines.length > 0 ? lines : [''];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
type FontSize = 'double' | 'tall' | 'normal';
|
|
29
|
+
|
|
30
|
+
function chooseFontForLines(totalLines: number): { fontSize: FontSize; cols: number } {
|
|
31
|
+
if (totalLines <= 4) {
|
|
32
|
+
return { fontSize: 'double', cols: COLS_DOUBLE };
|
|
33
|
+
} else if (totalLines <= 8) {
|
|
34
|
+
return { fontSize: 'tall', cols: COLS_DOUBLE };
|
|
35
|
+
} else {
|
|
36
|
+
return { fontSize: 'normal', cols: COLS_NORMAL };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
export async function buildEscpBuffer(job: PrintJob): Promise<Buffer> {
|
|
42
|
+
const conn = new InMemory();
|
|
43
|
+
const printer = await Printer.CONNECT('TM-T20', conn);
|
|
44
|
+
|
|
45
|
+
const { fontSize, cols } = chooseFontForLines(job.lines.length <= 2 ? 2 : job.lines.length);
|
|
46
|
+
|
|
47
|
+
// Header
|
|
48
|
+
if (job.header) {
|
|
49
|
+
const headerLines = wrapText(job.header, COLS_DOUBLE);
|
|
50
|
+
for (const line of headerLines) {
|
|
51
|
+
await printer.writeln(line, Style.Bold | Style.DoubleWidth | Style.DoubleHeight, Align.Center);
|
|
52
|
+
}
|
|
53
|
+
await printer.feed(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Icon / sprite image
|
|
57
|
+
if (job.iconPath) {
|
|
58
|
+
try {
|
|
59
|
+
const img = await Jimp.read(job.iconPath);
|
|
60
|
+
const PRINT_WIDTH = 576; // ~80mm at 180dpi
|
|
61
|
+
const scale = img.width <= PRINT_WIDTH
|
|
62
|
+
? Math.floor(PRINT_WIDTH / img.width)
|
|
63
|
+
: PRINT_WIDTH / img.width;
|
|
64
|
+
img.resize({ w: Math.round(img.width * scale), h: Math.round(img.height * scale), mode: ResizeStrategy.NEAREST_NEIGHBOR });
|
|
65
|
+
const escImage = new Image(img.bitmap as { width: number; height: number; data: Buffer });
|
|
66
|
+
await printer.draw(escImage);
|
|
67
|
+
await printer.feed(1);
|
|
68
|
+
} catch {
|
|
69
|
+
// silently skip if image can't be loaded/printed
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Primary text
|
|
74
|
+
if (job.lines.length > 0) {
|
|
75
|
+
const widthStyle = fontSize === 'double'
|
|
76
|
+
? Style.DoubleWidth | Style.DoubleHeight
|
|
77
|
+
: fontSize === 'tall'
|
|
78
|
+
? Style.DoubleHeight
|
|
79
|
+
: 0;
|
|
80
|
+
|
|
81
|
+
for (const line of job.lines) {
|
|
82
|
+
const wrapped = wrapText(line, cols);
|
|
83
|
+
for (const l of wrapped) {
|
|
84
|
+
await printer.writeln(l, widthStyle);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await printer.feed(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// URLs (printed as labelled text; QR code can be added later)
|
|
91
|
+
if (job.urls.length > 0) {
|
|
92
|
+
await printer.feed(1);
|
|
93
|
+
for (let i = 0; i < job.urls.length; i++) {
|
|
94
|
+
const url = job.urls[i];
|
|
95
|
+
if (!url) continue;
|
|
96
|
+
const label = job.urls.length === 1 ? '[link]' : `[link ${i + 1}]`;
|
|
97
|
+
await printer.writeln(label, Style.Bold);
|
|
98
|
+
const urlLines = wrapText(url, COLS_NORMAL);
|
|
99
|
+
for (const l of urlLines) {
|
|
100
|
+
await printer.writeln(l);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Footer
|
|
106
|
+
if (job.footer) {
|
|
107
|
+
await printer.feed(1);
|
|
108
|
+
const footerLines = wrapText(job.footer, COLS_NORMAL);
|
|
109
|
+
for (const line of footerLines) {
|
|
110
|
+
await printer.writeln(line, Style.Bold);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Metadata footer
|
|
115
|
+
if (job.metadataLines && job.metadataLines.length > 0) {
|
|
116
|
+
await printer.feed(1);
|
|
117
|
+
for (const line of job.metadataLines) {
|
|
118
|
+
await printer.writeln(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await printer.feed(3);
|
|
123
|
+
await printer.cutter(Cut.Partial);
|
|
124
|
+
await printer.close();
|
|
125
|
+
|
|
126
|
+
return conn.buffer();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
export async function printJobEscp(job: PrintJob, portPath: string): Promise<void> {
|
|
131
|
+
const data = await buildEscpBuffer(job);
|
|
132
|
+
|
|
133
|
+
// USB printer devices (e.g. /dev/usb/lp0) don't support serial ioctl;
|
|
134
|
+
// write directly to the device file instead.
|
|
135
|
+
if (/\/lp\d+$/.test(portPath)) {
|
|
136
|
+
const { writeFile } = await import('fs/promises');
|
|
137
|
+
await writeFile(portPath, data);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await new Promise<void>((resolve, reject) => {
|
|
142
|
+
const port = new SerialPort({ path: portPath, baudRate: 115200 });
|
|
143
|
+
|
|
144
|
+
port.on('error', reject);
|
|
145
|
+
port.on('open', () => {
|
|
146
|
+
port.write(data, (err) => {
|
|
147
|
+
if (err) { reject(err); return; }
|
|
148
|
+
port.drain((drainErr) => {
|
|
149
|
+
port.close((closeErr) => {
|
|
150
|
+
if (drainErr) reject(drainErr);
|
|
151
|
+
else if (closeErr) reject(closeErr);
|
|
152
|
+
else resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|