libniimai 0.2.0 → 0.4.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/dist/libniimai.js CHANGED
@@ -29,8 +29,12 @@ var NAME_PREFIXES = [
29
29
  "T",
30
30
  "Z"
31
31
  ];
32
- /** Opens the browser's printer chooser. */
33
- function requestPrinter(extraPrefixes = []) {
32
+ /** Opens the browser's printer chooser. `all` lists every device, for printers whose names niimai does not know. */
33
+ function requestPrinter(extraPrefixes = [], all = false) {
34
+ if (all) return navigator.bluetooth.requestDevice({
35
+ acceptAllDevices: true,
36
+ optionalServices: CANDIDATE_SERVICES
37
+ });
34
38
  return navigator.bluetooth.requestDevice({
35
39
  filters: [...[...NAME_PREFIXES, ...extraPrefixes].map((namePrefix) => ({ namePrefix })), { services: [CANDIDATE_SERVICES[0]] }],
36
40
  optionalServices: CANDIDATE_SERVICES
@@ -154,6 +158,8 @@ function createBleClient(lib, chosen) {
154
158
  var THERMAL_HEAD_DOTS = 384;
155
159
  /** The print width in whole millimetres, the widest a design on continuous paper can be. */
156
160
  var THERMAL_HEAD_MM = 48;
161
+ /** The name a printer shows: its model, or its own Bluetooth name when the model entry is only a brand or a stock name. */
162
+ var shownName = (model, transport, fallback) => (model && !model.generic ? model.prefix : transport.name) || model?.prefix || fallback;
157
163
  /** The known model a Bluetooth name belongs to, longest prefix first. */
158
164
  function modelFor(models, name) {
159
165
  if (!name) return null;
@@ -301,13 +307,14 @@ var ThermalPrinterClient = class {
301
307
  newPrintTask: (_name, options) => {
302
308
  const density = Number(options.density ?? 1);
303
309
  const labelType = Number(options.labelType ?? 1);
310
+ const gapDots = options.gapDots === void 0 ? void 0 : Math.max(0, Math.round(Number(options.gapDots)));
304
311
  let printed = 0;
305
312
  return {
306
313
  printInit: async () => {},
307
314
  printPage: async (image, quantity = 1) => {
308
315
  const total = () => Math.max(printed, Number(options.totalPages ?? printed));
309
316
  let shown = -1;
310
- await this.send(this.job(image, density, Math.max(1, quantity), labelType), (share) => {
317
+ await this.send(this.job(image, density, Math.max(1, quantity), labelType, gapDots), (share) => {
311
318
  const sending = Math.floor(share * 100);
312
319
  if (sending === shown) return;
313
320
  shown = sending;
@@ -371,7 +378,7 @@ async function webTransport(device, server, ids) {
371
378
  var CAT_SERVICE = "0000ae30-0000-1000-8000-00805f9b34fb";
372
379
  var CAT_TX = "0000ae01-0000-1000-8000-00805f9b34fb";
373
380
  var CAT_RX = "0000ae02-0000-1000-8000-00805f9b34fb";
374
- var ROW_BYTES$1 = 48;
381
+ var ROW_BYTES = 48;
375
382
  /**
376
383
  * Models known to speak this protocol. "high": supported by both reference
377
384
  * projects and widely used. "medium": listed by Cat-Printer, fewer reports.
@@ -483,6 +490,7 @@ function packet(cmd, payload) {
483
490
  }
484
491
  var u16 = (v) => [v & 255, v >> 8 & 255];
485
492
  var CMD = {
493
+ retract: 160,
486
494
  feed: 161,
487
495
  row: 162,
488
496
  state: 163,
@@ -556,8 +564,12 @@ var FEED_AFTER = 128;
556
564
  var GAP_BETWEEN = 24;
557
565
  /** Rows for a cat printer: leftmost dot in the lowest bit. */
558
566
  var encodeRows$2 = (data, width, height) => packRows(data, width, height, "lsb");
559
- /** Everything sent for one job: setup, the rows for each copy, then feed and finish. */
560
- function printJob(rows, { density, copies, model }) {
567
+ /**
568
+ * Everything sent for one job: setup, the rows for each copy, then feed and
569
+ * finish. With `gapDots`, for labels with gaps, every label is followed by
570
+ * exactly that feed, so the next print starts on the next label.
571
+ */
572
+ function printJob(rows, { density, copies, model, gapDots }) {
561
573
  const out = [];
562
574
  const push = (cmd, payload) => out.push(...packet(cmd, payload));
563
575
  push(CMD.state, [0]);
@@ -569,18 +581,28 @@ function printJob(rows, { density, copies, model }) {
569
581
  push(CMD.applyEnergy, [1]);
570
582
  push(CMD.update, [0]);
571
583
  push(CMD.lattice, LATTICE_START);
572
- const blank = new Uint8Array(ROW_BYTES$1);
584
+ const blank = new Uint8Array(ROW_BYTES);
573
585
  for (let c = 0; c < copies; c++) {
574
- if (c > 0) for (let i = 0; i < GAP_BETWEEN; i++) push(CMD.row, blank);
586
+ if (c > 0) for (let i = 0; i < (gapDots ?? GAP_BETWEEN); i++) push(CMD.row, blank);
575
587
  for (const row of rows) push(CMD.row, row);
576
588
  }
577
589
  push(CMD.lattice, LATTICE_END);
578
590
  push(CMD.speed, [8]);
579
- if (model?.feedWithRows) for (let i = 0; i < FEED_AFTER; i++) push(CMD.row, blank);
591
+ if (gapDots !== void 0) out.push(...catFeed(gapDots, model));
592
+ else if (model?.feedWithRows) for (let i = 0; i < FEED_AFTER; i++) push(CMD.row, blank);
580
593
  else push(CMD.feed, u16(FEED_AFTER));
581
594
  push(CMD.state, [0]);
582
595
  return Uint8Array.from(out);
583
596
  }
597
+ /** Moves the paper by `dots`, back when negative. Models that mishandle the feed command get blank rows. */
598
+ function catFeed(dots, model) {
599
+ const n = Math.abs(Math.round(dots));
600
+ if (!n) return /* @__PURE__ */ new Uint8Array();
601
+ if (dots < 0) return Uint8Array.from(packet(CMD.retract, u16(n)));
602
+ if (!model?.feedWithRows) return Uint8Array.from(packet(CMD.feed, u16(n)));
603
+ const blank = new Uint8Array(ROW_BYTES);
604
+ return Uint8Array.from(Array.from({ length: n }, () => packet(CMD.row, blank)).flat());
605
+ }
584
606
  /** A cat printer: the shared thermal client with cat packets and flow control. */
585
607
  var CatPrinterClient = class extends ThermalPrinterClient {
586
608
  model;
@@ -592,7 +614,7 @@ var CatPrinterClient = class extends ThermalPrinterClient {
592
614
  }) {
593
615
  super(transport, pace, 180);
594
616
  this.model = model;
595
- this.modelName = model?.prefix ?? (transport.name || "Cat printer");
617
+ this.modelName = shownName(model, transport, "Cat printer");
596
618
  transport.onNotify((data) => {
597
619
  if (same(data, FLOW_PAUSE)) this.paused = true;
598
620
  else if (same(data, FLOW_RESUME)) this.paused = false;
@@ -601,13 +623,19 @@ var CatPrinterClient = class extends ThermalPrinterClient {
601
623
  encodeRows(data, width, height) {
602
624
  return encodeRows$2(data, width, height);
603
625
  }
604
- job(rows, density, copies) {
626
+ job(rows, density, copies, _labelType, gapDots) {
605
627
  return printJob(rows, {
606
628
  density,
607
629
  copies,
608
- model: this.model
630
+ model: this.model,
631
+ gapDots
609
632
  });
610
633
  }
634
+ feedsBack = true;
635
+ /** These printers cannot sense the paper, so the user lines it up by hand. */
636
+ async feedPaper(dots) {
637
+ await this.send(catFeed(dots, this.model));
638
+ }
611
639
  };
612
640
  var same = (a, b) => a.length === b.length && b.every((v, i) => a[i] === v);
613
641
  // src/escpos.ts
@@ -618,10 +646,17 @@ var same = (a, b) => a.length === b.length && b.every((v, i) => a[i] === v);
618
646
  */
619
647
  var ESCPOS_SERVICE = "000018f0-0000-1000-8000-00805f9b34fb";
620
648
  var ESCPOS_WRITE = "00002af1-0000-1000-8000-00805f9b34fb";
649
+ /** The serial service many budget printers offer, Xprinter among them; NIIMBOT printers use it too. */
650
+ var ESCPOS_ALT_SERVICE = "e7810a71-73ae-499d-8c15-faa9aef0c3f2";
651
+ var ESCPOS_ALT_WRITE = "bef8d6c9-9c21-4c9e-b632-bd58c1009f9f";
621
652
  /**
622
- * Models known to take ESC/POS raster images over 18F0. "tested": printed
623
- * with niimai on real hardware. "medium": the same chipset and service are
624
- * widely reported, untested here.
653
+ * Models known to take ESC/POS raster images, by Bluetooth name. "tested":
654
+ * printed with niimai on real hardware. "high": supported by several
655
+ * open-source printer projects. "medium": the name is reported by one or two
656
+ * of them, untested here. Names from receipt-printer projects such as
657
+ * WebBluetoothReceiptPrinter and bitbank2/Thermal_Printer; see
658
+ * docs/thermal-printers.md for sources. Most of these printers share one
659
+ * Bluetooth module, so its services tell nothing apart: the name decides.
625
660
  */
626
661
  var ESCPOS_MODELS = [
627
662
  {
@@ -631,19 +666,57 @@ var ESCPOS_MODELS = [
631
666
  },
632
667
  {
633
668
  prefix: "MPT-II",
634
- confidence: "high"
669
+ confidence: "high",
670
+ note: "Stock module name on many 58 mm printers"
635
671
  },
636
672
  {
637
673
  prefix: "MP58",
638
674
  confidence: "medium"
639
675
  },
676
+ {
677
+ prefix: "MP210",
678
+ confidence: "medium"
679
+ },
640
680
  {
641
681
  prefix: "MTP-II",
682
+ confidence: "high",
683
+ note: "Stock module name on many 58 mm printers"
684
+ },
685
+ {
686
+ prefix: "MTP-2",
642
687
  confidence: "medium"
643
688
  },
689
+ {
690
+ prefix: "MPT-3",
691
+ confidence: "medium",
692
+ note: "80 mm",
693
+ headDots: 576
694
+ },
695
+ {
696
+ prefix: "MTP-3",
697
+ confidence: "medium",
698
+ note: "80 mm",
699
+ headDots: 576
700
+ },
644
701
  {
645
702
  prefix: "PT-210",
646
- confidence: "medium"
703
+ confidence: "high",
704
+ brand: "Goojprt PT-210"
705
+ },
706
+ {
707
+ prefix: "PT210",
708
+ confidence: "medium",
709
+ brand: "Goojprt PT-210"
710
+ },
711
+ {
712
+ prefix: "GOJRPT",
713
+ confidence: "medium",
714
+ brand: "Goojprt"
715
+ },
716
+ {
717
+ prefix: "Gojprt",
718
+ confidence: "medium",
719
+ brand: "Goojprt"
647
720
  },
648
721
  {
649
722
  prefix: "PT-200",
@@ -651,35 +724,105 @@ var ESCPOS_MODELS = [
651
724
  },
652
725
  {
653
726
  prefix: "RPP02",
654
- confidence: "medium"
727
+ confidence: "high",
728
+ brand: "Rongta RPP02N",
729
+ note: "Also sold as EPPOS EP58 Plus"
730
+ },
731
+ {
732
+ prefix: "RPP300",
733
+ confidence: "medium",
734
+ brand: "Rongta RPP300",
735
+ note: "80 mm",
736
+ headDots: 576
655
737
  },
656
738
  {
657
739
  prefix: "ZJ-58",
658
- confidence: "medium"
740
+ confidence: "medium",
741
+ brand: "Zjiang"
742
+ },
743
+ {
744
+ prefix: "TM-P",
745
+ confidence: "medium",
746
+ brand: "Epson TM-P series"
747
+ },
748
+ {
749
+ prefix: "XP-P323B",
750
+ confidence: "medium",
751
+ brand: "Xprinter XP-P323B",
752
+ note: "80 mm",
753
+ headDots: 576
754
+ },
755
+ {
756
+ prefix: "XP-",
757
+ confidence: "medium",
758
+ brand: "other Xprinters",
759
+ generic: true
760
+ },
761
+ {
762
+ prefix: "Printer",
763
+ confidence: "tested",
764
+ brand: "Xprinter XP-58IIL, HOIN, Gprinter",
765
+ note: "Stock module name",
766
+ generic: true
767
+ },
768
+ {
769
+ prefix: "printer",
770
+ confidence: "medium",
771
+ note: "Stock module name",
772
+ generic: true
773
+ },
774
+ {
775
+ prefix: "BlueTooth Printer",
776
+ confidence: "medium",
777
+ note: "Stock module name: Zjiang POS-5805 and others",
778
+ generic: true
779
+ },
780
+ {
781
+ prefix: "58Printer",
782
+ confidence: "medium",
783
+ note: "Stock module name",
784
+ generic: true
659
785
  }
660
786
  ];
661
787
  var escposModelFor = (name) => modelFor(ESCPOS_MODELS, name);
662
- var ROW_BYTES = 48;
663
788
  /** Rows per raster command; small bands keep the printer's buffer from overflowing. */
664
789
  var BAND = 24;
665
790
  /** Dots fed between copies. */
666
791
  var GAP_DOTS = 24;
667
792
  /** Lines fed after the last copy, so the print clears the tear bar. */
668
793
  var FEED_LINES = 4;
669
- /** Rows for an ESC/POS printer: leftmost dot in the highest bit. */
670
- var encodeRows$1 = (data, width, height) => packRows(data, width, height, "msb");
671
- /** ESC @, then each copy as GS v 0 bands with a gap between, then a feed. */
672
- function escposJob(rows, copies) {
794
+ /** Rows for an ESC/POS printer: leftmost dot in the highest bit, centred on a head `head` dots wide. */
795
+ var encodeRows$1 = (data, width, height, head = 384) => packRows(data, width, height, "msb", head);
796
+ /**
797
+ * ESC @, then each copy as GS v 0 bands with a gap between, then a feed past
798
+ * the tear bar. With `gapDots`, for labels with gaps, every label is followed
799
+ * by exactly that feed, so the next print starts on the next label.
800
+ */
801
+ function escposJob(rows, copies, gapDots) {
673
802
  const out = [27, 64];
803
+ const rowBytes = rows[0]?.length ?? 48;
674
804
  for (let c = 0; c < copies; c++) {
675
- if (c > 0) out.push(27, 74, GAP_DOTS);
805
+ if (c > 0) out.push(...escposFeed(gapDots ?? GAP_DOTS));
676
806
  for (let i = 0; i < rows.length; i += BAND) {
677
807
  const band = rows.slice(i, i + BAND);
678
- out.push(29, 118, 48, 0, ROW_BYTES & 255, ROW_BYTES >> 8, band.length & 255, band.length >> 8);
808
+ out.push(29, 118, 48, 0, rowBytes & 255, rowBytes >> 8, band.length & 255, band.length >> 8);
679
809
  for (const row of band) out.push(...row);
680
810
  }
681
811
  }
682
- out.push(27, 100, FEED_LINES);
812
+ if (gapDots === void 0) out.push(27, 100, FEED_LINES);
813
+ else out.push(...escposFeed(gapDots));
814
+ return Uint8Array.from(out);
815
+ }
816
+ /**
817
+ * Moves the paper forward by `dots`: line feeds with the line spacing set to
818
+ * the distance (ESC 3 n, LF), then the default spacing back (ESC 2). The
819
+ * MP583 ignores ESC J, the usual dot feed, but takes line feeds. These
820
+ * printers have no reverse feed.
821
+ */
822
+ function escposFeed(dots) {
823
+ const out = [];
824
+ for (let left = Math.round(dots); left > 0; left -= 255) out.push(27, 51, Math.min(255, left), 10);
825
+ if (out.length) out.push(27, 50);
683
826
  return Uint8Array.from(out);
684
827
  }
685
828
  /**
@@ -691,19 +834,26 @@ var EscPosPrinterClient = class extends ThermalPrinterClient {
691
834
  model;
692
835
  densityLevels = 1;
693
836
  modelName;
837
+ headDots;
694
838
  constructor(transport, model, pace = {
695
839
  chunkMs: 30,
696
840
  pauseMs: 100
697
841
  }) {
698
842
  super(transport, pace, 100);
699
843
  this.model = model;
700
- this.modelName = model?.prefix ?? (transport.name || "Thermal printer");
844
+ this.modelName = shownName(model, transport, "Thermal printer");
845
+ this.headDots = model?.headDots ?? 384;
701
846
  }
702
847
  encodeRows(data, width, height) {
703
- return encodeRows$1(data, width, height);
848
+ return encodeRows$1(data, width, height, this.headDots);
704
849
  }
705
- job(rows, _density, copies) {
706
- return escposJob(rows, copies);
850
+ job(rows, _density, copies, _labelType, gapDots) {
851
+ return escposJob(rows, copies, gapDots);
852
+ }
853
+ /** These printers cannot sense the paper, so the user lines it up by hand. */
854
+ async feedPaper(dots) {
855
+ if (dots < 0) throw new Error("This printer cannot move the paper back");
856
+ await this.send(escposFeed(dots));
707
857
  }
708
858
  };
709
859
  // src/tspl.ts
@@ -713,12 +863,17 @@ var TSPL_NOTIFY = "00002af0-0000-1000-8000-00805f9b34fb";
713
863
  var DOTS_PER_MM = 8;
714
864
  /**
715
865
  * Models known to take TSPL over 18F0. "tested": printed with niimai on real
716
- * hardware.
866
+ * hardware. "medium": reported by open-source printer projects, untested here.
717
867
  */
718
868
  var TSPL_MODELS = [{
719
869
  prefix: "QR380A",
720
870
  confidence: "tested",
721
871
  note: "Sold as CC390"
872
+ }, {
873
+ prefix: "GP-M322",
874
+ confidence: "medium",
875
+ brand: "Gprinter GP-M322",
876
+ headDots: 576
722
877
  }];
723
878
  var tsplModelFor = (name) => modelFor(TSPL_MODELS, name);
724
879
  /** Common 2- to 3-inch label sizes, width × height in mm, smallest first. */
@@ -815,7 +970,7 @@ var TsplPrinterClient = class extends ThermalPrinterClient {
815
970
  ackTimeoutMs;
816
971
  densityLevels = DENSITY_LEVELS;
817
972
  modelName;
818
- headDots = 640;
973
+ headDots;
819
974
  kind = void 0;
820
975
  labelSizes = TSPL_SIZES;
821
976
  oks = 0;
@@ -828,7 +983,8 @@ var TsplPrinterClient = class extends ThermalPrinterClient {
828
983
  super(transport, pace, 100);
829
984
  this.model = model;
830
985
  this.ackTimeoutMs = ackTimeoutMs;
831
- this.modelName = model?.prefix ?? (transport.name || "Label printer");
986
+ this.modelName = shownName(model, transport, "Label printer");
987
+ this.headDots = model?.headDots ?? 640;
832
988
  transport.onNotify((data) => {
833
989
  if (data.length >= 2 && data[0] === OK[0] && data[1] === OK[1]) this.oks++;
834
990
  else if (data.length === 1) this.status = data[0];
@@ -876,14 +1032,137 @@ var TsplPrinterClient = class extends ThermalPrinterClient {
876
1032
  console.warn("[niimai] Label printer did not confirm the job");
877
1033
  }
878
1034
  };
1035
+ // src/catalog.ts
1036
+ /**
1037
+ * Every printer outside NIIMBOT that libniimai knows, by Bluetooth name, in
1038
+ * one lookup across families. A name matches one family at most, and a name
1039
+ * that matches none is known to be unknown before anything is sent to it.
1040
+ * The models themselves live with their family: ESCPOS_MODELS, TSPL_MODELS
1041
+ * and CAT_MODELS.
1042
+ */
1043
+ var PRINTER_CATALOG = [
1044
+ ...ESCPOS_MODELS.map((model) => ({
1045
+ family: "escpos",
1046
+ model
1047
+ })),
1048
+ ...TSPL_MODELS.map((model) => ({
1049
+ family: "tspl",
1050
+ model
1051
+ })),
1052
+ ...CAT_MODELS.map((model) => ({
1053
+ family: "cat",
1054
+ model
1055
+ }))
1056
+ ];
1057
+ /** The known printer a Bluetooth name belongs to, longest prefix first across every family. */
1058
+ function identifyPrinter(name) {
1059
+ if (!name) return null;
1060
+ let best = null;
1061
+ for (const entry of PRINTER_CATALOG) if (name.startsWith(entry.model.prefix) && entry.model.prefix.length > (best?.model.prefix.length ?? 0)) best = entry;
1062
+ return best;
1063
+ }
1064
+ /** The kinds of printer an unrecognised one can be set to, alongside NIIMBOT's MANUAL_PROFILES. */
1065
+ var PRINTER_TYPES = [
1066
+ {
1067
+ id: "escpos-58",
1068
+ name: "Receipt printer, 58 mm",
1069
+ family: "escpos",
1070
+ model: {
1071
+ prefix: "",
1072
+ confidence: "medium",
1073
+ generic: true,
1074
+ headDots: 384
1075
+ }
1076
+ },
1077
+ {
1078
+ id: "escpos-80",
1079
+ name: "Receipt printer, 80 mm",
1080
+ family: "escpos",
1081
+ model: {
1082
+ prefix: "",
1083
+ confidence: "medium",
1084
+ generic: true,
1085
+ headDots: 576
1086
+ }
1087
+ },
1088
+ {
1089
+ id: "tspl",
1090
+ name: "Label printer (TSPL)",
1091
+ family: "tspl",
1092
+ model: {
1093
+ prefix: "",
1094
+ confidence: "medium",
1095
+ generic: true,
1096
+ headDots: 640
1097
+ }
1098
+ },
1099
+ {
1100
+ id: "cat",
1101
+ name: "Cat printer",
1102
+ family: "cat",
1103
+ model: {
1104
+ prefix: "",
1105
+ confidence: "medium",
1106
+ generic: true
1107
+ }
1108
+ }
1109
+ ];
1110
+ var findPrinterType = (id) => PRINTER_TYPES.find((t) => t.id === id) ?? null;
879
1111
  // src/bluetooth.ts
880
1112
  /**
1113
+ * A writable characteristic on one of the serial services budget printers
1114
+ * offer, for ESC/POS printers on none of the known layouts.
1115
+ */
1116
+ async function writeChannel(server, services) {
1117
+ for (const service of CANDIDATE_SERVICES) {
1118
+ if (!services.includes(service) || service === "0000ae30-0000-1000-8000-00805f9b34fb") continue;
1119
+ const write = (await server.getPrimaryService(service).then((s) => s.getCharacteristics()).catch(() => [])).find((c) => c.properties.writeWithoutResponse || c.properties.write);
1120
+ if (write) return {
1121
+ service,
1122
+ write: write.uuid
1123
+ };
1124
+ }
1125
+ return null;
1126
+ }
1127
+ var PRINTER_TYPE_NAMES = {
1128
+ escpos: "receipt printer",
1129
+ tspl: "label printer",
1130
+ cat: "cat printer"
1131
+ };
1132
+ /** The client for a known or hand-picked thermal printer, over the service its family uses; null when the printer lacks it. */
1133
+ async function thermalClient(device, server, services, { family, model }) {
1134
+ if (family === "cat") return services.includes("0000ae30-0000-1000-8000-00805f9b34fb") ? new CatPrinterClient(await webTransport(device, server, {
1135
+ service: CAT_SERVICE,
1136
+ write: CAT_TX,
1137
+ notify: CAT_RX
1138
+ }), model) : null;
1139
+ if (family === "tspl") return services.includes("000018f0-0000-1000-8000-00805f9b34fb") ? new TsplPrinterClient(await webTransport(device, server, {
1140
+ service: TSPL_SERVICE,
1141
+ write: TSPL_WRITE,
1142
+ notify: TSPL_NOTIFY
1143
+ }), model) : null;
1144
+ const channel = services.includes("000018f0-0000-1000-8000-00805f9b34fb") ? {
1145
+ service: ESCPOS_SERVICE,
1146
+ write: ESCPOS_WRITE
1147
+ } : services.includes("e7810a71-73ae-499d-8c15-faa9aef0c3f2") ? {
1148
+ service: ESCPOS_ALT_SERVICE,
1149
+ write: ESCPOS_ALT_WRITE
1150
+ } : await writeChannel(server, services);
1151
+ return channel ? new EscPosPrinterClient(await webTransport(device, server, channel), model) : null;
1152
+ }
1153
+ /** The name starts like one of niimbluelib's NIIMBOT models ("D110-…", "B21_…"). */
1154
+ function looksNiimbot(lib, name) {
1155
+ const token = name?.split(/[-_ ]/)[0].toUpperCase();
1156
+ return !!token && lib.modelsLibrary.some((m) => m.model.split("_")[0] === token);
1157
+ }
1158
+ /**
881
1159
  * The Bluetooth printer behind the Connect button. One chooser lists NIIMBOT
882
1160
  * printers and the known extra thermal printers. A known cat printer offering
883
- * AE30 gets the cat client, a known ESC/POS printer offering 18F0 the ESC/POS
884
- * client, a known TSPL label printer offering 18F0 the TSPL client, and
885
- * everything else the NIIMBOT client exactly as before. Known names never
886
- * overlap, so each printer matches one client at most.
1161
+ * AE30 gets the cat client; a known ESC/POS printer the ESC/POS client, over
1162
+ * 18F0, the shared E781 service (as Xprinters use), or any serial service
1163
+ * with a writable characteristic; a known TSPL label printer offering 18F0
1164
+ * the TSPL client; and everything else the NIIMBOT client exactly as before.
1165
+ * Known names never overlap, so each printer matches one client at most.
887
1166
  */
888
1167
  var BluetoothPrinter = class {
889
1168
  lib;
@@ -895,36 +1174,31 @@ var BluetoothPrinter = class {
895
1174
  if (!this.inner) throw new Error("Not connected");
896
1175
  return this.inner;
897
1176
  }
1177
+ /** The kind of printer to treat an unrecognised one as; null leaves it to NIIMBOT. */
1178
+ fallback = null;
1179
+ unrecognisedAs(typeId) {
1180
+ this.fallback = findPrinterType(typeId);
1181
+ }
898
1182
  async connect() {
899
- const device = await requestPrinter([
900
- ...CAT_MODELS,
901
- ...ESCPOS_MODELS,
902
- ...TSPL_MODELS
903
- ].map((m) => m.prefix));
904
- const cat = catModelFor(device.name);
905
- const escpos = escposModelFor(device.name);
906
- const tspl = tsplModelFor(device.name);
907
- if ((cat || escpos || tspl) && device.gatt) {
1183
+ const device = await requestPrinter(PRINTER_CATALOG.map((e) => e.model.prefix), !!this.fallback);
1184
+ const known = identifyPrinter(device.name);
1185
+ const fallback = !known && this.fallback && !looksNiimbot(await this.lib(), device.name) ? this.fallback : null;
1186
+ const entry = known ?? fallback;
1187
+ if (entry && device.gatt) {
908
1188
  const server = await device.gatt.connect();
909
1189
  const services = (await server.getPrimaryServices().catch(() => [])).map((s) => s.uuid);
910
- if (cat && services.includes("0000ae30-0000-1000-8000-00805f9b34fb")) this.inner = new CatPrinterClient(await webTransport(device, server, {
911
- service: CAT_SERVICE,
912
- write: CAT_TX,
913
- notify: CAT_RX
914
- }), cat);
915
- else if (escpos && services.includes("000018f0-0000-1000-8000-00805f9b34fb")) this.inner = new EscPosPrinterClient(await webTransport(device, server, {
916
- service: ESCPOS_SERVICE,
917
- write: ESCPOS_WRITE
918
- }), escpos);
919
- else if (tspl && services.includes("000018f0-0000-1000-8000-00805f9b34fb")) this.inner = new TsplPrinterClient(await webTransport(device, server, {
920
- service: TSPL_SERVICE,
921
- write: TSPL_WRITE,
922
- notify: TSPL_NOTIFY
923
- }), tspl);
1190
+ this.inner = await thermalClient(device, server, services, entry);
924
1191
  if (this.inner) return this.inner.connect();
1192
+ if (!known) throw new Error(`${device.name ?? "This printer"} does not offer a ${PRINTER_TYPE_NAMES[entry.family]} service`);
1193
+ }
1194
+ const lib = await this.lib();
1195
+ this.inner = createBleClient(lib, device);
1196
+ try {
1197
+ return await this.inner.connect();
1198
+ } catch (err) {
1199
+ if (looksNiimbot(lib, device.name)) throw err;
1200
+ throw new Error(`${device.name ?? "This printer"} did not answer as a NIIMBOT. If it is another kind, pick its type under Advanced and connect again.`);
925
1201
  }
926
- this.inner = createBleClient(await this.lib(), device);
927
- return this.inner.connect();
928
1202
  }
929
1203
  async disconnect() {
930
1204
  await this.inner?.disconnect();
@@ -971,6 +1245,12 @@ var BluetoothPrinter = class {
971
1245
  get rollKey() {
972
1246
  return this.inner?.rollKey?.bind(this.inner);
973
1247
  }
1248
+ get feedPaper() {
1249
+ return this.inner?.feedPaper?.bind(this.inner);
1250
+ }
1251
+ get feedsBack() {
1252
+ return this.inner?.feedsBack;
1253
+ }
974
1254
  };
975
1255
  // src/mock.ts
976
1256
  /** A printer that only exists in memory. Used by the demo mode and the tests. */
@@ -1294,6 +1574,22 @@ function shiftCanvas(canvas, dx, dy) {
1294
1574
  ctx.drawImage(canvas, dx, dy);
1295
1575
  return out;
1296
1576
  }
1577
+ /** The page turned clockwise by `degrees`, a multiple of 90. Returns the canvas itself for no turn. */
1578
+ function rotateCanvas(canvas, degrees) {
1579
+ const turn = (Math.round(degrees / 90) % 4 + 4) % 4;
1580
+ if (!turn) return canvas;
1581
+ const out = document.createElement("canvas");
1582
+ out.width = turn % 2 ? canvas.height : canvas.width;
1583
+ out.height = turn % 2 ? canvas.width : canvas.height;
1584
+ const ctx = out.getContext("2d");
1585
+ if (!ctx) return canvas;
1586
+ ctx.fillStyle = "#fff";
1587
+ ctx.fillRect(0, 0, out.width, out.height);
1588
+ ctx.translate(out.width / 2, out.height / 2);
1589
+ ctx.rotate(turn * Math.PI / 2);
1590
+ ctx.drawImage(canvas, -canvas.width / 2, -canvas.height / 2);
1591
+ return out;
1592
+ }
1297
1593
  /** Crops the page to what the printhead covers. Returns the canvas itself when it already fits. */
1298
1594
  function fitToPrinthead(canvas, profile) {
1299
1595
  const crop = printheadCrop(canvas.width, canvas.height, profile);
@@ -1346,6 +1642,8 @@ var PrinterService = class {
1346
1642
  listeners = /* @__PURE__ */ new Set();
1347
1643
  manualProfileId = null;
1348
1644
  detach = null;
1645
+ /** Paper moves run one after another, and a print waits for them. */
1646
+ feeding = Promise.resolve();
1349
1647
  /** How the current printer is connected. */
1350
1648
  kind = null;
1351
1649
  constructor(createClient, encode) {
@@ -1412,6 +1710,7 @@ var PrinterService = class {
1412
1710
  let client;
1413
1711
  try {
1414
1712
  client = await this.createClient(kind);
1713
+ client.unrecognisedAs?.(this.manualProfileId);
1415
1714
  const info = await client.connect();
1416
1715
  this.client = client;
1417
1716
  this.set({ deviceName: info.deviceName });
@@ -1448,7 +1747,9 @@ var PrinterService = class {
1448
1747
  battery: client.getPrinterInfo().batteryPercents,
1449
1748
  detectedModel: meta?.model,
1450
1749
  profile: this.currentProfile(),
1451
- label: this.readRfid()
1750
+ label: this.readRfid(),
1751
+ ...client.feedPaper ? { canFeed: true } : {},
1752
+ ...client.feedPaper && client.feedsBack ? { canRetract: true } : {}
1452
1753
  });
1453
1754
  client.startHeartbeat();
1454
1755
  }
@@ -1485,12 +1786,31 @@ var PrinterService = class {
1485
1786
  total: copies
1486
1787
  } });
1487
1788
  }
1789
+ /**
1790
+ * Moves the paper by `mm`, out of the printer when positive and back in when
1791
+ * negative, to line it up on printers that cannot sense it.
1792
+ */
1793
+ feed(mm) {
1794
+ const client = this.client;
1795
+ const profile = this.status.profile;
1796
+ if (!client || !profile) return Promise.reject(/* @__PURE__ */ new Error("Connect a printer first"));
1797
+ if (!client.feedPaper) return Promise.reject(/* @__PURE__ */ new Error("This printer finds the paper itself"));
1798
+ if (mm < 0 && !client.feedsBack) return Promise.reject(/* @__PURE__ */ new Error("This printer cannot move the paper back"));
1799
+ if (this.status.state === "printing") return Promise.reject(/* @__PURE__ */ new Error("Wait for the print to finish"));
1800
+ const dots = Math.round(mmToPx(mm, profile.dpi));
1801
+ const run = this.feeding.then(() => dots ? client.feedPaper(dots) : void 0);
1802
+ this.feeding = run.catch(() => {});
1803
+ return run.catch((err) => {
1804
+ throw new Error(friendlyError(err));
1805
+ });
1806
+ }
1488
1807
  async print(source, opts) {
1489
1808
  const client = this.client;
1490
1809
  const profile = this.status.profile;
1491
1810
  if (!client) throw new Error("Connect a printer first");
1492
1811
  if (!profile) throw new Error("Choose your printer type");
1493
1812
  if (this.status.state === "printing") throw new Error("Already printing");
1813
+ await this.feeding;
1494
1814
  const pages = "page" in source ? source : {
1495
1815
  count: 1,
1496
1816
  page: async () => source
@@ -1502,6 +1822,7 @@ var PrinterService = class {
1502
1822
  totalPages: total,
1503
1823
  density,
1504
1824
  labelType: opts.labelType ?? 1,
1825
+ ...opts.gapMm !== void 0 && client.feedPaper ? { gapDots: Math.round(mmToPx(Math.max(0, opts.gapMm), profile.dpi)) } : {},
1505
1826
  pageColor: 0,
1506
1827
  statusPollIntervalMs: 100,
1507
1828
  statusTimeoutMs: 8e3
@@ -1520,7 +1841,7 @@ var PrinterService = class {
1520
1841
  y: 0
1521
1842
  };
1522
1843
  for (let i = 0; i < pages.count; i++) {
1523
- const canvas = await pages.page(i);
1844
+ const canvas = rotateCanvas(await pages.page(i), opts.rotate ?? 0);
1524
1845
  const page = fitToPrinthead(shiftCanvas(canvas, mmToPx(offset.x, profile.dpi), mmToPx(offset.y, profile.dpi)), profile);
1525
1846
  const image = client.encodePage ? await client.encodePage(page) : await this.encode(page, profile.printDirection);
1526
1847
  if (i === 0) {
@@ -1530,6 +1851,7 @@ var PrinterService = class {
1530
1851
  direction: profile.printDirection,
1531
1852
  printhead: profile.printheadPixels,
1532
1853
  page: `${canvas.width}x${canvas.height}`,
1854
+ rotate: opts.rotate ?? 0,
1533
1855
  sent: `${page.width}x${page.height}`,
1534
1856
  density,
1535
1857
  copies,
@@ -1598,6 +1920,6 @@ async function createClient(kind) {
1598
1920
  function createPrinter(options = {}) {
1599
1921
  return new PrinterService(options.createClient ?? createClient, async (canvas, direction) => (await niimbot()).ImageEncoder.encodeCanvas(canvas, 0, direction));
1600
1922
  }
1601
- export { BluetoothPrinter, CAT_MODELS, CAT_UNSUPPORTED, CatPrinterClient, DEMO_META, DEMO_NAME, ESCPOS_MODELS, EscPosPrinterClient, MANUAL_PROFILES, MockClient, PAPER_TYPES, PrinterService, THERMAL_DEFAULT_SIZE, THERMAL_HEAD_DOTS, THERMAL_HEAD_MM, THERMAL_SIZES, TSPL_MODELS, TSPL_SIZES, ThermalPrinterClient, TsplPrinterClient, printJob as catJob, catModelFor, correctTask, createClient, createPrinter, describeBleError, detectPlatform, effectivePaper, escposJob, escposModelFor, findManualProfile, fitToPrinthead, friendlyError, inkOutsideHead, labelTypeOf, modelFor, paperFromLabelType, paperName, printheadCrop, printheadMm, resolveProfile, shiftCanvas, supportsBluetooth, supportsSerial, tsplJob, tsplModelFor, unsupportedReason, webTransport };
1923
+ export { BluetoothPrinter, CAT_MODELS, CAT_UNSUPPORTED, CatPrinterClient, DEMO_META, DEMO_NAME, ESCPOS_MODELS, EscPosPrinterClient, MANUAL_PROFILES, MockClient, PAPER_TYPES, PRINTER_CATALOG, PRINTER_TYPES, PrinterService, THERMAL_DEFAULT_SIZE, THERMAL_HEAD_DOTS, THERMAL_HEAD_MM, THERMAL_SIZES, TSPL_MODELS, TSPL_SIZES, ThermalPrinterClient, TsplPrinterClient, catFeed, printJob as catJob, catModelFor, correctTask, createClient, createPrinter, describeBleError, detectPlatform, effectivePaper, escposFeed, escposJob, escposModelFor, findManualProfile, findPrinterType, fitToPrinthead, friendlyError, identifyPrinter, inkOutsideHead, labelTypeOf, modelFor, paperFromLabelType, paperName, printheadCrop, printheadMm, resolveProfile, rotateCanvas, shiftCanvas, supportsBluetooth, supportsSerial, tsplJob, tsplModelFor, unsupportedReason, webTransport };
1602
1924
 
1603
1925
  //# sourceMappingURL=libniimai.js.map