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
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
212
+ }