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.
Files changed (226) hide show
  1. package/README.md +128 -0
  2. package/SPEC.md +179 -0
  3. package/assets/pokemon-sprites/1.png +0 -0
  4. package/assets/pokemon-sprites/10.png +0 -0
  5. package/assets/pokemon-sprites/100.png +0 -0
  6. package/assets/pokemon-sprites/101.png +0 -0
  7. package/assets/pokemon-sprites/102.png +0 -0
  8. package/assets/pokemon-sprites/103.png +0 -0
  9. package/assets/pokemon-sprites/104.png +0 -0
  10. package/assets/pokemon-sprites/105.png +0 -0
  11. package/assets/pokemon-sprites/106.png +0 -0
  12. package/assets/pokemon-sprites/107.png +0 -0
  13. package/assets/pokemon-sprites/108.png +0 -0
  14. package/assets/pokemon-sprites/109.png +0 -0
  15. package/assets/pokemon-sprites/11.png +0 -0
  16. package/assets/pokemon-sprites/110.png +0 -0
  17. package/assets/pokemon-sprites/111.png +0 -0
  18. package/assets/pokemon-sprites/112.png +0 -0
  19. package/assets/pokemon-sprites/113.png +0 -0
  20. package/assets/pokemon-sprites/114.png +0 -0
  21. package/assets/pokemon-sprites/115.png +0 -0
  22. package/assets/pokemon-sprites/116.png +0 -0
  23. package/assets/pokemon-sprites/117.png +0 -0
  24. package/assets/pokemon-sprites/118.png +0 -0
  25. package/assets/pokemon-sprites/119.png +0 -0
  26. package/assets/pokemon-sprites/12.png +0 -0
  27. package/assets/pokemon-sprites/120.png +0 -0
  28. package/assets/pokemon-sprites/121.png +0 -0
  29. package/assets/pokemon-sprites/122.png +0 -0
  30. package/assets/pokemon-sprites/123.png +0 -0
  31. package/assets/pokemon-sprites/124.png +0 -0
  32. package/assets/pokemon-sprites/125.png +0 -0
  33. package/assets/pokemon-sprites/126.png +0 -0
  34. package/assets/pokemon-sprites/127.png +0 -0
  35. package/assets/pokemon-sprites/128.png +0 -0
  36. package/assets/pokemon-sprites/129.png +0 -0
  37. package/assets/pokemon-sprites/13.png +0 -0
  38. package/assets/pokemon-sprites/130.png +0 -0
  39. package/assets/pokemon-sprites/131.png +0 -0
  40. package/assets/pokemon-sprites/132.png +0 -0
  41. package/assets/pokemon-sprites/133.png +0 -0
  42. package/assets/pokemon-sprites/134.png +0 -0
  43. package/assets/pokemon-sprites/135.png +0 -0
  44. package/assets/pokemon-sprites/136.png +0 -0
  45. package/assets/pokemon-sprites/137.png +0 -0
  46. package/assets/pokemon-sprites/138.png +0 -0
  47. package/assets/pokemon-sprites/139.png +0 -0
  48. package/assets/pokemon-sprites/14.png +0 -0
  49. package/assets/pokemon-sprites/140.png +0 -0
  50. package/assets/pokemon-sprites/141.png +0 -0
  51. package/assets/pokemon-sprites/142.png +0 -0
  52. package/assets/pokemon-sprites/143.png +0 -0
  53. package/assets/pokemon-sprites/144.png +0 -0
  54. package/assets/pokemon-sprites/145.png +0 -0
  55. package/assets/pokemon-sprites/146.png +0 -0
  56. package/assets/pokemon-sprites/147.png +0 -0
  57. package/assets/pokemon-sprites/148.png +0 -0
  58. package/assets/pokemon-sprites/149.png +0 -0
  59. package/assets/pokemon-sprites/15.png +0 -0
  60. package/assets/pokemon-sprites/150.png +0 -0
  61. package/assets/pokemon-sprites/151.png +0 -0
  62. package/assets/pokemon-sprites/16.png +0 -0
  63. package/assets/pokemon-sprites/17.png +0 -0
  64. package/assets/pokemon-sprites/18.png +0 -0
  65. package/assets/pokemon-sprites/19.png +0 -0
  66. package/assets/pokemon-sprites/2.png +0 -0
  67. package/assets/pokemon-sprites/20.png +0 -0
  68. package/assets/pokemon-sprites/21.png +0 -0
  69. package/assets/pokemon-sprites/22.png +0 -0
  70. package/assets/pokemon-sprites/23.png +0 -0
  71. package/assets/pokemon-sprites/24.png +0 -0
  72. package/assets/pokemon-sprites/25.png +0 -0
  73. package/assets/pokemon-sprites/26.png +0 -0
  74. package/assets/pokemon-sprites/27.png +0 -0
  75. package/assets/pokemon-sprites/28.png +0 -0
  76. package/assets/pokemon-sprites/29.png +0 -0
  77. package/assets/pokemon-sprites/3.png +0 -0
  78. package/assets/pokemon-sprites/30.png +0 -0
  79. package/assets/pokemon-sprites/31.png +0 -0
  80. package/assets/pokemon-sprites/32.png +0 -0
  81. package/assets/pokemon-sprites/33.png +0 -0
  82. package/assets/pokemon-sprites/34.png +0 -0
  83. package/assets/pokemon-sprites/35.png +0 -0
  84. package/assets/pokemon-sprites/36.png +0 -0
  85. package/assets/pokemon-sprites/37.png +0 -0
  86. package/assets/pokemon-sprites/38.png +0 -0
  87. package/assets/pokemon-sprites/39.png +0 -0
  88. package/assets/pokemon-sprites/4.png +0 -0
  89. package/assets/pokemon-sprites/40.png +0 -0
  90. package/assets/pokemon-sprites/41.png +0 -0
  91. package/assets/pokemon-sprites/42.png +0 -0
  92. package/assets/pokemon-sprites/43.png +0 -0
  93. package/assets/pokemon-sprites/44.png +0 -0
  94. package/assets/pokemon-sprites/45.png +0 -0
  95. package/assets/pokemon-sprites/46.png +0 -0
  96. package/assets/pokemon-sprites/47.png +0 -0
  97. package/assets/pokemon-sprites/48.png +0 -0
  98. package/assets/pokemon-sprites/49.png +0 -0
  99. package/assets/pokemon-sprites/5.png +0 -0
  100. package/assets/pokemon-sprites/50.png +0 -0
  101. package/assets/pokemon-sprites/51.png +0 -0
  102. package/assets/pokemon-sprites/52.png +0 -0
  103. package/assets/pokemon-sprites/53.png +0 -0
  104. package/assets/pokemon-sprites/54.png +0 -0
  105. package/assets/pokemon-sprites/55.png +0 -0
  106. package/assets/pokemon-sprites/56.png +0 -0
  107. package/assets/pokemon-sprites/57.png +0 -0
  108. package/assets/pokemon-sprites/58.png +0 -0
  109. package/assets/pokemon-sprites/59.png +0 -0
  110. package/assets/pokemon-sprites/6.png +0 -0
  111. package/assets/pokemon-sprites/60.png +0 -0
  112. package/assets/pokemon-sprites/61.png +0 -0
  113. package/assets/pokemon-sprites/62.png +0 -0
  114. package/assets/pokemon-sprites/63.png +0 -0
  115. package/assets/pokemon-sprites/64.png +0 -0
  116. package/assets/pokemon-sprites/65.png +0 -0
  117. package/assets/pokemon-sprites/66.png +0 -0
  118. package/assets/pokemon-sprites/67.png +0 -0
  119. package/assets/pokemon-sprites/68.png +0 -0
  120. package/assets/pokemon-sprites/69.png +0 -0
  121. package/assets/pokemon-sprites/7.png +0 -0
  122. package/assets/pokemon-sprites/70.png +0 -0
  123. package/assets/pokemon-sprites/71.png +0 -0
  124. package/assets/pokemon-sprites/72.png +0 -0
  125. package/assets/pokemon-sprites/73.png +0 -0
  126. package/assets/pokemon-sprites/74.png +0 -0
  127. package/assets/pokemon-sprites/75.png +0 -0
  128. package/assets/pokemon-sprites/76.png +0 -0
  129. package/assets/pokemon-sprites/77.png +0 -0
  130. package/assets/pokemon-sprites/78.png +0 -0
  131. package/assets/pokemon-sprites/79.png +0 -0
  132. package/assets/pokemon-sprites/8.png +0 -0
  133. package/assets/pokemon-sprites/80.png +0 -0
  134. package/assets/pokemon-sprites/81.png +0 -0
  135. package/assets/pokemon-sprites/82.png +0 -0
  136. package/assets/pokemon-sprites/83.png +0 -0
  137. package/assets/pokemon-sprites/84.png +0 -0
  138. package/assets/pokemon-sprites/85.png +0 -0
  139. package/assets/pokemon-sprites/86.png +0 -0
  140. package/assets/pokemon-sprites/87.png +0 -0
  141. package/assets/pokemon-sprites/88.png +0 -0
  142. package/assets/pokemon-sprites/89.png +0 -0
  143. package/assets/pokemon-sprites/9.png +0 -0
  144. package/assets/pokemon-sprites/90.png +0 -0
  145. package/assets/pokemon-sprites/91.png +0 -0
  146. package/assets/pokemon-sprites/92.png +0 -0
  147. package/assets/pokemon-sprites/93.png +0 -0
  148. package/assets/pokemon-sprites/94.png +0 -0
  149. package/assets/pokemon-sprites/95.png +0 -0
  150. package/assets/pokemon-sprites/96.png +0 -0
  151. package/assets/pokemon-sprites/97.png +0 -0
  152. package/assets/pokemon-sprites/98.png +0 -0
  153. package/assets/pokemon-sprites/99.png +0 -0
  154. package/dist/ai.js +193 -0
  155. package/dist/bot.d.ts +22 -0
  156. package/dist/bot.d.ts.map +1 -0
  157. package/dist/bot.js +547 -0
  158. package/dist/bot.js.map +1 -0
  159. package/dist/commands/debug-fail.js +8 -0
  160. package/dist/commands/hello-world.js +12 -0
  161. package/dist/commands/hello.d.ts +3 -0
  162. package/dist/commands/hello.d.ts.map +1 -0
  163. package/dist/commands/hello.js +8 -0
  164. package/dist/commands/hello.js.map +1 -0
  165. package/dist/commands/index.d.ts +3 -0
  166. package/dist/commands/index.d.ts.map +1 -0
  167. package/dist/commands/index.js +12 -0
  168. package/dist/commands/index.js.map +1 -0
  169. package/dist/commands/pokemon.js +132 -0
  170. package/dist/commands/print.d.ts +9 -0
  171. package/dist/commands/print.d.ts.map +1 -0
  172. package/dist/commands/print.js +46 -0
  173. package/dist/commands/print.js.map +1 -0
  174. package/dist/commands/sudoku.js +79 -0
  175. package/dist/commands/util.js +13 -0
  176. package/dist/commands/wordsearch.js +109 -0
  177. package/dist/config.d.ts +8 -0
  178. package/dist/config.d.ts.map +1 -0
  179. package/dist/config.js +149 -0
  180. package/dist/config.js.map +1 -0
  181. package/dist/index.d.ts +2 -0
  182. package/dist/index.d.ts.map +1 -0
  183. package/dist/index.js +85 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/printer.js +245 -0
  186. package/dist/printing/cups.js +89 -0
  187. package/dist/printing/escp.js +147 -0
  188. package/dist/printing/imagefeed.js +212 -0
  189. package/dist/printing/index.js +54 -0
  190. package/dist/reactions.js +5 -0
  191. package/dist/server.d.ts +5 -0
  192. package/dist/server.d.ts.map +1 -0
  193. package/dist/server.js +312 -0
  194. package/dist/server.js.map +1 -0
  195. package/dist/tests/config.test.js +78 -0
  196. package/dist/tests/urls.test.js +54 -0
  197. package/dist/types.d.ts +55 -0
  198. package/dist/types.d.ts.map +1 -0
  199. package/dist/types.js +1 -0
  200. package/dist/types.js.map +1 -0
  201. package/dist/web/app.js +325 -0
  202. package/package.json +45 -0
  203. package/scripts/download-pokemon-sprites.mjs +38 -0
  204. package/src/ai.ts +214 -0
  205. package/src/bot.ts +657 -0
  206. package/src/commands/debug-fail.ts +11 -0
  207. package/src/commands/hello-world.ts +15 -0
  208. package/src/commands/index.ts +35 -0
  209. package/src/commands/pokemon.ts +146 -0
  210. package/src/commands/sudoku.ts +94 -0
  211. package/src/commands/util.ts +14 -0
  212. package/src/commands/wordsearch.ts +118 -0
  213. package/src/config.ts +166 -0
  214. package/src/index.ts +98 -0
  215. package/src/printer.ts +268 -0
  216. package/src/printing/cups.ts +104 -0
  217. package/src/printing/escp.ts +158 -0
  218. package/src/printing/imagefeed.ts +246 -0
  219. package/src/printing/index.ts +63 -0
  220. package/src/reactions.ts +6 -0
  221. package/src/server.ts +355 -0
  222. package/src/tests/config.test.ts +88 -0
  223. package/src/tests/urls.test.ts +64 -0
  224. package/src/types.ts +120 -0
  225. package/src/web/app.tsx +690 -0
  226. 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
+ }