thermalkit 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/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/chunks/dither-DyIl72tE.js +166 -0
- package/dist/chunks/dither-DyIl72tE.js.map +1 -0
- package/dist/chunks/icons-C1OFHE6u.js +49 -0
- package/dist/chunks/icons-C1OFHE6u.js.map +1 -0
- package/dist/dither.d.ts +42 -0
- package/dist/dither.d.ts.map +1 -0
- package/dist/dither.js +3 -0
- package/dist/icons.d.ts +11 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +3 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +317 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +94 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/poster.d.ts +3 -0
- package/dist/poster.d.ts.map +1 -0
- package/dist/printer.d.ts +49 -0
- package/dist/printer.d.ts.map +1 -0
- package/dist/printer.js +77 -0
- package/dist/printer.js.map +1 -0
- package/dist/svg.d.ts +27 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/types.d.ts +95 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { loadIcon, loadIcons } from "./chunks/icons-C1OFHE6u.js";
|
|
2
|
+
import { dither } from "./chunks/dither-DyIl72tE.js";
|
|
3
|
+
|
|
4
|
+
//#region src/svg.ts
|
|
5
|
+
const escapeXml = (s) => String(s ?? "").replace(/[<>&"']/g, (c) => ({
|
|
6
|
+
"<": "<",
|
|
7
|
+
">": ">",
|
|
8
|
+
"&": "&",
|
|
9
|
+
"\"": """,
|
|
10
|
+
"'": "'"
|
|
11
|
+
})[c]);
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a shorthand `family` value (e.g. `'georgia'`) to a CSS font-family stack.
|
|
14
|
+
* Pass-through anything that looks like a real stack already.
|
|
15
|
+
*/
|
|
16
|
+
function resolveFontFamily(family, fallback) {
|
|
17
|
+
if (!family) return fallback;
|
|
18
|
+
if (family.includes(",")) return family;
|
|
19
|
+
const map = {
|
|
20
|
+
georgia: "Georgia, 'Times New Roman', serif",
|
|
21
|
+
serif: "Georgia, 'Times New Roman', serif",
|
|
22
|
+
helvetica: "Helvetica, Arial, sans-serif",
|
|
23
|
+
sans: "Helvetica, Arial, sans-serif",
|
|
24
|
+
mono: "Menlo, Consolas, 'Courier New', monospace"
|
|
25
|
+
};
|
|
26
|
+
return map[family.toLowerCase()] ?? family;
|
|
27
|
+
}
|
|
28
|
+
/** Naive proportional-font width estimate, good enough for wrap decisions at 10–24 px. */
|
|
29
|
+
function approxWidth(text, size, opts = {}) {
|
|
30
|
+
const bold = (opts.weight ?? 400) >= 600;
|
|
31
|
+
const serif = /serif|Georgia/i.test(opts.family ?? "");
|
|
32
|
+
const factor = bold ? .56 : serif ? .5 : .52;
|
|
33
|
+
return text.length * size * factor;
|
|
34
|
+
}
|
|
35
|
+
/** Word-wrap text to fit within a given pixel width. */
|
|
36
|
+
function wrapByWidth(text, maxWidth, size, opts = {}) {
|
|
37
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
38
|
+
const lines = [];
|
|
39
|
+
let line = "";
|
|
40
|
+
for (const w of words) {
|
|
41
|
+
const test = line ? line + " " + w : w;
|
|
42
|
+
if (approxWidth(test, size, opts) > maxWidth && line) {
|
|
43
|
+
lines.push(line);
|
|
44
|
+
line = w;
|
|
45
|
+
} else line = test;
|
|
46
|
+
}
|
|
47
|
+
if (line) lines.push(line);
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
function buildTextFragment(s, y, defaultFamily, o = { defaultX: 0 }) {
|
|
51
|
+
const x = o.x ?? o.defaultX;
|
|
52
|
+
const yy = o.y ?? y;
|
|
53
|
+
const anchor = o.anchor ? ` text-anchor="${o.anchor}"` : "";
|
|
54
|
+
const family = resolveFontFamily(o.family, defaultFamily);
|
|
55
|
+
const size = o.size ?? 14;
|
|
56
|
+
const weight = o.weight ?? 400;
|
|
57
|
+
const style = o.style ? ` font-style="${o.style}"` : "";
|
|
58
|
+
const sp = o.spacing ? ` letter-spacing="${o.spacing}"` : "";
|
|
59
|
+
const fill = o.fill ?? "#000";
|
|
60
|
+
return `<text x="${x}" y="${yy}"${anchor} font-family="${family}" font-size="${size}" font-weight="${weight}"${style}${sp} fill="${fill}">${escapeXml(s)}</text>`;
|
|
61
|
+
}
|
|
62
|
+
function buildRuleFragment(y, contentX1, contentX2, o = {}) {
|
|
63
|
+
const x1 = o.x1 ?? contentX1;
|
|
64
|
+
const x2 = o.x2 ?? contentX2;
|
|
65
|
+
const stroke = o.stroke ?? 1;
|
|
66
|
+
const dash = o.dasharray ? ` stroke-dasharray="${o.dasharray}"` : "";
|
|
67
|
+
return `<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="#000" stroke-width="${stroke}"${dash}/>`;
|
|
68
|
+
}
|
|
69
|
+
function buildIconFragment(content, x, y, size, fill = "#000") {
|
|
70
|
+
const scale = size / 256;
|
|
71
|
+
return `<g transform="translate(${x}, ${y}) scale(${scale.toFixed(4)})" fill="${fill}">${content}</g>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/page.ts
|
|
76
|
+
const DEFAULT_FONT = "Helvetica, Arial, sans-serif";
|
|
77
|
+
var Page = class {
|
|
78
|
+
/** Output width in pixels. */
|
|
79
|
+
width;
|
|
80
|
+
/** Horizontal padding on left/right. */
|
|
81
|
+
padding;
|
|
82
|
+
/** Width available for content (width - 2*padding). */
|
|
83
|
+
contentWidth;
|
|
84
|
+
/** Current vertical cursor (mutable). */
|
|
85
|
+
y = 0;
|
|
86
|
+
parts = [];
|
|
87
|
+
icons;
|
|
88
|
+
defaultFontFamily;
|
|
89
|
+
constructor(opts = {}) {
|
|
90
|
+
this.width = opts.width ?? 504;
|
|
91
|
+
if (this.width % 8 !== 0) throw new Error(`Page width must be a multiple of 8 (got ${this.width})`);
|
|
92
|
+
this.padding = opts.padding ?? 22;
|
|
93
|
+
this.contentWidth = this.width - 2 * this.padding;
|
|
94
|
+
this.defaultFontFamily = opts.defaultFontFamily ?? DEFAULT_FONT;
|
|
95
|
+
if (Array.isArray(opts.icons)) this.icons = loadIcons(opts.icons);
|
|
96
|
+
else if (opts.icons && typeof opts.icons === "object") this.icons = { ...opts.icons };
|
|
97
|
+
else this.icons = {};
|
|
98
|
+
}
|
|
99
|
+
/** Move the Y cursor down by `delta` pixels (negative values move up). */
|
|
100
|
+
advance(delta) {
|
|
101
|
+
this.y += delta;
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
/** Alias for `advance` with a documented default of 12 px. */
|
|
105
|
+
spacer(amount = 12) {
|
|
106
|
+
this.y += amount;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Draw text at the current Y baseline (or at `opts.y` if given).
|
|
111
|
+
* Does NOT advance the cursor — the caller controls vertical rhythm.
|
|
112
|
+
*/
|
|
113
|
+
text(content, opts = {}) {
|
|
114
|
+
this.parts.push(buildTextFragment(content, this.y, this.defaultFontFamily, {
|
|
115
|
+
...opts,
|
|
116
|
+
defaultX: opts.x ?? this.padding
|
|
117
|
+
}));
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Draw a Phosphor icon (must be pre-loaded into the page's icon map).
|
|
122
|
+
* Icon top-left is placed at (x, y + dy). Common pattern when placing an
|
|
123
|
+
* icon next to text is `dy: -size * 0.8` to visually align with the text baseline.
|
|
124
|
+
*/
|
|
125
|
+
icon(name, size = 16, opts = {}) {
|
|
126
|
+
const content = this.icons[name];
|
|
127
|
+
if (!content) return this;
|
|
128
|
+
const x = opts.x ?? this.padding;
|
|
129
|
+
const dy = opts.dy ?? 0;
|
|
130
|
+
this.parts.push(buildIconFragment(content, x, this.y + dy, size, opts.fill ?? "#000"));
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
/** Horizontal rule at the current Y. */
|
|
134
|
+
rule(opts = {}) {
|
|
135
|
+
this.parts.push(buildRuleFragment(this.y, this.padding, this.width - this.padding, opts));
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
/** Append a raw SVG fragment at the current Y (escape hatch). */
|
|
139
|
+
push(fragment) {
|
|
140
|
+
this.parts.push(fragment);
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Embed a PNG buffer at (x, y) sized to (width, height). The PNG is
|
|
145
|
+
* encoded as a base64 data URI inside an `<image>` element.
|
|
146
|
+
*
|
|
147
|
+
* NOTE: at render time sharp downsamples the SVG, which would smear an
|
|
148
|
+
* already-dithered raster. For halftone posters use the dedicated
|
|
149
|
+
* `poster()` helper (which composites onto the final raster) — `image()`
|
|
150
|
+
* is for sharp text / vector content already binarised.
|
|
151
|
+
*/
|
|
152
|
+
image(pngBuffer, opts = {}) {
|
|
153
|
+
const x = opts.x ?? this.padding;
|
|
154
|
+
const y = opts.y ?? this.y;
|
|
155
|
+
const w = opts.width;
|
|
156
|
+
const h = opts.height;
|
|
157
|
+
const sizeAttrs = [w != null ? `width="${w}"` : "", h != null ? `height="${h}"` : ""].filter(Boolean).join(" ");
|
|
158
|
+
const b64 = pngBuffer.toString("base64");
|
|
159
|
+
this.parts.push(`<image x="${x}" y="${y}" ${sizeAttrs} preserveAspectRatio="xMidYMid meet" href="data:image/png;base64,${b64}"/>`);
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run `fn` in a "row context": the cursor is preserved on exit, so any
|
|
164
|
+
* `text()` / `icon()` calls inside land on the same baseline. Optionally
|
|
165
|
+
* advance the cursor after.
|
|
166
|
+
*/
|
|
167
|
+
row(fn, opts = {}) {
|
|
168
|
+
const startY = this.y;
|
|
169
|
+
fn();
|
|
170
|
+
this.y = startY + (opts.advance ?? 0);
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Big centered title with optional italic subtitle below.
|
|
175
|
+
* Advances the cursor past the block.
|
|
176
|
+
*/
|
|
177
|
+
title(text, opts = {}) {
|
|
178
|
+
const size = opts.size ?? 50;
|
|
179
|
+
this.text(text, {
|
|
180
|
+
x: this.width / 2,
|
|
181
|
+
anchor: "middle",
|
|
182
|
+
family: opts.family ?? "georgia",
|
|
183
|
+
size,
|
|
184
|
+
weight: 700,
|
|
185
|
+
spacing: opts.spacing ?? 10
|
|
186
|
+
});
|
|
187
|
+
if (opts.subtitle) {
|
|
188
|
+
this.y += Math.round(size * .5);
|
|
189
|
+
this.text(opts.subtitle, {
|
|
190
|
+
x: this.width / 2,
|
|
191
|
+
anchor: "middle",
|
|
192
|
+
size: 14,
|
|
193
|
+
style: "italic",
|
|
194
|
+
spacing: 3
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
this.y += Math.round(size * .5);
|
|
198
|
+
return this;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Section header: optional icon to the left + caps-tracked label.
|
|
202
|
+
* Advances the cursor past the block.
|
|
203
|
+
*/
|
|
204
|
+
section(label, opts = {}) {
|
|
205
|
+
const size = opts.size ?? 14;
|
|
206
|
+
const iconSize = opts.iconSize ?? Math.round(size * 1.8);
|
|
207
|
+
let textX = this.padding;
|
|
208
|
+
if (opts.icon && this.icons[opts.icon]) {
|
|
209
|
+
this.icon(opts.icon, iconSize, { dy: -Math.round(iconSize * .75) });
|
|
210
|
+
textX = this.padding + iconSize + 8;
|
|
211
|
+
}
|
|
212
|
+
this.text(label, {
|
|
213
|
+
x: textX,
|
|
214
|
+
size,
|
|
215
|
+
weight: 700,
|
|
216
|
+
spacing: 3
|
|
217
|
+
});
|
|
218
|
+
this.y += Math.round(size * 1.6);
|
|
219
|
+
return this;
|
|
220
|
+
}
|
|
221
|
+
approxWidth(text, size, opts = {}) {
|
|
222
|
+
return approxWidth(text, size, {
|
|
223
|
+
family: resolveFontFamily(opts.family, this.defaultFontFamily),
|
|
224
|
+
weight: opts.weight
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
wrapByWidth(text, maxWidth, size, opts = {}) {
|
|
228
|
+
return wrapByWidth(text, maxWidth, size, {
|
|
229
|
+
family: resolveFontFamily(opts.family, this.defaultFontFamily),
|
|
230
|
+
weight: opts.weight
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
escapeXml = escapeXml;
|
|
234
|
+
/** Serialise to a complete SVG document at the current Y as height. */
|
|
235
|
+
toSvg() {
|
|
236
|
+
const h = this.y;
|
|
237
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
238
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${this.width}" height="${h}" viewBox="0 0 ${this.width} ${h}">
|
|
239
|
+
<rect width="${this.width}" height="${h}" fill="#fff"/>
|
|
240
|
+
${this.parts.join("\n")}
|
|
241
|
+
</svg>`;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Rasterise the page to a 1-bit-style PNG, ready for the thermal printer.
|
|
245
|
+
* Renders at high density (so anti-aliasing produces good edges), downsamples
|
|
246
|
+
* to the final width, then thresholds to pure B&W.
|
|
247
|
+
*/
|
|
248
|
+
async toPng(opts = {}) {
|
|
249
|
+
const { default: sharp } = await import("sharp");
|
|
250
|
+
return sharp(Buffer.from(this.toSvg()), { density: opts.density ?? 240 }).resize({
|
|
251
|
+
width: opts.width ?? this.width,
|
|
252
|
+
fit: "inside"
|
|
253
|
+
}).flatten({ background: "#ffffff" }).greyscale().threshold(opts.threshold ?? 140).png().toBuffer();
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Like `toPng`, but composites a list of already-dithered raster images
|
|
257
|
+
* onto the thresholded base at exact pixel positions. Use for halftone
|
|
258
|
+
* posters that would otherwise be smeared by the master rasterisation.
|
|
259
|
+
*/
|
|
260
|
+
async toPngWithImages(images, opts = {}) {
|
|
261
|
+
const { default: sharp } = await import("sharp");
|
|
262
|
+
const base = await this.toPng(opts);
|
|
263
|
+
if (!images.length) return base;
|
|
264
|
+
return sharp(base).composite(images.map((im) => ({
|
|
265
|
+
input: im.data,
|
|
266
|
+
left: Math.round(im.x),
|
|
267
|
+
top: Math.round(im.y)
|
|
268
|
+
}))).png().toBuffer();
|
|
269
|
+
}
|
|
270
|
+
/** Inspect the accumulated SVG fragments (for testing/debugging). */
|
|
271
|
+
get fragments() {
|
|
272
|
+
return this.parts;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/poster.ts
|
|
278
|
+
const DEFAULT_USER_AGENT = "thermalkit/0.1";
|
|
279
|
+
async function preparePoster(url, opts = {}) {
|
|
280
|
+
const { default: sharp } = await import("sharp");
|
|
281
|
+
const res = await fetch(url, { headers: { "User-Agent": opts.userAgent ?? DEFAULT_USER_AGENT } });
|
|
282
|
+
if (!res.ok) throw new Error(`Poster HTTP ${res.status} (${url})`);
|
|
283
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
284
|
+
const meta = await sharp(buf).metadata();
|
|
285
|
+
const ratio = (meta.height ?? 1) / (meta.width ?? 1);
|
|
286
|
+
const displayW = opts.width ?? 100;
|
|
287
|
+
const displayH = Math.round(displayW * ratio);
|
|
288
|
+
let pipeline = sharp(buf).resize({
|
|
289
|
+
width: displayW,
|
|
290
|
+
height: displayH,
|
|
291
|
+
fit: "fill"
|
|
292
|
+
}).greyscale();
|
|
293
|
+
const contrast = opts.contrast ?? "clahe";
|
|
294
|
+
if (contrast === "clahe") pipeline = pipeline.clahe({
|
|
295
|
+
width: 8,
|
|
296
|
+
height: 8,
|
|
297
|
+
maxSlope: 3
|
|
298
|
+
}).normalize();
|
|
299
|
+
else if (contrast === "normalize") pipeline = pipeline.normalize();
|
|
300
|
+
const { data: grey, info } = await pipeline.raw().toBuffer({ resolveWithObject: true });
|
|
301
|
+
const algo = opts.dither ?? "atkinson";
|
|
302
|
+
const dithered = dither(algo, grey, info.width, info.height);
|
|
303
|
+
const { data, info: outInfo } = await sharp(Buffer.from(dithered.buffer), { raw: {
|
|
304
|
+
width: info.width,
|
|
305
|
+
height: info.height,
|
|
306
|
+
channels: 1
|
|
307
|
+
} }).png({ compressionLevel: 9 }).toBuffer({ resolveWithObject: true });
|
|
308
|
+
return {
|
|
309
|
+
data,
|
|
310
|
+
width: outInfo.width,
|
|
311
|
+
height: outInfo.height
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
//#endregion
|
|
316
|
+
export { Page, approxWidth, escapeXml, loadIcon, loadIcons, preparePoster, wrapByWidth };
|
|
317
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["s: unknown","family: string | undefined","fallback: string","map: Record<string, string>","text: string","size: number","opts: { family?: string; weight?: number }","maxWidth: number","lines: string[]","s: string","y: number","defaultFamily: string","o: TextOptions & { defaultX: number }","contentX1: number","contentX2: number","o: RuleOptions","content: string","x: number","opts: PageOptions","delta: number","content: string","opts: TextOptions","name: string","opts: IconOptions","opts: RuleOptions","fragment: string","pngBuffer: Buffer","opts: ImageOptions","fn: () => void","opts: RowOptions","text: string","opts: { subtitle?: string; size?: number; family?: string; spacing?: number }","label: string","opts: { icon?: string; iconSize?: number; size?: number; spacing?: number }","size: number","maxWidth: number","opts: RenderOptions","images: Array<{ data: Buffer; x: number; y: number }>","url: string","opts: PosterOptions"],"sources":["../src/svg.ts","../src/page.ts","../src/poster.ts"],"sourcesContent":["/**\n * SVG-fragment helpers used internally by Page. Exposed for callers who want\n * to inject custom shapes via `page.push(rawSvg)`.\n */\nimport type { TextOptions, RuleOptions } from './types.js';\n\nexport const escapeXml = (s: unknown): string =>\n String(s ?? '').replace(/[<>&\"']/g, (c) =>\n ({ '<': '<', '>': '>', '&': '&', '\"': '"', \"'\": ''' }[c] as string));\n\n/**\n * Resolve a shorthand `family` value (e.g. `'georgia'`) to a CSS font-family stack.\n * Pass-through anything that looks like a real stack already.\n */\nexport function resolveFontFamily(family: string | undefined, fallback: string): string {\n if (!family) return fallback;\n if (family.includes(',')) return family;\n const map: Record<string, string> = {\n georgia: \"Georgia, 'Times New Roman', serif\",\n serif: \"Georgia, 'Times New Roman', serif\",\n helvetica: 'Helvetica, Arial, sans-serif',\n sans: 'Helvetica, Arial, sans-serif',\n mono: \"Menlo, Consolas, 'Courier New', monospace\",\n };\n return map[family.toLowerCase()] ?? family;\n}\n\n/** Naive proportional-font width estimate, good enough for wrap decisions at 10–24 px. */\nexport function approxWidth(\n text: string,\n size: number,\n opts: { family?: string; weight?: number } = {},\n): number {\n const bold = (opts.weight ?? 400) >= 600;\n const serif = /serif|Georgia/i.test(opts.family ?? '');\n const factor = bold ? 0.56 : (serif ? 0.50 : 0.52);\n return text.length * size * factor;\n}\n\n/** Word-wrap text to fit within a given pixel width. */\nexport function wrapByWidth(\n text: string,\n maxWidth: number,\n size: number,\n opts: { family?: string; weight?: number } = {},\n): string[] {\n const words = String(text).split(/\\s+/).filter(Boolean);\n const lines: string[] = [];\n let line = '';\n for (const w of words) {\n const test = line ? line + ' ' + w : w;\n if (approxWidth(test, size, opts) > maxWidth && line) {\n lines.push(line);\n line = w;\n } else {\n line = test;\n }\n }\n if (line) lines.push(line);\n return lines;\n}\n\nexport function buildTextFragment(\n s: string,\n y: number,\n defaultFamily: string,\n o: TextOptions & { defaultX: number } = { defaultX: 0 },\n): string {\n const x = o.x ?? o.defaultX;\n const yy = o.y ?? y;\n const anchor = o.anchor ? ` text-anchor=\"${o.anchor}\"` : '';\n const family = resolveFontFamily(o.family, defaultFamily);\n const size = o.size ?? 14;\n const weight = o.weight ?? 400;\n const style = o.style ? ` font-style=\"${o.style}\"` : '';\n const sp = o.spacing ? ` letter-spacing=\"${o.spacing}\"` : '';\n const fill = o.fill ?? '#000';\n return `<text x=\"${x}\" y=\"${yy}\"${anchor} font-family=\"${family}\" font-size=\"${size}\" font-weight=\"${weight}\"${style}${sp} fill=\"${fill}\">${escapeXml(s)}</text>`;\n}\n\nexport function buildRuleFragment(\n y: number,\n contentX1: number,\n contentX2: number,\n o: RuleOptions = {},\n): string {\n const x1 = o.x1 ?? contentX1;\n const x2 = o.x2 ?? contentX2;\n const stroke = o.stroke ?? 1;\n const dash = o.dasharray ? ` stroke-dasharray=\"${o.dasharray}\"` : '';\n return `<line x1=\"${x1}\" y1=\"${y}\" x2=\"${x2}\" y2=\"${y}\" stroke=\"#000\" stroke-width=\"${stroke}\"${dash}/>`;\n}\n\nexport function buildIconFragment(\n content: string,\n x: number,\n y: number,\n size: number,\n fill = '#000',\n): string {\n const scale = size / 256; // Phosphor uses a 256x256 viewBox\n return `<g transform=\"translate(${x}, ${y}) scale(${scale.toFixed(4)})\" fill=\"${fill}\">${content}</g>`;\n}\n","/**\n * The Page class — a thermal-receipt-oriented vertical layout builder.\n *\n * Conceptual model: a strip of paper (default 504 px wide for the TM-T88VI)\n * with a Y cursor you push down as you append content. Most calls advance\n * the cursor automatically; reach for `advance()` / `spacer()` when you need\n * manual control.\n */\nimport { loadIcons as loadPhosphorIcons } from './icons.js';\nimport {\n approxWidth,\n wrapByWidth,\n resolveFontFamily,\n escapeXml,\n buildTextFragment,\n buildRuleFragment,\n buildIconFragment,\n} from './svg.js';\nimport type {\n PageOptions,\n TextOptions,\n IconOptions,\n RuleOptions,\n RowOptions,\n ImageOptions,\n RenderOptions,\n} from './types.js';\n\nconst DEFAULT_FONT = 'Helvetica, Arial, sans-serif';\n\nexport class Page {\n /** Output width in pixels. */\n readonly width: number;\n /** Horizontal padding on left/right. */\n readonly padding: number;\n /** Width available for content (width - 2*padding). */\n readonly contentWidth: number;\n /** Current vertical cursor (mutable). */\n y = 0;\n\n private readonly parts: string[] = [];\n private readonly icons: Record<string, string>;\n private readonly defaultFontFamily: string;\n\n constructor(opts: PageOptions = {}) {\n this.width = opts.width ?? 504;\n if (this.width % 8 !== 0) {\n // ESC/POS GS v 0 requires the bitmap width to be a multiple of 8.\n throw new Error(`Page width must be a multiple of 8 (got ${this.width})`);\n }\n this.padding = opts.padding ?? 22;\n this.contentWidth = this.width - 2 * this.padding;\n this.defaultFontFamily = opts.defaultFontFamily ?? DEFAULT_FONT;\n\n if (Array.isArray(opts.icons)) {\n this.icons = loadPhosphorIcons(opts.icons);\n } else if (opts.icons && typeof opts.icons === 'object') {\n this.icons = { ...opts.icons };\n } else {\n this.icons = {};\n }\n }\n\n // ------------------------------------------------------------------\n // Cursor control\n // ------------------------------------------------------------------\n\n /** Move the Y cursor down by `delta` pixels (negative values move up). */\n advance(delta: number): this {\n this.y += delta;\n return this;\n }\n\n /** Alias for `advance` with a documented default of 12 px. */\n spacer(amount = 12): this {\n this.y += amount;\n return this;\n }\n\n // ------------------------------------------------------------------\n // Primitives\n // ------------------------------------------------------------------\n\n /**\n * Draw text at the current Y baseline (or at `opts.y` if given).\n * Does NOT advance the cursor — the caller controls vertical rhythm.\n */\n text(content: string, opts: TextOptions = {}): this {\n this.parts.push(\n buildTextFragment(content, this.y, this.defaultFontFamily, {\n ...opts,\n defaultX: opts.x ?? this.padding,\n }),\n );\n return this;\n }\n\n /**\n * Draw a Phosphor icon (must be pre-loaded into the page's icon map).\n * Icon top-left is placed at (x, y + dy). Common pattern when placing an\n * icon next to text is `dy: -size * 0.8` to visually align with the text baseline.\n */\n icon(name: string, size = 16, opts: IconOptions = {}): this {\n const content = this.icons[name];\n if (!content) return this; // graceful no-op\n const x = opts.x ?? this.padding;\n const dy = opts.dy ?? 0;\n this.parts.push(buildIconFragment(content, x, this.y + dy, size, opts.fill ?? '#000'));\n return this;\n }\n\n /** Horizontal rule at the current Y. */\n rule(opts: RuleOptions = {}): this {\n this.parts.push(\n buildRuleFragment(this.y, this.padding, this.width - this.padding, opts),\n );\n return this;\n }\n\n /** Append a raw SVG fragment at the current Y (escape hatch). */\n push(fragment: string): this {\n this.parts.push(fragment);\n return this;\n }\n\n /**\n * Embed a PNG buffer at (x, y) sized to (width, height). The PNG is\n * encoded as a base64 data URI inside an `<image>` element.\n *\n * NOTE: at render time sharp downsamples the SVG, which would smear an\n * already-dithered raster. For halftone posters use the dedicated\n * `poster()` helper (which composites onto the final raster) — `image()`\n * is for sharp text / vector content already binarised.\n */\n image(pngBuffer: Buffer, opts: ImageOptions = {}): this {\n const x = opts.x ?? this.padding;\n const y = opts.y ?? this.y;\n const w = opts.width;\n const h = opts.height;\n const sizeAttrs = [\n w != null ? `width=\"${w}\"` : '',\n h != null ? `height=\"${h}\"` : '',\n ].filter(Boolean).join(' ');\n const b64 = pngBuffer.toString('base64');\n this.parts.push(\n `<image x=\"${x}\" y=\"${y}\" ${sizeAttrs} preserveAspectRatio=\"xMidYMid meet\" href=\"data:image/png;base64,${b64}\"/>`,\n );\n return this;\n }\n\n // ------------------------------------------------------------------\n // Composition helpers\n // ------------------------------------------------------------------\n\n /**\n * Run `fn` in a \"row context\": the cursor is preserved on exit, so any\n * `text()` / `icon()` calls inside land on the same baseline. Optionally\n * advance the cursor after.\n */\n row(fn: () => void, opts: RowOptions = {}): this {\n const startY = this.y;\n fn();\n this.y = startY + (opts.advance ?? 0);\n return this;\n }\n\n // ------------------------------------------------------------------\n // Higher-level patterns\n // ------------------------------------------------------------------\n\n /**\n * Big centered title with optional italic subtitle below.\n * Advances the cursor past the block.\n */\n title(text: string, opts: { subtitle?: string; size?: number; family?: string; spacing?: number } = {}): this {\n const size = opts.size ?? 50;\n this.text(text, {\n x: this.width / 2,\n anchor: 'middle',\n family: opts.family ?? 'georgia',\n size,\n weight: 700,\n spacing: opts.spacing ?? 10,\n });\n if (opts.subtitle) {\n this.y += Math.round(size * 0.5);\n this.text(opts.subtitle, {\n x: this.width / 2,\n anchor: 'middle',\n size: 14,\n style: 'italic',\n spacing: 3,\n });\n }\n this.y += Math.round(size * 0.5);\n return this;\n }\n\n /**\n * Section header: optional icon to the left + caps-tracked label.\n * Advances the cursor past the block.\n */\n section(\n label: string,\n opts: { icon?: string; iconSize?: number; size?: number; spacing?: number } = {},\n ): this {\n const size = opts.size ?? 14;\n const iconSize = opts.iconSize ?? Math.round(size * 1.8);\n let textX = this.padding;\n if (opts.icon && this.icons[opts.icon]) {\n this.icon(opts.icon, iconSize, { dy: -Math.round(iconSize * 0.75) });\n textX = this.padding + iconSize + 8;\n }\n this.text(label, {\n x: textX,\n size,\n weight: 700,\n spacing: 3,\n });\n this.y += Math.round(size * 1.6);\n return this;\n }\n\n // ------------------------------------------------------------------\n // Text-measurement helpers (proxied to the standalone fns)\n // ------------------------------------------------------------------\n\n approxWidth(text: string, size: number, opts: TextOptions = {}): number {\n return approxWidth(text, size, { family: resolveFontFamily(opts.family, this.defaultFontFamily), weight: opts.weight });\n }\n\n wrapByWidth(text: string, maxWidth: number, size: number, opts: TextOptions = {}): string[] {\n return wrapByWidth(text, maxWidth, size, { family: resolveFontFamily(opts.family, this.defaultFontFamily), weight: opts.weight });\n }\n\n // Re-exposed for users assembling raw fragments via `page.push(...)`.\n escapeXml = escapeXml;\n\n // ------------------------------------------------------------------\n // Serialization\n // ------------------------------------------------------------------\n\n /** Serialise to a complete SVG document at the current Y as height. */\n toSvg(): string {\n const h = this.y;\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${this.width}\" height=\"${h}\" viewBox=\"0 0 ${this.width} ${h}\">\n<rect width=\"${this.width}\" height=\"${h}\" fill=\"#fff\"/>\n${this.parts.join('\\n')}\n</svg>`;\n }\n\n /**\n * Rasterise the page to a 1-bit-style PNG, ready for the thermal printer.\n * Renders at high density (so anti-aliasing produces good edges), downsamples\n * to the final width, then thresholds to pure B&W.\n */\n async toPng(opts: RenderOptions = {}): Promise<Buffer> {\n const { default: sharp } = await import('sharp');\n return sharp(Buffer.from(this.toSvg()), { density: opts.density ?? 240 })\n .resize({ width: opts.width ?? this.width, fit: 'inside' })\n .flatten({ background: '#ffffff' })\n .greyscale()\n .threshold(opts.threshold ?? 140)\n .png()\n .toBuffer();\n }\n\n /**\n * Like `toPng`, but composites a list of already-dithered raster images\n * onto the thresholded base at exact pixel positions. Use for halftone\n * posters that would otherwise be smeared by the master rasterisation.\n */\n async toPngWithImages(\n images: Array<{ data: Buffer; x: number; y: number }>,\n opts: RenderOptions = {},\n ): Promise<Buffer> {\n const { default: sharp } = await import('sharp');\n const base = await this.toPng(opts);\n if (!images.length) return base;\n return sharp(base)\n .composite(images.map(im => ({\n input: im.data,\n left: Math.round(im.x),\n top: Math.round(im.y),\n })))\n .png()\n .toBuffer();\n }\n\n /** Inspect the accumulated SVG fragments (for testing/debugging). */\n get fragments(): readonly string[] {\n return this.parts;\n }\n}\n","/**\n * Halftone poster pipeline: URL → 1-bit dithered PNG, ready to composite onto\n * a rasterised page.\n *\n * Steps:\n * 1. Download bytes (with optional User-Agent).\n * 2. Resize to the exact target display size (no scaling during render).\n * 3. CLAHE for local contrast (rescues dark posters), then normalize.\n * 4. Dither (Atkinson by default).\n * 5. Re-encode as a clean greyscale PNG with exactly 0 / 255 values.\n *\n * The output PNG is at the EXACT pixel dimensions you'll display it at on\n * the page — no further scaling. That's the trick that keeps the dot\n * pattern crisp through the master rasterisation step (use `page.toPngWithImages`).\n */\nimport { dither as runDither } from './dither.js';\nimport type { PosterOptions, PreparedImage } from './types.js';\n\nconst DEFAULT_USER_AGENT = 'thermalkit/0.1';\n\nexport async function preparePoster(\n url: string,\n opts: PosterOptions = {},\n): Promise<PreparedImage> {\n const { default: sharp } = await import('sharp');\n\n const res = await fetch(url, {\n headers: { 'User-Agent': opts.userAgent ?? DEFAULT_USER_AGENT },\n });\n if (!res.ok) throw new Error(`Poster HTTP ${res.status} (${url})`);\n const buf = Buffer.from(await res.arrayBuffer());\n\n // Compute the exact target H/W from the source aspect ratio.\n const meta = await sharp(buf).metadata();\n const ratio = (meta.height ?? 1) / (meta.width ?? 1);\n const displayW = opts.width ?? 100;\n const displayH = Math.round(displayW * ratio);\n\n let pipeline = sharp(buf)\n .resize({ width: displayW, height: displayH, fit: 'fill' })\n .greyscale();\n\n const contrast = opts.contrast ?? 'clahe';\n if (contrast === 'clahe') {\n pipeline = pipeline.clahe({ width: 8, height: 8, maxSlope: 3 }).normalize();\n } else if (contrast === 'normalize') {\n pipeline = pipeline.normalize();\n }\n\n const { data: grey, info } = await pipeline.raw().toBuffer({ resolveWithObject: true });\n\n const algo = opts.dither ?? 'atkinson';\n const dithered = runDither(algo, grey, info.width, info.height);\n\n const { data, info: outInfo } = await sharp(Buffer.from(dithered.buffer), {\n raw: { width: info.width, height: info.height, channels: 1 },\n })\n .png({ compressionLevel: 9 })\n .toBuffer({ resolveWithObject: true });\n\n return { data, width: outInfo.width, height: outInfo.height };\n}\n"],"mappings":"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"}
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PageOptions, TextOptions, IconOptions, RuleOptions, RowOptions, ImageOptions, RenderOptions } from './types.js';
|
|
2
|
+
export declare class Page {
|
|
3
|
+
/** Output width in pixels. */
|
|
4
|
+
readonly width: number;
|
|
5
|
+
/** Horizontal padding on left/right. */
|
|
6
|
+
readonly padding: number;
|
|
7
|
+
/** Width available for content (width - 2*padding). */
|
|
8
|
+
readonly contentWidth: number;
|
|
9
|
+
/** Current vertical cursor (mutable). */
|
|
10
|
+
y: number;
|
|
11
|
+
private readonly parts;
|
|
12
|
+
private readonly icons;
|
|
13
|
+
private readonly defaultFontFamily;
|
|
14
|
+
constructor(opts?: PageOptions);
|
|
15
|
+
/** Move the Y cursor down by `delta` pixels (negative values move up). */
|
|
16
|
+
advance(delta: number): this;
|
|
17
|
+
/** Alias for `advance` with a documented default of 12 px. */
|
|
18
|
+
spacer(amount?: number): this;
|
|
19
|
+
/**
|
|
20
|
+
* Draw text at the current Y baseline (or at `opts.y` if given).
|
|
21
|
+
* Does NOT advance the cursor — the caller controls vertical rhythm.
|
|
22
|
+
*/
|
|
23
|
+
text(content: string, opts?: TextOptions): this;
|
|
24
|
+
/**
|
|
25
|
+
* Draw a Phosphor icon (must be pre-loaded into the page's icon map).
|
|
26
|
+
* Icon top-left is placed at (x, y + dy). Common pattern when placing an
|
|
27
|
+
* icon next to text is `dy: -size * 0.8` to visually align with the text baseline.
|
|
28
|
+
*/
|
|
29
|
+
icon(name: string, size?: number, opts?: IconOptions): this;
|
|
30
|
+
/** Horizontal rule at the current Y. */
|
|
31
|
+
rule(opts?: RuleOptions): this;
|
|
32
|
+
/** Append a raw SVG fragment at the current Y (escape hatch). */
|
|
33
|
+
push(fragment: string): this;
|
|
34
|
+
/**
|
|
35
|
+
* Embed a PNG buffer at (x, y) sized to (width, height). The PNG is
|
|
36
|
+
* encoded as a base64 data URI inside an `<image>` element.
|
|
37
|
+
*
|
|
38
|
+
* NOTE: at render time sharp downsamples the SVG, which would smear an
|
|
39
|
+
* already-dithered raster. For halftone posters use the dedicated
|
|
40
|
+
* `poster()` helper (which composites onto the final raster) — `image()`
|
|
41
|
+
* is for sharp text / vector content already binarised.
|
|
42
|
+
*/
|
|
43
|
+
image(pngBuffer: Buffer, opts?: ImageOptions): this;
|
|
44
|
+
/**
|
|
45
|
+
* Run `fn` in a "row context": the cursor is preserved on exit, so any
|
|
46
|
+
* `text()` / `icon()` calls inside land on the same baseline. Optionally
|
|
47
|
+
* advance the cursor after.
|
|
48
|
+
*/
|
|
49
|
+
row(fn: () => void, opts?: RowOptions): this;
|
|
50
|
+
/**
|
|
51
|
+
* Big centered title with optional italic subtitle below.
|
|
52
|
+
* Advances the cursor past the block.
|
|
53
|
+
*/
|
|
54
|
+
title(text: string, opts?: {
|
|
55
|
+
subtitle?: string;
|
|
56
|
+
size?: number;
|
|
57
|
+
family?: string;
|
|
58
|
+
spacing?: number;
|
|
59
|
+
}): this;
|
|
60
|
+
/**
|
|
61
|
+
* Section header: optional icon to the left + caps-tracked label.
|
|
62
|
+
* Advances the cursor past the block.
|
|
63
|
+
*/
|
|
64
|
+
section(label: string, opts?: {
|
|
65
|
+
icon?: string;
|
|
66
|
+
iconSize?: number;
|
|
67
|
+
size?: number;
|
|
68
|
+
spacing?: number;
|
|
69
|
+
}): this;
|
|
70
|
+
approxWidth(text: string, size: number, opts?: TextOptions): number;
|
|
71
|
+
wrapByWidth(text: string, maxWidth: number, size: number, opts?: TextOptions): string[];
|
|
72
|
+
escapeXml: (s: unknown) => string;
|
|
73
|
+
/** Serialise to a complete SVG document at the current Y as height. */
|
|
74
|
+
toSvg(): string;
|
|
75
|
+
/**
|
|
76
|
+
* Rasterise the page to a 1-bit-style PNG, ready for the thermal printer.
|
|
77
|
+
* Renders at high density (so anti-aliasing produces good edges), downsamples
|
|
78
|
+
* to the final width, then thresholds to pure B&W.
|
|
79
|
+
*/
|
|
80
|
+
toPng(opts?: RenderOptions): Promise<Buffer>;
|
|
81
|
+
/**
|
|
82
|
+
* Like `toPng`, but composites a list of already-dithered raster images
|
|
83
|
+
* onto the thresholded base at exact pixel positions. Use for halftone
|
|
84
|
+
* posters that would otherwise be smeared by the master rasterisation.
|
|
85
|
+
*/
|
|
86
|
+
toPngWithImages(images: Array<{
|
|
87
|
+
data: Buffer;
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
}>, opts?: RenderOptions): Promise<Buffer>;
|
|
91
|
+
/** Inspect the accumulated SVG fragments (for testing/debugging). */
|
|
92
|
+
get fragments(): readonly string[];
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,WAAW,EACX,WAAW,EACX,UAAU,EACV,YAAY,EACZ,aAAa,EACd,MAAM,YAAY,CAAC;AAIpB,qBAAa,IAAI;IACf,8BAA8B;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,wCAAwC;IACxC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,yCAAyC;IACzC,CAAC,SAAK;IAEN,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAC/C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;gBAE/B,IAAI,GAAE,WAAgB;IAuBlC,0EAA0E;IAC1E,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAK5B,8DAA8D;IAC9D,MAAM,CAAC,MAAM,SAAK,GAAG,IAAI;IASzB;;;OAGG;IACH,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,IAAI;IAUnD;;;;OAIG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAK,EAAE,IAAI,GAAE,WAAgB,GAAG,IAAI;IAS3D,wCAAwC;IACxC,IAAI,CAAC,IAAI,GAAE,WAAgB,GAAG,IAAI;IAOlC,iEAAiE;IACjE,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAK5B;;;;;;;;OAQG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,IAAI;IAoBvD;;;;OAIG;IACH,GAAG,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,IAAI,GAAE,UAAe,GAAG,IAAI;IAWhD;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,IAAI;IAwB7G;;;OAGG;IACH,OAAO,CACL,KAAK,EAAE,MAAM,EACb,IAAI,GAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO,GAC/E,IAAI;IAsBP,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,MAAM;IAIvE,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,MAAM,EAAE;IAK3F,SAAS,yBAAa;IAMtB,uEAAuE;IACvE,KAAK,IAAI,MAAM;IASf;;;;OAIG;IACG,KAAK,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,MAAM,CAAC;IAWtD;;;;OAIG;IACG,eAAe,CACnB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACrD,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,MAAM,CAAC;IAclB,qEAAqE;IACrE,IAAI,SAAS,IAAI,SAAS,MAAM,EAAE,CAEjC;CACF"}
|
package/dist/poster.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poster.d.ts","sourceRoot":"","sources":["../src/poster.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAI/D,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,aAAa,CAAC,CAsCxB"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface EpsonPrinterOptions {
|
|
2
|
+
/** Hostname or IP. */
|
|
3
|
+
host: string;
|
|
4
|
+
/** TCP port (default 9100 — the JetDirect/raw port). */
|
|
5
|
+
port?: number;
|
|
6
|
+
/** TCP timeout in milliseconds. Default 15000. */
|
|
7
|
+
timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PrintOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Persistent print-density adjustment (-50..+50, percent offset from factory).
|
|
12
|
+
* Sent as `GS | n` (level 0..8). NB: this setting persists in the printer's
|
|
13
|
+
* memory across power cycles until changed again — use `0` to reset.
|
|
14
|
+
*/
|
|
15
|
+
density?: number;
|
|
16
|
+
/** Issue a paper cut at the end. Default true. */
|
|
17
|
+
cut?: boolean;
|
|
18
|
+
/** Alignment for the embedded raster. Default 'center'. */
|
|
19
|
+
align?: 'left' | 'center' | 'right';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the ESC/POS bytes for the `GS | n` print-density command.
|
|
23
|
+
* n=0 → -50%, n=4 → 0% (factory default), n=8 → +50%.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setPrintDensityBytes(percent: number): Buffer;
|
|
26
|
+
/**
|
|
27
|
+
* Epson TM-T88VI and compatible thermal printers over TCP/IP.
|
|
28
|
+
* Reuse one instance across multiple prints if you want.
|
|
29
|
+
*/
|
|
30
|
+
export declare class EpsonPrinter {
|
|
31
|
+
readonly host: string;
|
|
32
|
+
readonly port: number;
|
|
33
|
+
readonly timeout: number;
|
|
34
|
+
constructor(opts: EpsonPrinterOptions);
|
|
35
|
+
private build;
|
|
36
|
+
/** Probe connectivity (TCP connect). */
|
|
37
|
+
isReachable(): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Send a rasterised PNG to the printer. Optionally adjusts persistent density
|
|
40
|
+
* and / or aligns the image. Always followed by a blank line and (by default)
|
|
41
|
+
* a paper cut.
|
|
42
|
+
*/
|
|
43
|
+
print(png: Buffer, opts?: PrintOptions): Promise<void>;
|
|
44
|
+
/** Send arbitrary ESC/POS bytes. Useful for one-off commands. */
|
|
45
|
+
raw(bytes: Buffer): Promise<void>;
|
|
46
|
+
/** Convenience: change density without printing anything. Persistent. */
|
|
47
|
+
setDensity(percent: number): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=printer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"printer.d.ts","sourceRoot":"","sources":["../src/printer.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,mBAAmB;IAClC,sBAAsB;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;CACrC;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAI5D;AAED;;;GAGG;AACH,qBAAa,YAAY;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEb,IAAI,EAAE,mBAAmB;IAMrC,OAAO,CAAC,KAAK;IASb,wCAAwC;IAClC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAQrC;;;;OAIG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBhE,iEAAiE;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvC,yEAAyE;IACnE,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGjD"}
|
package/dist/printer.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { printer, types } from "node-thermal-printer";
|
|
2
|
+
|
|
3
|
+
//#region src/printer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Build the ESC/POS bytes for the `GS | n` print-density command.
|
|
6
|
+
* n=0 → -50%, n=4 → 0% (factory default), n=8 → +50%.
|
|
7
|
+
*/
|
|
8
|
+
function setPrintDensityBytes(percent) {
|
|
9
|
+
const clamped = Math.max(-50, Math.min(50, Math.round(percent)));
|
|
10
|
+
const level = Math.round((clamped + 50) / 100 * 8);
|
|
11
|
+
return Buffer.from([
|
|
12
|
+
29,
|
|
13
|
+
124,
|
|
14
|
+
level
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Epson TM-T88VI and compatible thermal printers over TCP/IP.
|
|
19
|
+
* Reuse one instance across multiple prints if you want.
|
|
20
|
+
*/
|
|
21
|
+
var EpsonPrinter = class {
|
|
22
|
+
host;
|
|
23
|
+
port;
|
|
24
|
+
timeout;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
this.host = opts.host;
|
|
27
|
+
this.port = opts.port ?? 9100;
|
|
28
|
+
this.timeout = opts.timeout ?? 15e3;
|
|
29
|
+
}
|
|
30
|
+
build() {
|
|
31
|
+
return new printer({
|
|
32
|
+
type: types.EPSON,
|
|
33
|
+
interface: `tcp://${this.host}:${this.port}`,
|
|
34
|
+
width: 42,
|
|
35
|
+
options: { timeout: this.timeout }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Probe connectivity (TCP connect). */
|
|
39
|
+
async isReachable() {
|
|
40
|
+
try {
|
|
41
|
+
return await this.build().isPrinterConnected();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Send a rasterised PNG to the printer. Optionally adjusts persistent density
|
|
48
|
+
* and / or aligns the image. Always followed by a blank line and (by default)
|
|
49
|
+
* a paper cut.
|
|
50
|
+
*/
|
|
51
|
+
async print(png, opts = {}) {
|
|
52
|
+
const printer$1 = this.build();
|
|
53
|
+
if (!await printer$1.isPrinterConnected()) throw new Error(`Printer unreachable at ${this.host}:${this.port}`);
|
|
54
|
+
if (opts.density != null) await printer$1.raw(setPrintDensityBytes(opts.density));
|
|
55
|
+
const align = opts.align ?? "center";
|
|
56
|
+
if (align === "left") printer$1.alignLeft();
|
|
57
|
+
else if (align === "right") printer$1.alignRight();
|
|
58
|
+
else printer$1.alignCenter();
|
|
59
|
+
await printer$1.printImageBuffer(png);
|
|
60
|
+
printer$1.println("");
|
|
61
|
+
if (opts.cut !== false) printer$1.cut();
|
|
62
|
+
await printer$1.execute();
|
|
63
|
+
}
|
|
64
|
+
/** Send arbitrary ESC/POS bytes. Useful for one-off commands. */
|
|
65
|
+
async raw(bytes) {
|
|
66
|
+
const printer$1 = this.build();
|
|
67
|
+
await printer$1.raw(bytes);
|
|
68
|
+
}
|
|
69
|
+
/** Convenience: change density without printing anything. Persistent. */
|
|
70
|
+
async setDensity(percent) {
|
|
71
|
+
await this.raw(setPrintDensityBytes(percent));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
export { EpsonPrinter, setPrintDensityBytes };
|
|
77
|
+
//# sourceMappingURL=printer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"printer.js","names":["percent: number","opts: EpsonPrinterOptions","ThermalPrinter","PrinterTypes","png: Buffer","opts: PrintOptions","printer","bytes: Buffer"],"sources":["../src/printer.ts"],"sourcesContent":["/**\n * Thin TCP wrapper around node-thermal-printer, with a few thermal-kit\n * conveniences (density command, raw escape hatch).\n *\n * Imported as `thermalkit/printer` so it's optional — pure image-generation\n * users don't pay for `node-thermal-printer`.\n */\nimport { printer as ThermalPrinter, types as PrinterTypes } from 'node-thermal-printer';\n\nexport interface EpsonPrinterOptions {\n /** Hostname or IP. */\n host: string;\n /** TCP port (default 9100 — the JetDirect/raw port). */\n port?: number;\n /** TCP timeout in milliseconds. Default 15000. */\n timeout?: number;\n}\n\nexport interface PrintOptions {\n /**\n * Persistent print-density adjustment (-50..+50, percent offset from factory).\n * Sent as `GS | n` (level 0..8). NB: this setting persists in the printer's\n * memory across power cycles until changed again — use `0` to reset.\n */\n density?: number;\n /** Issue a paper cut at the end. Default true. */\n cut?: boolean;\n /** Alignment for the embedded raster. Default 'center'. */\n align?: 'left' | 'center' | 'right';\n}\n\n/**\n * Build the ESC/POS bytes for the `GS | n` print-density command.\n * n=0 → -50%, n=4 → 0% (factory default), n=8 → +50%.\n */\nexport function setPrintDensityBytes(percent: number): Buffer {\n const clamped = Math.max(-50, Math.min(50, Math.round(percent)));\n const level = Math.round(((clamped + 50) / 100) * 8);\n return Buffer.from([0x1d, 0x7c, level]);\n}\n\n/**\n * Epson TM-T88VI and compatible thermal printers over TCP/IP.\n * Reuse one instance across multiple prints if you want.\n */\nexport class EpsonPrinter {\n readonly host: string;\n readonly port: number;\n readonly timeout: number;\n\n constructor(opts: EpsonPrinterOptions) {\n this.host = opts.host;\n this.port = opts.port ?? 9100;\n this.timeout = opts.timeout ?? 15000;\n }\n\n private build(): InstanceType<typeof ThermalPrinter> {\n return new ThermalPrinter({\n type: PrinterTypes.EPSON,\n interface: `tcp://${this.host}:${this.port}`,\n width: 42,\n options: { timeout: this.timeout },\n });\n }\n\n /** Probe connectivity (TCP connect). */\n async isReachable(): Promise<boolean> {\n try {\n return await this.build().isPrinterConnected();\n } catch {\n return false;\n }\n }\n\n /**\n * Send a rasterised PNG to the printer. Optionally adjusts persistent density\n * and / or aligns the image. Always followed by a blank line and (by default)\n * a paper cut.\n */\n async print(png: Buffer, opts: PrintOptions = {}): Promise<void> {\n const printer = this.build();\n if (!(await printer.isPrinterConnected())) {\n throw new Error(`Printer unreachable at ${this.host}:${this.port}`);\n }\n if (opts.density != null) {\n await printer.raw(setPrintDensityBytes(opts.density));\n }\n const align = opts.align ?? 'center';\n if (align === 'left') printer.alignLeft();\n else if (align === 'right') printer.alignRight();\n else printer.alignCenter();\n\n await printer.printImageBuffer(png);\n printer.println('');\n if (opts.cut !== false) printer.cut();\n await printer.execute();\n }\n\n /** Send arbitrary ESC/POS bytes. Useful for one-off commands. */\n async raw(bytes: Buffer): Promise<void> {\n const printer = this.build();\n await printer.raw(bytes);\n }\n\n /** Convenience: change density without printing anything. Persistent. */\n async setDensity(percent: number): Promise<void> {\n await this.raw(setPrintDensityBytes(percent));\n }\n}\n"],"mappings":";;;;;;;AAmCA,SAAgB,qBAAqBA,SAAyB;CAC5D,MAAM,UAAU,KAAK,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,MAAM,QAAQ,CAAC,CAAC;CAChE,MAAM,QAAQ,KAAK,OAAQ,UAAU,MAAM,MAAO,EAAE;AACpD,QAAO,OAAO,KAAK;EAAC;EAAM;EAAM;CAAM,EAAC;AACxC;;;;;AAMD,IAAa,eAAb,MAA0B;CACxB,AAAS;CACT,AAAS;CACT,AAAS;CAET,YAAYC,MAA2B;AACrC,OAAK,OAAO,KAAK;AACjB,OAAK,OAAO,KAAK,QAAQ;AACzB,OAAK,UAAU,KAAK,WAAW;CAChC;CAED,AAAQ,QAA6C;AACnD,SAAO,IAAIC,QAAe;GACxB,MAAMC,MAAa;GACnB,YAAY,QAAQ,KAAK,KAAK,GAAG,KAAK,KAAK;GAC3C,OAAO;GACP,SAAS,EAAE,SAAS,KAAK,QAAS;EACnC;CACF;;CAGD,MAAM,cAAgC;AACpC,MAAI;AACF,UAAO,MAAM,KAAK,OAAO,CAAC,oBAAoB;EAC/C,QAAO;AACN,UAAO;EACR;CACF;;;;;;CAOD,MAAM,MAAMC,KAAaC,OAAqB,CAAE,GAAiB;EAC/D,MAAMC,YAAU,KAAK,OAAO;AAC5B,OAAM,MAAM,UAAQ,oBAAoB,CACtC,OAAM,IAAI,OAAO,yBAAyB,KAAK,KAAK,GAAG,KAAK,KAAK;AAEnE,MAAI,KAAK,WAAW,KAClB,OAAM,UAAQ,IAAI,qBAAqB,KAAK,QAAQ,CAAC;EAEvD,MAAM,QAAQ,KAAK,SAAS;AAC5B,MAAI,UAAU,OAAe,WAAQ,WAAW;WACvC,UAAU,QAAU,WAAQ,YAAY;MACpB,WAAQ,aAAa;AAElD,QAAM,UAAQ,iBAAiB,IAAI;AACnC,YAAQ,QAAQ,GAAG;AACnB,MAAI,KAAK,QAAQ,MAAO,WAAQ,KAAK;AACrC,QAAM,UAAQ,SAAS;CACxB;;CAGD,MAAM,IAAIC,OAA8B;EACtC,MAAMD,YAAU,KAAK,OAAO;AAC5B,QAAM,UAAQ,IAAI,MAAM;CACzB;;CAGD,MAAM,WAAWN,SAAgC;AAC/C,QAAM,KAAK,IAAI,qBAAqB,QAAQ,CAAC;CAC9C;AACF"}
|
package/dist/svg.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG-fragment helpers used internally by Page. Exposed for callers who want
|
|
3
|
+
* to inject custom shapes via `page.push(rawSvg)`.
|
|
4
|
+
*/
|
|
5
|
+
import type { TextOptions, RuleOptions } from './types.js';
|
|
6
|
+
export declare const escapeXml: (s: unknown) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a shorthand `family` value (e.g. `'georgia'`) to a CSS font-family stack.
|
|
9
|
+
* Pass-through anything that looks like a real stack already.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveFontFamily(family: string | undefined, fallback: string): string;
|
|
12
|
+
/** Naive proportional-font width estimate, good enough for wrap decisions at 10–24 px. */
|
|
13
|
+
export declare function approxWidth(text: string, size: number, opts?: {
|
|
14
|
+
family?: string;
|
|
15
|
+
weight?: number;
|
|
16
|
+
}): number;
|
|
17
|
+
/** Word-wrap text to fit within a given pixel width. */
|
|
18
|
+
export declare function wrapByWidth(text: string, maxWidth: number, size: number, opts?: {
|
|
19
|
+
family?: string;
|
|
20
|
+
weight?: number;
|
|
21
|
+
}): string[];
|
|
22
|
+
export declare function buildTextFragment(s: string, y: number, defaultFamily: string, o?: TextOptions & {
|
|
23
|
+
defaultX: number;
|
|
24
|
+
}): string;
|
|
25
|
+
export declare function buildRuleFragment(y: number, contentX1: number, contentX2: number, o?: RuleOptions): string;
|
|
26
|
+
export declare function buildIconFragment(content: string, x: number, y: number, size: number, fill?: string): string;
|
|
27
|
+
//# sourceMappingURL=svg.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../src/svg.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE3D,eAAO,MAAM,SAAS,GAAI,GAAG,OAAO,KAAG,MAEqD,CAAC;AAE7F;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWtF;AAED,0FAA0F;AAC1F,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,MAAM,CAKR;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,MAAM,EAAE,CAeV;AAED,wBAAgB,iBAAiB,CAC/B,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,EACT,aAAa,EAAE,MAAM,EACrB,CAAC,GAAE,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAoB,GACtD,MAAM,CAWR;AAED,wBAAgB,iBAAiB,CAC/B,CAAC,EAAE,MAAM,EACT,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,CAAC,GAAE,WAAgB,GAClB,MAAM,CAMR;AAED,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,EACT,IAAI,EAAE,MAAM,EACZ,IAAI,SAAS,GACZ,MAAM,CAGR"}
|