@tecsom/apipos-js 0.0.0-stage → 2.0.4
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 +409 -2
- package/dist/apipos.d.ts +67 -0
- package/dist/apipos.js +291 -0
- package/dist/http.d.ts +36 -0
- package/dist/http.js +113 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/printer.d.ts +61 -0
- package/dist/printer.js +189 -0
- package/dist/strip-accents.d.ts +21 -0
- package/dist/strip-accents.js +57 -0
- package/dist/ticket-preview.d.ts +33 -0
- package/dist/ticket-preview.js +115 -0
- package/dist/types.d.ts +506 -0
- package/dist/types.js +14 -0
- package/dist/version-check.d.ts +22 -0
- package/dist/version-check.js +72 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +56 -4
package/dist/printer.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Impresora de Apipos: manda trabajos RAW (ticket ESC/POS o renderizado a PDF
|
|
3
|
+
* en Apipos) y PDF a UNA impresora por su nombre exacto, abre su cajón y pide
|
|
4
|
+
* la vista previa con sus settings. Se crea con `apipos.createPrinter(name, settings)`.
|
|
5
|
+
*
|
|
6
|
+
* No depende de la app que la usa: lo que haya que hacerle al texto antes de
|
|
7
|
+
* imprimir lo decide el `transformContent` del `Apipos` que la creó.
|
|
8
|
+
*/
|
|
9
|
+
import { isRecord, isUnknownArray, unexpectedResponse } from './http.js';
|
|
10
|
+
// Settings sin los que no se puede armar un ticket, con el tipo que exige Apipos.
|
|
11
|
+
const REQUIRED_SETTINGS = [['paper_size', 'number']];
|
|
12
|
+
function isOptional(value, type) {
|
|
13
|
+
return value === undefined || typeof value === type;
|
|
14
|
+
}
|
|
15
|
+
// La forma de `data` cambia con el tipo de trabajo (ver PrintResult); se exige lo
|
|
16
|
+
// común (`printer`) y que lo demás, si viene, tenga el tipo del contrato.
|
|
17
|
+
function isPrintResult(value) {
|
|
18
|
+
return (isRecord(value) &&
|
|
19
|
+
typeof value.printer === 'string' &&
|
|
20
|
+
isOptional(value.type, 'string') &&
|
|
21
|
+
isOptional(value.char_width, 'number') &&
|
|
22
|
+
isOptional(value.strategy, 'string') &&
|
|
23
|
+
isOptional(value.width_dots, 'number') &&
|
|
24
|
+
isOptional(value.cut, 'boolean') &&
|
|
25
|
+
(value.media === undefined || value.media === null || typeof value.media === 'string') &&
|
|
26
|
+
isOptional(value.renderer, 'string') &&
|
|
27
|
+
(value.page_mm === undefined || isRecord(value.page_mm)) &&
|
|
28
|
+
(value.fonts === undefined || isUnknownArray(value.fonts)) &&
|
|
29
|
+
(value.warnings === undefined || isUnknownArray(value.warnings)) &&
|
|
30
|
+
isOptional(value.drawer_opened, 'boolean'));
|
|
31
|
+
}
|
|
32
|
+
function isOpenDrawerResult(value) {
|
|
33
|
+
return isRecord(value) && typeof value.printer === 'string';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Única fuente de verdad del mapeo `options` (camelCase, de la librería) →
|
|
37
|
+
* claves de `settings` (snake_case, del cable). La usan `print()` y `render()`.
|
|
38
|
+
* Solo se copian las opciones definidas (`undefined` no viaja; `false` y `0`
|
|
39
|
+
* sí) y `options.settings` va al final: gana sobre las de arriba.
|
|
40
|
+
*/
|
|
41
|
+
function settingsFromOptions(options) {
|
|
42
|
+
const { renderer, pdfMode, font, marginsMm, widthMm, maxHeightMm, cut, settings } = options;
|
|
43
|
+
const mapped = {};
|
|
44
|
+
if (renderer !== undefined)
|
|
45
|
+
mapped.renderer = renderer;
|
|
46
|
+
if (pdfMode !== undefined)
|
|
47
|
+
mapped.pdf_mode = pdfMode;
|
|
48
|
+
if (font !== undefined)
|
|
49
|
+
mapped.font = font;
|
|
50
|
+
if (marginsMm !== undefined)
|
|
51
|
+
mapped.margins_mm = marginsMm;
|
|
52
|
+
if (widthMm !== undefined)
|
|
53
|
+
mapped.width_mm = widthMm;
|
|
54
|
+
if (maxHeightMm !== undefined)
|
|
55
|
+
mapped.max_height_mm = maxHeightMm;
|
|
56
|
+
if (cut !== undefined)
|
|
57
|
+
mapped.cut = cut;
|
|
58
|
+
if (settings !== undefined) {
|
|
59
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
60
|
+
if (value !== undefined)
|
|
61
|
+
mapped[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return mapped;
|
|
65
|
+
}
|
|
66
|
+
export class ApiPosPrinter {
|
|
67
|
+
#name;
|
|
68
|
+
#apipos;
|
|
69
|
+
settings;
|
|
70
|
+
/**
|
|
71
|
+
* Valida `settings.paper_size` (number, obligatorio): lanza
|
|
72
|
+
* `Missing required setting: paper_size` o
|
|
73
|
+
* `Invalid type for setting paper_size: expected number, got <tipo>`.
|
|
74
|
+
*/
|
|
75
|
+
constructor(name, settings = {}, apipos) {
|
|
76
|
+
this.#name = name;
|
|
77
|
+
this.settings = settings;
|
|
78
|
+
this.#apipos = apipos;
|
|
79
|
+
this.validateSettings();
|
|
80
|
+
}
|
|
81
|
+
validateSettings() {
|
|
82
|
+
for (const [key, type] of REQUIRED_SETTINGS) {
|
|
83
|
+
const value = this.settings[key];
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
throw new Error(`Missing required setting: ${key}`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof value !== type) {
|
|
88
|
+
throw new Error(`Invalid type for setting ${key}: expected ${type}, got ${typeof value}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
get name() {
|
|
93
|
+
return this.#name;
|
|
94
|
+
}
|
|
95
|
+
get apiUrl() {
|
|
96
|
+
return this.#apipos.apiUrl;
|
|
97
|
+
}
|
|
98
|
+
// Único punto de envío a POST /print. Maneja la respuesta estandarizada
|
|
99
|
+
// de Apipos: { status, data, message }. Devuelve `data` o lanza con `message`.
|
|
100
|
+
async #send(payload) {
|
|
101
|
+
const data = await this.#apipos.request('POST', '/print', { body: { printer: this.#name, ...payload } });
|
|
102
|
+
if (data !== null && !isPrintResult(data))
|
|
103
|
+
throw unexpectedResponse('/print');
|
|
104
|
+
return data;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Trabajo RAW: `content` es la lista de elementos del ticket.
|
|
108
|
+
*
|
|
109
|
+
* Lo que haya que hacerle al texto (p. ej. una limpieza base o un
|
|
110
|
+
* interruptor por dispositivo "sin acentos ni ñ") lo aplica el
|
|
111
|
+
* `transformContent` del cliente en CADA trabajo, no al crear la impresora:
|
|
112
|
+
* así un cambio de ajustes vale desde el siguiente ticket sin recargar.
|
|
113
|
+
*
|
|
114
|
+
* SIN `options` (o con `{}`) el payload es el de v1, byte a byte:
|
|
115
|
+
* `{ printer, type: 'RAW', settings: this.settings, content }`.
|
|
116
|
+
*
|
|
117
|
+
* Con `options` se agregan claves SOLO dentro de `settings` (nunca al nivel
|
|
118
|
+
* raíz) y `this.settings` no se modifica:
|
|
119
|
+
* renderer → renderer ('escpos' | 'pdf'; con 'pdf' Apipos maqueta el ticket
|
|
120
|
+
* como PDF con fuentes del sistema y lo imprime por la ruta PDF)
|
|
121
|
+
* pdfMode → pdf_mode ; font → font ({ family, size, line_height })
|
|
122
|
+
* marginsMm → margins_mm ; widthMm → width_mm ; maxHeightMm → max_height_mm
|
|
123
|
+
* cut → cut ; settings → se mezcla al final y gana (claves nuevas del backend)
|
|
124
|
+
* Las opciones `undefined` no viajan.
|
|
125
|
+
*/
|
|
126
|
+
async print(content, options = {}) {
|
|
127
|
+
const printable = this.#apipos.applyTransform(content);
|
|
128
|
+
const overrides = settingsFromOptions(options);
|
|
129
|
+
// Sin opciones definidas viaja `this.settings` MISMO (no una copia): el
|
|
130
|
+
// payload de v1 sale byte a byte igual. Con opciones, un objeto nuevo.
|
|
131
|
+
const settings = Object.keys(overrides).length === 0 ? this.settings : { ...this.settings, ...overrides };
|
|
132
|
+
return this.#send({ type: 'RAW', settings, content: printable });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Abre el cajón de dinero conectado a ESTA impresora, sin imprimir nada.
|
|
136
|
+
* POST /open-withdrawer con `{ printer }` → `{ printer }`. Lanza
|
|
137
|
+
* `ApiposError` si Apipos responde error (p. ej. `Printer 'X' not found.`).
|
|
138
|
+
*/
|
|
139
|
+
async openDrawer() {
|
|
140
|
+
const data = await this.#apipos.request('POST', '/open-withdrawer', { body: { printer: this.#name } });
|
|
141
|
+
if (!isOpenDrawerResult(data))
|
|
142
|
+
throw unexpectedResponse('/open-withdrawer');
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Vista previa con los settings de ESTA impresora y las mismas `options` que
|
|
147
|
+
* `print()`, con el mismo mapeo a `settings`:
|
|
148
|
+
* `apipos.renderTicket(content, { settings: { ...this.settings, ...mapeo(options) }, format, dpi })`.
|
|
149
|
+
* No imprime ni valida la impresora; `this.settings` no se modifica. Default
|
|
150
|
+
* `png` a 203 dpi (la resolución de una tiquetera: 72 mm → 576 px de ancho).
|
|
151
|
+
*/
|
|
152
|
+
async render(content, { format = 'png', dpi = 203, ...options } = {}) {
|
|
153
|
+
const settings = { ...this.settings, ...settingsFromOptions(options) };
|
|
154
|
+
return this.#apipos.renderTicket(content, { settings, format, dpi });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Trabajo PDF: en el navegador el PDF es un Blob; se envía en base64.
|
|
158
|
+
* Opciones:
|
|
159
|
+
* pdfMode: 'driver' (el driver de la impresora renderiza el PDF — el modo
|
|
160
|
+
* correcto para etiquetas, ej. AiYin) | 'raw' (rasteriza a ESC/POS, para
|
|
161
|
+
* tiqueteras de recibos). Default del servidor: 'driver' si se mandan
|
|
162
|
+
* labelSettings, 'raw' si no.
|
|
163
|
+
* labelSettings: { width, height, unit: 'mm'|'inch' } — tamaño de la
|
|
164
|
+
* etiqueta; la página del PDF debe medir lo mismo.
|
|
165
|
+
*/
|
|
166
|
+
async printPDF(blob, { pdfMode, labelSettings } = {}) {
|
|
167
|
+
const data = await blobToBase64(blob);
|
|
168
|
+
const settings = { ...this.settings };
|
|
169
|
+
if (pdfMode)
|
|
170
|
+
settings.pdf_mode = pdfMode;
|
|
171
|
+
if (labelSettings)
|
|
172
|
+
settings.label_settings = labelSettings;
|
|
173
|
+
return this.#send({ type: 'PDF', settings, data });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Bytes por llamada a `String.fromCharCode`: pasarle un PDF entero como
|
|
177
|
+
// argumentos revienta la pila; 32 KiB está muy por debajo del límite de los motores.
|
|
178
|
+
const BASE64_CHUNK = 0x8000;
|
|
179
|
+
// Mismo base64 que daba `FileReader.readAsDataURL` (sin el prefijo
|
|
180
|
+
// "data:<mime>;base64,"), pero sin FileReader: `arrayBuffer()` + `btoa` existen
|
|
181
|
+
// en el navegador y en Node, así que se puede probar fuera del navegador.
|
|
182
|
+
async function blobToBase64(blob) {
|
|
183
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
184
|
+
let binary = '';
|
|
185
|
+
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
|
|
186
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK));
|
|
187
|
+
}
|
|
188
|
+
return btoa(binary);
|
|
189
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TicketContent } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Devuelve `text` sin acentos ni ñ. Cada vocal acentuada y cada ñ cambian un
|
|
4
|
+
* carácter por otro, así que el ancho de las líneas y la alineación de las
|
|
5
|
+
* columnas del ticket no se mueven; sólo `¿` y `¡` acortan la línea en uno.
|
|
6
|
+
* Lo que no es string se devuelve tal cual.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stripAccents(text: string): string;
|
|
9
|
+
export declare function stripAccents<T>(text: T): T;
|
|
10
|
+
/**
|
|
11
|
+
* Aplica `stripAccents` a todo el texto de un trabajo RAW de Apipos.
|
|
12
|
+
*
|
|
13
|
+
* Devuelve un arreglo NUEVO y no toca la entrada: quien imprime puede reusar
|
|
14
|
+
* su contenido (otra copia, o el reintento sin logo). Recorre los strings a
|
|
15
|
+
* cualquier profundidad (`text.data`, `special_text.data.text1/text2`,
|
|
16
|
+
* `table.data.rows`), pero deja intactos los elementos `image`: su `data` es
|
|
17
|
+
* base64, no puede llevar acentos, y normalizar cientos de KB en cada ticket
|
|
18
|
+
* sería trabajo tirado.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stripAccentsFromContent(content: TicketContent): TicketContent;
|
|
21
|
+
export declare function stripAccentsFromContent<T>(content: T): T;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Texto de impresión sin acentos ni ñ, para las tiqueteras que no traen la
|
|
3
|
+
* tabla Latin-1.
|
|
4
|
+
*
|
|
5
|
+
* Apipos manda cada carácter como su byte Latin-1 y nunca elige página de
|
|
6
|
+
* códigos; una impresora que arranca en otra página (las genéricas suelen
|
|
7
|
+
* arrancar en CP437) pinta otro glifo por cada byte ≥ 0x80: la `ñ` sale `±`,
|
|
8
|
+
* la `í` sale `φ`, la `á` sale `ß`. Con el texto reducido a letras base no queda
|
|
9
|
+
* byte que la impresora pueda malinterpretar.
|
|
10
|
+
*
|
|
11
|
+
* Módulo puro (sin DOM ni imports en runtime: solo un tipo) para poder probarlo
|
|
12
|
+
* directamente desde mocha. Quién decide si se aplica es el `transformContent`
|
|
13
|
+
* del cliente.
|
|
14
|
+
*/
|
|
15
|
+
// Marcas combinantes que deja `normalize('NFD')` al separar la letra base de su
|
|
16
|
+
// acento, diéresis, virgulilla o cedilla. Quitarlas cubre cualquier letra con
|
|
17
|
+
// diacrítico sin tener que listarlas una por una.
|
|
18
|
+
const COMBINING_MARKS = /[\u0300-\u036f]/g;
|
|
19
|
+
// Lo que NFD no descompone. Los signos de apertura se quitan (la tiquetera
|
|
20
|
+
// tampoco los tiene y el de cierre basta para leer la frase) y los ordinales
|
|
21
|
+
// se vuelven su letra.
|
|
22
|
+
const EXPLICIT_REPLACEMENTS = { '¿': '', '¡': '', ª: 'a', º: 'o' };
|
|
23
|
+
const EXPLICIT_PATTERN = /[¿¡ªº]/g;
|
|
24
|
+
export function stripAccents(text) {
|
|
25
|
+
if (typeof text !== 'string')
|
|
26
|
+
return text;
|
|
27
|
+
return text
|
|
28
|
+
.normalize('NFD')
|
|
29
|
+
.replace(COMBINING_MARKS, '')
|
|
30
|
+
.replace(EXPLICIT_PATTERN, char => EXPLICIT_REPLACEMENTS[char] ?? char);
|
|
31
|
+
}
|
|
32
|
+
export function stripAccentsFromContent(content) {
|
|
33
|
+
if (!isUnknownArray(content))
|
|
34
|
+
return stripStrings(content);
|
|
35
|
+
return content.map(item => (isRecord(item) && item.type === 'image' ? item : stripStrings(item)));
|
|
36
|
+
}
|
|
37
|
+
function stripStrings(value) {
|
|
38
|
+
if (typeof value === 'string')
|
|
39
|
+
return stripAccents(value);
|
|
40
|
+
if (isUnknownArray(value))
|
|
41
|
+
return value.map(stripStrings);
|
|
42
|
+
if (isRecord(value)) {
|
|
43
|
+
const stripped = {};
|
|
44
|
+
for (const key in value) {
|
|
45
|
+
stripped[key] = stripStrings(value[key]);
|
|
46
|
+
}
|
|
47
|
+
return stripped;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
// Guardas locales (el módulo se mantiene sin imports de código).
|
|
52
|
+
function isUnknownArray(value) {
|
|
53
|
+
return Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Apipos } from './apipos.js';
|
|
2
|
+
import type { RenderPreviewOptions, TicketContent, TicketItem } from './types.js';
|
|
3
|
+
/** Columnas de texto del papel (fuente A de la impresora térmica). */
|
|
4
|
+
export declare function ticketColumns(paperSize?: number): number;
|
|
5
|
+
/** Estilos del ticket. Papel blanco y tinta negra también en modo oscuro. */
|
|
6
|
+
export declare const TICKET_PREVIEW_CSS = "\n.tkt { --tkt-cols: 48; box-sizing: content-box; width: calc(var(--tkt-cols) * 1ch); max-width: 100%;\n margin: 0 auto; padding: 14px 12px 18px; background: #fff; color: #111;\n font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); border-radius: 2px; }\n.tkt__text { white-space: pre-wrap; overflow-wrap: anywhere; }\n.tkt__text--big { font-size: 1.2em; font-weight: 700; }\n.tkt__row { display: flex; justify-content: space-between; gap: 1ch; }\n.tkt__row > span:first-child { flex: 1 1 auto; white-space: pre-wrap; overflow-wrap: anywhere; }\n.tkt__row > span:last-child { flex: 0 0 auto; white-space: nowrap; text-align: right; }\n.tkt__sep { white-space: nowrap; overflow: hidden; color: #444; }\n@media print { .tkt { box-shadow: none; padding: 0; } }\n";
|
|
7
|
+
/**
|
|
8
|
+
* HTML del ticket a partir del contenido de Apipos.
|
|
9
|
+
* @param content Lo que recibe `printer.print(content)`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderTicketHtml(content: readonly TicketItem[] | null | undefined, { paperSize }?: {
|
|
12
|
+
paperSize?: number;
|
|
13
|
+
}): string;
|
|
14
|
+
/**
|
|
15
|
+
* Imprime el ticket desde el navegador (PDF o impresora del sistema) sin sacar
|
|
16
|
+
* el resto de la página: lo pinta en un iframe oculto y lo imprime solo.
|
|
17
|
+
*/
|
|
18
|
+
export declare function printTicketHtml(html: string, { title }?: {
|
|
19
|
+
title?: string;
|
|
20
|
+
}): void;
|
|
21
|
+
/**
|
|
22
|
+
* Vista previa REAL (la pinta Apipos con el mismo motor que imprime). Devuelve un
|
|
23
|
+
* data URL `data:image/png;base64,...` listo para un <img>. Para el camino ESC/POS
|
|
24
|
+
* sigue existiendo renderTicketHtml (aproximación en pantalla).
|
|
25
|
+
*
|
|
26
|
+
* Usa `apipos.renderTicket(content, { settings, format: 'png', dpi })`, así que
|
|
27
|
+
* `content` pasa por el `transformContent` del cliente, como al imprimir. Lanza
|
|
28
|
+
* lo mismo que `renderTicket` (sin conexión, `ApiposError`, respuesta inesperada).
|
|
29
|
+
*
|
|
30
|
+
* @param apipos El cliente (basta con algo que tenga `renderTicket`).
|
|
31
|
+
* @param options `settings` del ticket (default `{}`) y `dpi` (default 150).
|
|
32
|
+
*/
|
|
33
|
+
export declare function renderTicketPreviewPng(apipos: Pick<Apipos, 'renderTicket'>, content: TicketContent, { settings, dpi }?: RenderPreviewOptions): Promise<string>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* =====================================================================
|
|
2
|
+
Vista previa en pantalla de un ticket ESC/POS (Apipos).
|
|
3
|
+
|
|
4
|
+
Pinta el MISMO arreglo de elementos que se manda a la impresora
|
|
5
|
+
({ type: 'text' | 'special_text' | 'separator' }), línea por línea, con el
|
|
6
|
+
ancho de columnas del papel: 48 en 80 mm, 32 en 58 mm. No arma su propio
|
|
7
|
+
resumen: si el ticket cambia, la vista previa cambia con él.
|
|
8
|
+
|
|
9
|
+
renderTicketPreviewPng() es la vista previa REAL: la pinta Apipos con el
|
|
10
|
+
mismo renderizador que imprime (POST /render).
|
|
11
|
+
===================================================================== */
|
|
12
|
+
const HTML_ESCAPES = {
|
|
13
|
+
'&': '&',
|
|
14
|
+
'<': '<',
|
|
15
|
+
'>': '>',
|
|
16
|
+
'"': '"',
|
|
17
|
+
"'": '''
|
|
18
|
+
};
|
|
19
|
+
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => HTML_ESCAPES[c] ?? c);
|
|
20
|
+
// Lee una propiedad de lo que sea sin fallar: los tickets llegan de código JS
|
|
21
|
+
// sin tipos, y un elemento raro debe pintarse vacío, no romper la vista previa.
|
|
22
|
+
function field(value, key) {
|
|
23
|
+
return isRecord(value) ? value[key] : undefined;
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null;
|
|
27
|
+
}
|
|
28
|
+
/** Columnas de texto del papel (fuente A de la impresora térmica). */
|
|
29
|
+
export function ticketColumns(paperSize = 80) {
|
|
30
|
+
return Number(paperSize) === 58 ? 32 : 48;
|
|
31
|
+
}
|
|
32
|
+
/** Estilos del ticket. Papel blanco y tinta negra también en modo oscuro. */
|
|
33
|
+
export const TICKET_PREVIEW_CSS = `
|
|
34
|
+
.tkt { --tkt-cols: 48; box-sizing: content-box; width: calc(var(--tkt-cols) * 1ch); max-width: 100%;
|
|
35
|
+
margin: 0 auto; padding: 14px 12px 18px; background: #fff; color: #111;
|
|
36
|
+
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
37
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); border-radius: 2px; }
|
|
38
|
+
.tkt__text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
39
|
+
.tkt__text--big { font-size: 1.2em; font-weight: 700; }
|
|
40
|
+
.tkt__row { display: flex; justify-content: space-between; gap: 1ch; }
|
|
41
|
+
.tkt__row > span:first-child { flex: 1 1 auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
42
|
+
.tkt__row > span:last-child { flex: 0 0 auto; white-space: nowrap; text-align: right; }
|
|
43
|
+
.tkt__sep { white-space: nowrap; overflow: hidden; color: #444; }
|
|
44
|
+
@media print { .tkt { box-shadow: none; padding: 0; } }
|
|
45
|
+
`;
|
|
46
|
+
/**
|
|
47
|
+
* HTML del ticket a partir del contenido de Apipos.
|
|
48
|
+
* @param content Lo que recibe `printer.print(content)`.
|
|
49
|
+
*/
|
|
50
|
+
export function renderTicketHtml(content, { paperSize = 80 } = {}) {
|
|
51
|
+
const cols = ticketColumns(paperSize);
|
|
52
|
+
const items = content || [];
|
|
53
|
+
const lines = items.map((item) => {
|
|
54
|
+
const type = field(item, 'type');
|
|
55
|
+
if (type === 'separator')
|
|
56
|
+
return `<div class="tkt__sep">${'-'.repeat(cols)}</div>`;
|
|
57
|
+
if (type === 'special_text') {
|
|
58
|
+
const data = field(item, 'data');
|
|
59
|
+
return `<div class="tkt__row"><span>${esc(field(data, 'text1'))}</span><span>${esc(field(data, 'text2'))}</span></div>`;
|
|
60
|
+
}
|
|
61
|
+
const align = field(item, 'align');
|
|
62
|
+
const textAlign = align === 'center' || align === 'right' ? align : 'left';
|
|
63
|
+
const fontSize = field(item, 'font_size');
|
|
64
|
+
const big = fontSize === 'md' || fontSize === 'lg' ? ' tkt__text--big' : '';
|
|
65
|
+
return `<div class="tkt__text${big}" style="text-align: ${textAlign}">${esc(field(item, 'data'))}</div>`;
|
|
66
|
+
});
|
|
67
|
+
return `<div class="tkt" style="--tkt-cols: ${cols}">${lines.join('')}</div>`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Imprime el ticket desde el navegador (PDF o impresora del sistema) sin sacar
|
|
71
|
+
* el resto de la página: lo pinta en un iframe oculto y lo imprime solo.
|
|
72
|
+
*/
|
|
73
|
+
export function printTicketHtml(html, { title = 'Ticket' } = {}) {
|
|
74
|
+
const frame = document.createElement('iframe');
|
|
75
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
76
|
+
frame.style.cssText = 'position: fixed; right: 0; bottom: 0; width: 0; height: 0; border: 0;';
|
|
77
|
+
document.body.appendChild(frame);
|
|
78
|
+
const doc = frame.contentDocument;
|
|
79
|
+
if (doc === null) {
|
|
80
|
+
frame.remove();
|
|
81
|
+
throw new Error('No se pudo preparar el iframe para imprimir el ticket.');
|
|
82
|
+
}
|
|
83
|
+
doc.open();
|
|
84
|
+
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>` +
|
|
85
|
+
`<style>@page { margin: 8mm; } body { margin: 0; } ${TICKET_PREVIEW_CSS}</style></head>` +
|
|
86
|
+
`<body>${html}</body></html>`);
|
|
87
|
+
doc.close();
|
|
88
|
+
const win = frame.contentWindow;
|
|
89
|
+
if (win === null) {
|
|
90
|
+
frame.remove();
|
|
91
|
+
throw new Error('No se pudo preparar el iframe para imprimir el ticket.');
|
|
92
|
+
}
|
|
93
|
+
const cleanup = () => setTimeout(() => frame.remove(), 500);
|
|
94
|
+
win.addEventListener('afterprint', cleanup);
|
|
95
|
+
win.focus();
|
|
96
|
+
win.print();
|
|
97
|
+
// Safari no siempre dispara afterprint: el iframe se retira igual.
|
|
98
|
+
setTimeout(cleanup, 60000);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Vista previa REAL (la pinta Apipos con el mismo motor que imprime). Devuelve un
|
|
102
|
+
* data URL `data:image/png;base64,...` listo para un <img>. Para el camino ESC/POS
|
|
103
|
+
* sigue existiendo renderTicketHtml (aproximación en pantalla).
|
|
104
|
+
*
|
|
105
|
+
* Usa `apipos.renderTicket(content, { settings, format: 'png', dpi })`, así que
|
|
106
|
+
* `content` pasa por el `transformContent` del cliente, como al imprimir. Lanza
|
|
107
|
+
* lo mismo que `renderTicket` (sin conexión, `ApiposError`, respuesta inesperada).
|
|
108
|
+
*
|
|
109
|
+
* @param apipos El cliente (basta con algo que tenga `renderTicket`).
|
|
110
|
+
* @param options `settings` del ticket (default `{}`) y `dpi` (default 150).
|
|
111
|
+
*/
|
|
112
|
+
export async function renderTicketPreviewPng(apipos, content, { settings = {}, dpi = 150 } = {}) {
|
|
113
|
+
const { base64 } = await apipos.renderTicket(content, { settings, format: 'png', dpi });
|
|
114
|
+
return `data:image/png;base64,${base64}`;
|
|
115
|
+
}
|