taro-bluetooth-print 2.5.0 → 2.6.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.
@@ -2,7 +2,7 @@
2
2
  * Template Engine
3
3
  *
4
4
  * Provides template parsing and rendering for receipts and labels.
5
- * Supports variable substitution, conditional rendering, and loops.
5
+ * Supports variable substitution, conditional rendering, loops, and border/table drawing.
6
6
  *
7
7
  * @example
8
8
  * ```typescript
@@ -96,6 +96,134 @@ export interface LabelData {
96
96
  expiryDate?: string;
97
97
  }
98
98
 
99
+ /**
100
+ * Loop element for iterating over arrays
101
+ */
102
+ export interface LoopElement {
103
+ /** Element type identifier */
104
+ type: 'loop';
105
+ /** Variable name to iterate over (array) */
106
+ items: string;
107
+ /** Item variable name for each iteration */
108
+ itemVar: string;
109
+ /** Index variable name (optional) */
110
+ indexVar?: string;
111
+ /** Template elements to render for each item */
112
+ elements: TemplateElement[];
113
+ /** Separator between iterations (optional) */
114
+ separator?: string;
115
+ }
116
+
117
+ /**
118
+ * Condition element for conditional rendering
119
+ */
120
+ export interface ConditionElement {
121
+ /** Element type identifier */
122
+ type: 'condition';
123
+ /** Variable name to evaluate */
124
+ variable: string;
125
+ /** Operator for comparison */
126
+ operator:
127
+ | 'exists'
128
+ | 'not_exists'
129
+ | 'equals'
130
+ | 'not_equals'
131
+ | 'gt'
132
+ | 'gte'
133
+ | 'lt'
134
+ | 'lte'
135
+ | 'truthy'
136
+ | 'falsy';
137
+ /** Value to compare against (for binary operators) */
138
+ value?: unknown;
139
+ /** Elements to render when condition is true */
140
+ then: TemplateElement[];
141
+ /** Elements to render when condition is false (optional) */
142
+ else?: TemplateElement[];
143
+ }
144
+
145
+ /**
146
+ * Border style for box/table drawing
147
+ */
148
+ export type BorderStyle = 'single' | 'double' | 'thick' | 'rounded' | 'dashed' | 'none';
149
+
150
+ /**
151
+ * Border element for drawing boxes/lines
152
+ */
153
+ export interface BorderElement {
154
+ /** Element type identifier */
155
+ type: 'border';
156
+ /** Border style */
157
+ style?: BorderStyle;
158
+ /** Top-left corner character */
159
+ topLeft?: string;
160
+ /** Top-right corner character */
161
+ topRight?: string;
162
+ /** Bottom-left corner character */
163
+ bottomLeft?: string;
164
+ /** Bottom-right corner character */
165
+ bottomRight?: string;
166
+ /** Top border character */
167
+ top?: string;
168
+ /** Bottom border character */
169
+ bottom?: string;
170
+ /** Left border character */
171
+ left?: string;
172
+ /** Right border character */
173
+ right?: string;
174
+ /** Intersection character */
175
+ cross?: string;
176
+ /** Whether to draw top border */
177
+ drawTop?: boolean;
178
+ /** Whether to draw bottom border */
179
+ drawBottom?: boolean;
180
+ /** Whether to draw left border */
181
+ drawLeft?: boolean;
182
+ /** Whether to draw right border */
183
+ drawRight?: boolean;
184
+ /** Whether to fill inside with spaces */
185
+ filled?: boolean;
186
+ /** Inner padding (default: 0) */
187
+ padding?: number;
188
+ }
189
+
190
+ /**
191
+ * Table column definition
192
+ */
193
+ export interface TableColumn {
194
+ /** Column header text */
195
+ header: string;
196
+ /** Width of column in characters */
197
+ width: number;
198
+ /** Text alignment for header */
199
+ headerAlign?: TextAlign;
200
+ /** Text alignment for cells */
201
+ cellAlign?: TextAlign;
202
+ }
203
+
204
+ /**
205
+ * Table row data
206
+ */
207
+ export type TableRowData = Record<string, string | number>;
208
+
209
+ /**
210
+ * Table element for drawing table-like structures
211
+ */
212
+ export interface TableElement {
213
+ /** Element type identifier */
214
+ type: 'table';
215
+ /** Table columns definition */
216
+ columns: TableColumn[];
217
+ /** Variable name of array to render as rows */
218
+ rowsVar: string;
219
+ /** Whether to draw header row */
220
+ showHeader?: boolean;
221
+ /** Border style for table */
222
+ borderStyle?: BorderStyle;
223
+ /** Whether to alternate row shading */
224
+ alternateRows?: boolean;
225
+ }
226
+
99
227
  /**
100
228
  * Template element types
101
229
  */
@@ -106,7 +234,11 @@ export type TemplateElement =
106
234
  | { type: 'qrcode'; content: string; size?: number }
107
235
  | { type: 'barcode'; content: string; format: BarcodeFormat; height?: number }
108
236
  | { type: 'feed'; lines: number }
109
- | { type: 'variable'; name: string; format?: string };
237
+ | { type: 'variable'; name: string; format?: string }
238
+ | LoopElement
239
+ | ConditionElement
240
+ | BorderElement
241
+ | TableElement;
110
242
 
111
243
  /**
112
244
  * Template definition
@@ -141,6 +273,91 @@ export interface ITemplateEngine {
141
273
  validate(template: TemplateDefinition, data: Record<string, unknown>): ValidationResult;
142
274
  }
143
275
 
276
+ /**
277
+ * Border style character sets
278
+ */
279
+ const BORDER_CHARS: Record<
280
+ BorderStyle,
281
+ {
282
+ topLeft: string;
283
+ topRight: string;
284
+ bottomLeft: string;
285
+ bottomRight: string;
286
+ top: string;
287
+ bottom: string;
288
+ left: string;
289
+ right: string;
290
+ cross: string;
291
+ }
292
+ > = {
293
+ single: {
294
+ topLeft: '+',
295
+ topRight: '+',
296
+ bottomLeft: '+',
297
+ bottomRight: '+',
298
+ top: '-',
299
+ bottom: '-',
300
+ left: '|',
301
+ right: '|',
302
+ cross: '+',
303
+ },
304
+ double: {
305
+ topLeft: '╔',
306
+ topRight: '╗',
307
+ bottomLeft: '╚',
308
+ bottomRight: '╝',
309
+ top: '═',
310
+ bottom: '═',
311
+ left: '║',
312
+ right: '║',
313
+ cross: '╬',
314
+ },
315
+ thick: {
316
+ topLeft: '┏',
317
+ topRight: '┓',
318
+ bottomLeft: '┗',
319
+ bottomRight: '┛',
320
+ top: '━',
321
+ bottom: '━',
322
+ left: '┃',
323
+ right: '┃',
324
+ cross: '╋',
325
+ },
326
+ rounded: {
327
+ topLeft: '╭',
328
+ topRight: '╮',
329
+ bottomLeft: '╰',
330
+ bottomRight: '╯',
331
+ top: '─',
332
+ bottom: '─',
333
+ left: '│',
334
+ right: '│',
335
+ cross: '┼',
336
+ },
337
+ dashed: {
338
+ topLeft: '+',
339
+ topRight: '+',
340
+ bottomLeft: '+',
341
+ bottomRight: '+',
342
+ top: '-',
343
+ bottom: '-',
344
+ left: ':',
345
+ right: ':',
346
+ cross: '+',
347
+ },
348
+ none: {
349
+ topLeft: ' ',
350
+ topRight: ' ',
351
+ bottomLeft: ' ',
352
+ bottomRight: ' ',
353
+ top: ' ',
354
+ bottom: ' ',
355
+ left: ' ',
356
+ right: ' ',
357
+ cross: ' ',
358
+ },
359
+ };
360
+
144
361
  /**
145
362
  * Template Engine class
146
363
  * Renders print templates to ESC/POS commands
@@ -397,6 +614,17 @@ export class TemplateEngine implements ITemplateEngine {
397
614
  code: 'MISSING_VARIABLE',
398
615
  });
399
616
  }
617
+ } else if (element.type === 'loop') {
618
+ const items = this.getNestedValue(data, element.items);
619
+ if (!Array.isArray(items)) {
620
+ errors.push({
621
+ field: element.items,
622
+ message: `Loop variable '${element.items}' must be an array`,
623
+ code: 'INVALID_LOOP_VARIABLE',
624
+ });
625
+ }
626
+ } else if (element.type === 'condition') {
627
+ // Condition validation is optional - conditions can reference missing data
400
628
  }
401
629
  }
402
630
 
@@ -407,6 +635,316 @@ export class TemplateEngine implements ITemplateEngine {
407
635
  * Render a single template element
408
636
  */
409
637
  private renderElement(element: TemplateElement, data: Record<string, unknown>): Uint8Array[] {
638
+ switch (element.type) {
639
+ case 'loop':
640
+ return this.renderLoop(element, data);
641
+ case 'condition':
642
+ return this.renderCondition(element, data);
643
+ case 'border':
644
+ return this.renderBorder(element);
645
+ case 'table':
646
+ return this.renderTable(element, data);
647
+ default:
648
+ return this.renderStandardElement(element, data);
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Render a loop element
654
+ */
655
+ private renderLoop(loop: LoopElement, data: Record<string, unknown>): Uint8Array[] {
656
+ const commands: Uint8Array[] = [];
657
+ const items = this.getNestedValue(data, loop.items);
658
+
659
+ if (!Array || !Array.isArray(items)) {
660
+ this.logger.warn(`Loop variable '${loop.items}' is not an array`);
661
+ return commands;
662
+ }
663
+
664
+ for (let i = 0; i < items.length; i++) {
665
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
666
+ const itemData: any = items[i];
667
+
668
+ // Create iteration context with item and optionally index
669
+ const context: Record<string, unknown> = {
670
+ ...data,
671
+ [loop.itemVar]: itemData,
672
+ };
673
+
674
+ if (loop.indexVar) {
675
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
676
+ context[loop.indexVar] = i;
677
+ }
678
+
679
+ // Render each element in the loop
680
+ for (const childElement of loop.elements) {
681
+ commands.push(...this.renderElement(childElement, context));
682
+ }
683
+
684
+ // Add separator between items (but not after the last)
685
+ if (loop.separator && i < items.length - 1) {
686
+ commands.push(...this.driver.text(loop.separator));
687
+ commands.push(...this.driver.feed(1));
688
+ }
689
+ }
690
+
691
+ return commands;
692
+ }
693
+
694
+ /**
695
+ * Render a condition element
696
+ */
697
+ private renderCondition(
698
+ condition: ConditionElement,
699
+ data: Record<string, unknown>
700
+ ): Uint8Array[] {
701
+ const commands: Uint8Array[] = [];
702
+ const value = this.getNestedValue(data, condition.variable);
703
+ const result = this.evaluateCondition(value, condition.operator, condition.value);
704
+
705
+ const elementsToRender = result ? condition.then : (condition.else ?? []);
706
+
707
+ for (const childElement of elementsToRender) {
708
+ commands.push(...this.renderElement(childElement, data));
709
+ }
710
+
711
+ return commands;
712
+ }
713
+
714
+ /**
715
+ * Evaluate a condition
716
+ */
717
+ private evaluateCondition(
718
+ value: unknown,
719
+ operator: ConditionElement['operator'],
720
+ compareValue?: unknown
721
+ ): boolean {
722
+ switch (operator) {
723
+ case 'exists':
724
+ return value !== undefined && value !== null;
725
+ case 'not_exists':
726
+ return value === undefined || value === null;
727
+ case 'equals':
728
+ return value === compareValue;
729
+ case 'not_equals':
730
+ return value !== compareValue;
731
+ case 'gt':
732
+ return (
733
+ typeof value === 'number' && typeof compareValue === 'number' && value > compareValue
734
+ );
735
+ case 'gte':
736
+ return (
737
+ typeof value === 'number' && typeof compareValue === 'number' && value >= compareValue
738
+ );
739
+ case 'lt':
740
+ return (
741
+ typeof value === 'number' && typeof compareValue === 'number' && value < compareValue
742
+ );
743
+ case 'lte':
744
+ return (
745
+ typeof value === 'number' && typeof compareValue === 'number' && value <= compareValue
746
+ );
747
+ case 'truthy':
748
+ return Boolean(value);
749
+ case 'falsy':
750
+ return !value;
751
+ default:
752
+ return false;
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Render a border element
758
+ */
759
+ private renderBorder(border: BorderElement): Uint8Array[] {
760
+ const commands: Uint8Array[] = [];
761
+ const style = BORDER_CHARS[border.style ?? 'single'];
762
+ const width = this.paperWidth;
763
+ const padding = border.padding ?? 0;
764
+
765
+ // Build custom or default characters
766
+ const tl = border.topLeft ?? style.topLeft;
767
+ const tr = border.topRight ?? style.topRight;
768
+ const bl = border.bottomLeft ?? style.bottomLeft;
769
+ const br = border.bottomRight ?? style.bottomRight;
770
+ const t = border.top ?? style.top;
771
+ const b = border.bottom ?? style.bottom;
772
+ const l = border.left ?? style.left;
773
+ const r = border.right ?? style.right;
774
+
775
+ // Top border
776
+ if (border.drawTop !== false) {
777
+ const topLine = tl + t.repeat(width - 2) + tr;
778
+ commands.push(...this.driver.text(topLine));
779
+ commands.push(...this.driver.feed(1));
780
+ }
781
+
782
+ // Middle lines with optional fill
783
+ if (border.filled) {
784
+ const innerWidth = width - 2 - padding * 2;
785
+ const fillChar = ' ';
786
+ for (let i = 0; i < padding; i++) {
787
+ const fillLine = l + fillChar.repeat(width - 2) + r;
788
+ commands.push(...this.driver.text(fillLine));
789
+ commands.push(...this.driver.feed(1));
790
+ }
791
+ const contentLine =
792
+ l + fillChar.repeat(padding) + fillChar.repeat(innerWidth) + fillChar.repeat(padding) + r;
793
+ commands.push(...this.driver.text(contentLine));
794
+ commands.push(...this.driver.feed(1));
795
+ for (let i = 0; i < padding; i++) {
796
+ const fillLine = l + fillChar.repeat(width - 2) + r;
797
+ commands.push(...this.driver.text(fillLine));
798
+ commands.push(...this.driver.feed(1));
799
+ }
800
+ } else if (border.drawLeft || border.drawRight) {
801
+ const middleLine =
802
+ (border.drawLeft !== false ? l : ' ') +
803
+ ' '.repeat(width - 2) +
804
+ (border.drawRight !== false ? r : ' ');
805
+ commands.push(...this.driver.text(middleLine));
806
+ commands.push(...this.driver.feed(1));
807
+ }
808
+
809
+ // Bottom border
810
+ if (border.drawBottom !== false) {
811
+ const bottomLine = bl + b.repeat(width - 2) + br;
812
+ commands.push(...this.driver.text(bottomLine));
813
+ commands.push(...this.driver.feed(1));
814
+ }
815
+
816
+ return commands;
817
+ }
818
+
819
+ /**
820
+ * Render a table element
821
+ */
822
+ private renderTable(table: TableElement, data: Record<string, unknown>): Uint8Array[] {
823
+ const commands: Uint8Array[] = [];
824
+ const rows = this.getNestedValue(data, table.rowsVar);
825
+
826
+ if (!Array.isArray(rows)) {
827
+ this.logger.warn(`Table rows variable '${table.rowsVar}' is not an array`);
828
+ return commands;
829
+ }
830
+
831
+ const style = BORDER_CHARS[table.borderStyle ?? 'single'];
832
+
833
+ // Draw top border
834
+ if (table.showHeader) {
835
+ const topLine =
836
+ style.topLeft +
837
+ table.columns
838
+ .map(col => {
839
+ const width = col.width + 1;
840
+ return style.top.repeat(width);
841
+ })
842
+ .join('') +
843
+ style.topRight;
844
+ commands.push(...this.driver.text(topLine));
845
+ commands.push(...this.driver.feed(1));
846
+ }
847
+
848
+ // Draw header row
849
+ if (table.showHeader) {
850
+ let headerContent = style.left + ' ';
851
+ table.columns.forEach((col, i) => {
852
+ const cellText = col.header.substring(0, col.width);
853
+ const aligned = this.alignText(cellText, col.width, col.headerAlign ?? TextAlign.LEFT);
854
+ headerContent +=
855
+ aligned + (i < table.columns.length - 1 ? style.cross + ' ' : ' ' + style.right);
856
+ });
857
+ commands.push(...this.driver.text(headerContent));
858
+ commands.push(...this.driver.feed(1));
859
+ }
860
+
861
+ // Draw separator after header
862
+ if (table.showHeader) {
863
+ const sepLine =
864
+ style.cross +
865
+ table.columns
866
+ .map(col => {
867
+ return style.bottom.repeat(col.width + 1);
868
+ })
869
+ .join('') +
870
+ style.cross;
871
+ commands.push(...this.driver.text(sepLine));
872
+ commands.push(...this.driver.feed(1));
873
+ }
874
+
875
+ // Draw data rows
876
+ rows.forEach((rowData, rowIndex) => {
877
+ const row = rowData as TableRowData;
878
+ let rowContent = style.left + ' ';
879
+
880
+ table.columns.forEach((col, colIndex) => {
881
+ const cellValue = row[col.header] ?? '';
882
+ const cellText = String(cellValue).substring(0, col.width);
883
+ const aligned = this.alignText(cellText, col.width, col.cellAlign ?? TextAlign.LEFT);
884
+ rowContent +=
885
+ aligned + (colIndex < table.columns.length - 1 ? style.cross + ' ' : ' ' + style.right);
886
+ });
887
+
888
+ commands.push(...this.driver.text(rowContent));
889
+ commands.push(...this.driver.feed(1));
890
+
891
+ // Draw row separator
892
+ if (rowIndex < rows.length - 1) {
893
+ const sepLine =
894
+ style.cross +
895
+ table.columns
896
+ .map(col => {
897
+ return style.bottom.repeat(col.width + 1);
898
+ })
899
+ .join('') +
900
+ style.cross;
901
+ commands.push(...this.driver.text(sepLine));
902
+ commands.push(...this.driver.feed(1));
903
+ }
904
+ });
905
+
906
+ // Draw bottom border
907
+ const bottomLine =
908
+ style.bottomLeft +
909
+ table.columns
910
+ .map(col => {
911
+ return style.bottom.repeat(col.width + 1);
912
+ })
913
+ .join('') +
914
+ style.bottomRight;
915
+ commands.push(...this.driver.text(bottomLine));
916
+ commands.push(...this.driver.feed(1));
917
+
918
+ return commands;
919
+ }
920
+
921
+ /**
922
+ * Align text within a specified width
923
+ */
924
+ private alignText(text: string, width: number, align: TextAlign): string {
925
+ const padded = text.padEnd(width).substring(0, width);
926
+ switch (align) {
927
+ case TextAlign.CENTER: {
928
+ const leftPad = Math.floor((width - text.length) / 2);
929
+ return text.padStart(leftPad + text.length).padEnd(width);
930
+ }
931
+ case TextAlign.RIGHT:
932
+ return text.padStart(width);
933
+ default:
934
+ return padded;
935
+ }
936
+ }
937
+
938
+ /**
939
+ * Render standard elements (text, line, image, qrcode, barcode, feed, variable)
940
+ */
941
+ private renderStandardElement(
942
+ element: Exclude<
943
+ TemplateElement,
944
+ LoopElement | ConditionElement | BorderElement | TableElement
945
+ >,
946
+ data: Record<string, unknown>
947
+ ): Uint8Array[] {
410
948
  const commands: Uint8Array[] = [];
411
949
 
412
950
  switch (element.type) {
@@ -483,9 +1021,10 @@ export class TemplateEngine implements ITemplateEngine {
483
1021
  /**
484
1022
  * Render a separator line
485
1023
  */
486
- private renderLine(char = '-', length?: number): Uint8Array[] {
1024
+ private renderLine(char?: string, length?: number): Uint8Array[] {
1025
+ const lineChar = char ?? '-';
487
1026
  const lineLength = length ?? this.paperWidth;
488
- const line = char.repeat(lineLength);
1027
+ const line = lineChar.repeat(lineLength);
489
1028
  return [...this.driver.text(line), ...this.driver.feed(1)];
490
1029
  }
491
1030