diff --git a/demo/index.html b/demo/index.html
index b8861d5..5bb8e4d 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -320,7 +320,7 @@
${titleHtml}
const printer = this.printers[printerIdx];
const textarea = this.labelForm.querySelector('#labelFormText') as HTMLTextAreaElement;
const rawReceiptline = textarea.value;
- const doc = WebReceipt.parseReceiptLineToDocument(rawReceiptline, printer.printerOptions);
+ const doc = await WebReceipt.parseReceiptLineToDocument(rawReceiptline, printer.printerOptions);
await printer.sendDocument(doc);
});
document.getElementById(`printer_${idx}`)!
diff --git a/demo/test_index.ts b/demo/test_index.ts
index fe038af..ccd5771 100644
--- a/demo/test_index.ts
+++ b/demo/test_index.ts
@@ -246,7 +246,7 @@ class BasicDocumentPrinterApp {
const printer = this.printers[printerIdx];
const textarea = this.labelForm.querySelector('#labelFormText') as HTMLTextAreaElement;
const rawReceiptline = textarea.value;
- const doc = WebReceipt.parseReceiptLineToDocument(rawReceiptline, printer.printerOptions);
+ const doc = await WebReceipt.parseReceiptLineToDocument(rawReceiptline, printer.printerOptions);
await printer.sendDocument(doc);
});
document.getElementById(`printer_${idx}`)!
diff --git a/src/Commands/BasicCommands.ts b/src/Commands/BasicCommands.ts
index b698063..24a0969 100644
--- a/src/Commands/BasicCommands.ts
+++ b/src/Commands/BasicCommands.ts
@@ -101,7 +101,7 @@ export class ImageCommand implements IPrinterBasicCommand {
effectFlags = new CommandEffectFlags(["feedsPaper"]);
toDisplay() { return this.name; }
- constructor(public readonly imgData: string) {}
+ constructor(public readonly img: Util.BitmapGRF) {}
}
export class Barcode implements IPrinterBasicCommand {
@@ -134,7 +134,7 @@ export class RawCommand implements IPrinterBasicCommand {
export type Underline = "None" | "Single" | "Double";
export type Bold = "None" | "Enable";
export type Invert = "None" | "Enable";
-export type Alignment = "Left" | "Center" | "Right";
+export type Alignment = "Left" | "Center" | "Right" | "Toggle";
export type Width = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export type Height = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
diff --git a/src/Languages/EscPos/BasicCommands.ts b/src/Languages/EscPos/BasicCommands.ts
index 2a2358a..3381127 100644
--- a/src/Languages/EscPos/BasicCommands.ts
+++ b/src/Languages/EscPos/BasicCommands.ts
@@ -1,5 +1,6 @@
import * as Util from '../../Util/index.js';
import * as Cmds from '../../Commands/index.js';
+import * as Conf from '../../Configs/index.js';
import { codepageNumberForEscPos, codepageSwitchCmd } from './Codepages.js';
/** Encode a single character, for readability of command sequences. */
@@ -51,7 +52,7 @@ export function setTextFormatting(f: Cmds.TextFormat, docState: Cmds.TranspiledD
const buffer: number[] = [];
if (f.underline !== undefined || f.resetToDefault) { // ESC - // FS -
- docState.textFormat.underline = f.underline;
+ docState.textFormat.underline = f.underline ?? 'None';
let op: number;
switch (f.underline) {
default:
@@ -75,11 +76,20 @@ export function setTextFormatting(f: Cmds.TextFormat, docState: Cmds.TranspiledD
}
if (f.alignment !== undefined || f.resetToDefault) { // ESC a
- docState.textFormat.alignment = f.alignment;
+ let newAlign = f.alignment ?? 'Center';
+ if (newAlign === 'Toggle') {
+ switch (docState.textFormat.alignment) {
+ case 'Left': newAlign = 'Right'; break;
+ case 'Right': newAlign = 'Left'; break;
+ default: newAlign = 'Center'; break;
+ }
+ }
+
+ docState.textFormat.alignment = newAlign;
let op: number;
- switch (f.alignment) {
+ switch (newAlign) {
+ default: Util.exhaustiveMatchGuard(newAlign); break;
case 'Left': op = 0x00; break;
- default:
case 'Center': op = 0x01; break;
case 'Right': op = 0x02; break;
}
@@ -115,10 +125,10 @@ export function setCodepage(
docState.codepage = gotCode;
}
- return [new Uint8Array([
+ return new Uint8Array([
// ESC t , then any other weird commands we might need to set.
- Util.AsciiCodeNumbers.ESC, enc('t')]), code
- ];
+ Util.AsciiCodeNumbers.ESC, enc('t'),
+ ...code]);
}
export function offsetPrintPosition(
@@ -203,12 +213,76 @@ export function text(
const fragments = Util.CodepageEncoder
.autoEncode(text, candidateCodepages)
.flatMap(f => [
- ...setCodepage(f.codepage, docState),
+ setCodepage(f.codepage, docState),
f.bytes
]);
return fragments;
}
+export function image(
+ cmd: Cmds.ImageCommand,
+ docState: Cmds.TranspiledDocumentState
+) {
+ const buffer: number[] = [];
+ const invert = docState.initialConfig.printOrientation === Conf.PrintOrientation.inverted;
+
+ if (invert) {
+ // When printing inverted we must also invert the print area and alignment.
+ buffer.push(...setPrintArea(new Cmds.SetPrintArea(
+ docState.margin.rightChars,
+ docState.currentPrintWidth,
+ docState.margin.leftChars
+ ), docState),
+ ...setTextFormatting({alignment: "Toggle"}, docState)
+ );
+ // let r = docState.initialConfig.printOrientation == Conf.PrintOrientation.inverted
+ // ? this.area(right + this.marginRight - this.margin, width, left) + this.align(2 - align)
+ // : '';
+ }
+
+ // Number of rows of pixels to split the image on into multiple commands.
+ const maxImgLength = 512;
+
+ // ESCPOS treats colors as print element enable. 1 means black, 0 means white.
+ const bitmap = invert
+ ? cmd.img.toInvertedGRF().rotate(180)
+ : cmd.img.toInvertedGRF();
+ const imgWidth = bitmap.boundingBox.width;
+ const imgByteWidth = bitmap.boundingBox.width + 7 >> 3;
+ const imgData = bitmap.toBinaryGRF();
+
+ for (let z = 0; z < bitmap.boundingBox.height; z += maxImgLength) {
+ const imgSplitHeight = Math.min(maxImgLength, bitmap.boundingBox.height - z);
+ const imgDataLength = imgByteWidth * imgSplitHeight + 10; // TODO: why 10?
+
+ buffer.push(
+ Util.AsciiCodeNumbers.GS, enc('8'), enc('L'), // Direct load raster img
+ imgDataLength & 255, // p1
+ imgDataLength >> 8 & 255, // p2
+ imgDataLength >> 16 & 255, // p3
+ imgDataLength >> 24 & 255, // p4
+
+ 48, 112, // raster img data command
+ 48, // monocolor, 52 = grayscale
+ 1, // bx, horizontal scale, 1 or 2
+ 1, // by, vertical scale, 1 or 2
+ 49, // c, color, 49 for mono. Not all printers support other colors.
+
+ imgWidth & 255, // xL
+ imgWidth >> 8 & 255, // xH
+ imgSplitHeight & 255, // yL
+ imgSplitHeight >> 8 & 255, // yH
+
+ ...imgData.slice((z + 7 >> 3), imgByteWidth * imgSplitHeight),
+
+ Util.AsciiCodeNumbers.GS, enc('('), enc('L'), // Print raster img
+ 2, 0, 48, 50
+ );
+ }
+
+ return new Uint8Array(buffer);
+}
+
export function setFormattingCodepage() {
// FS C 0 to disable kanji
// FS . to reset katakana mode?
diff --git a/src/Languages/EscPos/EscPos.ts b/src/Languages/EscPos/EscPos.ts
index 16e7bbc..5c13ef0 100644
--- a/src/Languages/EscPos/EscPos.ts
+++ b/src/Languages/EscPos/EscPos.ts
@@ -98,7 +98,7 @@ export class EscPos extends Cmds.RawCommandSet {
},
Codepage: {
commandType: 'Codepage',
- transpile: (c, d) => this.combineCommands(...Basic.setCodepage((c as Cmds.SetCodepage).codepage, d)),
+ transpile: (c, d) => Basic.setCodepage((c as Cmds.SetCodepage).codepage, d),
},
HorizontalRule: {
commandType: 'HorizontalRule',
@@ -106,7 +106,7 @@ export class EscPos extends Cmds.RawCommandSet {
},
Image: {
commandType: 'Image',
- transpile: (c) => { throw new Cmds.TranspileDocumentError(`Command not implemented: ${c.constructor.name}`) },
+ transpile: (c, d) => Basic.image(c as Cmds.ImageCommand, d),
},
SetLineSpacing: {
commandType: 'SetLineSpacing',
diff --git a/src/ReceiptLine/ESCPOS.js b/src/ReceiptLine/ESCPOS.js
index 3a37403..8cc94aa 100644
--- a/src/ReceiptLine/ESCPOS.js
+++ b/src/ReceiptLine/ESCPOS.js
@@ -6,43 +6,6 @@ import Flatten from 'canvas-flatten';
const codepageMappings = {
epson: {
- 'cp437': 0x00,
- 'shiftjis': 0x01,
- 'cp850': 0x02,
- 'cp860': 0x03,
- 'cp863': 0x04,
- 'cp865': 0x05,
- 'cp851': 0x0b,
- 'cp853': 0x0c,
- 'cp857': 0x0d,
- 'cp737': 0x0e,
- 'iso88597': 0x0f,
- 'windows1252': 0x10,
- 'cp866': 0x11,
- 'cp852': 0x12,
- 'cp858': 0x13,
- 'cp720': 0x20,
- 'cp775': 0x21,
- 'cp855': 0x22,
- 'cp861': 0x23,
- 'cp862': 0x24,
- 'cp864': 0x25,
- 'cp869': 0x26,
- 'iso88592': 0x27,
- 'iso885915': 0x28,
- 'cp1098': 0x29,
- 'cp1118': 0x2a,
- 'cp1119': 0x2b,
- 'cp1125': 0x2c,
- 'windows1250': 0x2d,
- 'windows1251': 0x2e,
- 'windows1253': 0x2f,
- 'windows1254': 0x30,
- 'windows1255': 0x31,
- 'windows1256': 0x32,
- 'windows1257': 0x33,
- 'windows1258': 0x34,
- 'rk1048': 0x35,
},
zjiang: {
@@ -607,128 +570,4 @@ class EscPosEncoder {
return this;
}
-
- /**
- * Image
- *
- * @param {object} element an element, like a canvas or image that needs to be printed
- * @param {number} width width of the image on the printer
- * @param {number} height height of the image on the printer
- * @param {string} algorithm the dithering algorithm for making the image black and white
- * @param {number} threshold threshold for the dithering algorithm
- * @return {object} Return the object, for easy chaining commands
- *
- */
- image(element, width, height, algorithm, threshold) {
- if (this._embedded) {
- throw new Error('Images are not supported in table cells or boxes');
- }
-
- if (width % 8 !== 0) {
- throw new Error('Width must be a multiple of 8');
- }
-
- if (height % 8 !== 0) {
- throw new Error('Height must be a multiple of 8');
- }
-
- if (typeof algorithm === 'undefined') {
- algorithm = 'threshold';
- }
-
- if (typeof threshold === 'undefined') {
- threshold = 128;
- }
-
- const canvas = createCanvas(width, height);
- const context = canvas.getContext('2d');
- context.drawImage(element, 0, 0, width, height);
- let image = context.getImageData(0, 0, width, height);
-
- image = Flatten.flatten(image, [0xff, 0xff, 0xff]);
-
- switch (algorithm) {
- case 'threshold': image = Dither.threshold(image, threshold); break;
- case 'bayer': image = Dither.bayer(image, threshold); break;
- case 'floydsteinberg': image = Dither.floydsteinberg(image); break;
- case 'atkinson': image = Dither.atkinson(image); break;
- }
-
- const getPixel = (x, y) => x < width && y < height ? (image.data[((width * y) + x) * 4] > 0 ? 0 : 1) : 0;
-
- const getColumnData = (width, height) => {
- const data = [];
-
- for (let s = 0; s < Math.ceil(height / 24); s++) {
- const bytes = new Uint8Array(width * 3);
-
- for (let x = 0; x < width; x++) {
- for (let c = 0; c < 3; c++) {
- for (let b = 0; b < 8; b++) {
- bytes[(x * 3) + c] |= getPixel(x, (s * 24) + b + (8 * c)) << (7 - b);
- }
- }
- }
-
- data.push(bytes);
- }
-
- return data;
- };
-
- const getRowData = (width, height) => {
- const bytes = new Uint8Array((width * height) >> 3);
-
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x = x + 8) {
- for (let b = 0; b < 8; b++) {
- bytes[(y * (width >> 3)) + (x >> 3)] |= getPixel(x + b, y) << (7 - b);
- }
- }
- }
-
- return bytes;
- };
-
-
- if (this._cursor != 0) {
- this.newline();
- }
-
- /* Encode images with ESC * */
-
- if (this._options.imageMode == 'column') {
- this._queue([
- 0x1b, 0x33, 0x24,
- ]);
-
- getColumnData(width, height).forEach((bytes) => {
- this._queue([
- 0x1b, 0x2a, 0x21,
- (width) & 0xff, (((width) >> 8) & 0xff),
- bytes,
- 0x0a,
- ]);
- });
-
- this._queue([
- 0x1b, 0x32,
- ]);
- }
-
- /* Encode images with GS v */
-
- if (this._options.imageMode == 'raster') {
- this._queue([
- 0x1d, 0x76, 0x30, 0x00,
- (width >> 3) & 0xff, (((width >> 3) >> 8) & 0xff),
- height & 0xff, ((height >> 8) & 0xff),
- getRowData(width, height),
- ]);
- }
-
- this._flush();
-
- return this;
- }
}
diff --git a/src/ReceiptLine/Parser.ts b/src/ReceiptLine/Parser.ts
index 304a8a4..9b3ebef 100644
--- a/src/ReceiptLine/Parser.ts
+++ b/src/ReceiptLine/Parser.ts
@@ -1,5 +1,6 @@
/* eslint-disable no-control-regex */
import * as Cmds from '../Commands/index.js';
+import * as Util from '../Util/index.js';
import { repeat, numberInRange, clampToRange } from '../Util/NumericRange.js';
import type { IDocument } from '../Documents/index.js';
@@ -64,6 +65,7 @@ function getLeftAlignmentMultiplier(align: Cmds.Alignment) {
const alignMultiplier: Record = {
Left: 0,
Center: 0.5,
+ Toggle: 0.5,
Right: 1
};
return alignMultiplier[align];
@@ -431,7 +433,10 @@ function columnsToLine(
* @param {Cmds.PrinterConfig} printerConfig Printer configuration
* @returns {IDocument} PCL document to give to a printer.
*/
-export function parseReceiptLineToDocument(doc: string, printerConfig: Cmds.PrinterConfig): IDocument {
+export async function parseReceiptLineToDocument(
+ doc: string,
+ printerConfig: Cmds.PrinterConfig
+): Promise {
// initialize state variables
const state: parseState = {
wrap: true,
@@ -450,10 +455,11 @@ export function parseReceiptLineToDocument(doc: string, printerConfig: Cmds.Prin
}
// parse each line and generate commands
- const res: Cmds.IPrinterCommand[] = doc
+ const res = (await Promise.all(doc
.normalize()
.split(/\n|\r\n|\r/)
- .flatMap(line => createLine(parseLine(line, state), printerConfig, state));
+ .map(async line => await createLine(parseLine(line, state), printerConfig, state))))
+ .flat();
// Clean up any lingering table formatting
switch (state.nextRuleOperation) {
@@ -579,11 +585,11 @@ function parseEscape(str: string) {
* @param {object} state state variables
* @returns {string} printer command fragment or SVG image fragment
*/
-function createLine(
+async function createLine(
line: lineElement[],
printerConfig: Cmds.PrinterConfig,
state: parseState
-): Cmds.IPrinterCommand[] {
+): Promise {
const lineCmds: Cmds.IPrinterCommand[] = [];
const isTextLine = line.every(el => el.text !== undefined);
@@ -827,7 +833,8 @@ function createLine(
// append commands to print image
lineCmds.push(
...resetFormattingCmds(left, width, right, firstColumn.align),
- new Cmds.ImageCommand(firstColumn.image),
+ new Cmds.ImageCommand(
+ await Util.BitmapGRF.fromBase64PNG(firstColumn.image, { trimWhitespace: false })),
);
}
diff --git a/src/ReceiptLine/RECEIPTLINE.js b/src/ReceiptLine/RECEIPTLINE.js
index 36602a5..8d1110a 100644
--- a/src/ReceiptLine/RECEIPTLINE.js
+++ b/src/ReceiptLine/RECEIPTLINE.js
@@ -944,53 +944,12 @@ limitations under the License.
'\x1b{' + String.fromCharCode(this.upsideDown) +
'\x1c.';
},
- // image split size
- split: 512,
- // print image: GS 8 L p1 p2 p3 p4 m fn a bx by c xL xH yL yH d1 ... dk GS ( L pL pH m fn
- image: function (image) {
- const align = arguments[1] || this.alignment;
- const left = arguments[2] || this.left;
- const width = arguments[3] || this.width;
- const right = arguments[4] || this.right;
- let r = this.upsideDown ? this.area(right + this.marginRight - this.margin, width, left) + this.align(2 - align) : '';
- const img = PNG.sync.read(Buffer.from(image, 'base64'));
- const w = img.width;
- const d = Array(w).fill(0);
- let j = this.upsideDown ? img.data.length - 4 : 0;
- for (let z = 0; z < img.height; z += this.split) {
- const h = Math.min(this.split, img.height - z);
- const l = (w + 7 >> 3) * h + 10;
- r += '\x1d8L' + String.fromCharCode(l & 255, l >> 8 & 255, l >> 16 & 255, l >> 24 & 255, 48, 112, 48, 1, 1, 49, w & 255, w >> 8 & 255, h & 255, h >> 8 & 255);
- for (let y = 0; y < h; y++) {
- let i = 0, e = 0;
- for (let x = 0; x < w; x += 8) {
- let b = 0;
- const q = Math.min(w - x, 8);
- for (let p = 0; p < q; p++) {
- const f = Math.floor((d[i] + e * 5) / 16 + Math.pow(((img.data[j] * .299 + img.data[j + 1] * .587 + img.data[j + 2] * .114 - 255) * img.data[j + 3] + 65525) / 65525, 1 / this.gamma) * 255);
- j += this.upsideDown ? -4 : 4;
- if (this.gradient) {
- d[i] = e * 3;
- e = f < this.threshold ? (b |= 128 >> p, f) : f - 255;
- if (i > 0) {
- d[i - 1] += e;
- }
- d[i++] += e * 7;
- }
- else {
- if (f < this.threshold) {
- b |= 128 >> p;
- }
- }
- }
- r += String.fromCharCode(b);
- }
- }
- r += '\x1d(L' + String.fromCharCode(2, 0, 48, 50);
- }
- return r;
- },
- // print QR Code: GS ( k pL pH cn fn n1 n2 GS ( k pL pH cn fn n GS ( k pL pH cn fn n GS ( k pL pH cn fn m d1 ... dk GS ( k pL pH cn fn m
+ // print QR Code:
+ // GS ( k pL pH cn fn n1 n2
+ // GS ( k pL pH cn fn n
+ // GS ( k pL pH cn fn n
+ // GS ( k pL pH cn fn m d1 ... dk
+ // GS ( k pL pH cn fn m
qrcode: function (symbol, encoding) {
if (typeof qrcode !== 'undefined') {
let r = this.upsideDown ? this.area(this.right + this.marginRight - this.margin, this.width, this.left) + this.align(2 - this.alignment) : '';
@@ -1036,7 +995,11 @@ limitations under the License.
qrlevel: {
l: 48, m: 49, q: 50, h: 51
},
- // print barcode: GS w n GS h n GS H n GS k m n d1 ... dn
+ // print barcode:
+ // GS w n
+ // GS h n
+ // GS H n
+ // GS k m n d1 ... dn
barcode: function (symbol, encoding) {
let d = iconv.encode(symbol.data, encoding === 'multilingual' ? 'ascii' : encoding).toString('binary');
const b = this.bartype[symbol.type] + Number(/upc|[ej]an/.test(symbol.type) && symbol.data.length < 9);
diff --git a/src/Util/ASCII.ts b/src/Util/ASCII.ts
index d32b563..bc19c46 100644
--- a/src/Util/ASCII.ts
+++ b/src/Util/ASCII.ts
@@ -143,6 +143,7 @@ export function EncodeAscii(str: string): Uint8Array {
* Convert a byte array of raw ASCII codepoints to a string.
* @param array
*/
-export function DecodeAscii(array: Uint8Array): string {
- return new TextDecoder('ascii').decode(array);
+export function DecodeAscii(input?: Uint8Array) {
+ if (input === undefined) { return ''; }
+ return String.fromCharCode(...input);
}
diff --git a/src/Util/BitmapGRF.test.ts b/src/Util/BitmapGRF.test.ts
new file mode 100644
index 0000000..cd61dba
--- /dev/null
+++ b/src/Util/BitmapGRF.test.ts
@@ -0,0 +1,443 @@
+import { expect, describe, it } from 'vitest';
+
+import {
+ BitmapGRF,
+ DitheringMethod,
+ type ImageConversionOptions
+} from './BitmapGRF.js';
+
+import * as testImage1 from "./test_files/test_imgdata.json" with { type: 'json' }
+
+// Class pulled from jest-mock-canvas which I can't seem to actually import.
+class ImageData {
+ _width = 0;
+ _height = 0;
+ _data: Uint8ClampedArray;
+ get width() {
+ return this._width;
+ }
+
+ get height() {
+ return this._height;
+ }
+
+ get data() {
+ return this._data;
+ }
+
+ get colorSpace() {
+ return 'srgb' as PredefinedColorSpace;
+ }
+
+
+ /**
+ * Creates an `ImageData` object from a given `Uint8ClampedArray` and the size of the image it contains.
+ *
+ * @param array A `Uint8ClampedArray` containing the underlying pixel representation of the image.
+ * @param width An `unsigned` `long` representing the width of the image.
+ * @param height An `unsigned` `long` representing the height of the image. This value is optional: the height will be inferred from the array's size and the given width.
+ */
+ constructor(array: Uint8ClampedArray, width: number, height?: number)
+
+ /**
+ * Creates an `ImageData` object of a black rectangle with the given width and height.
+ *
+ * @param width An `unsigned` `long` representing the width of the image.
+ * @param height An `unsigned` `long` representing the height of the image.
+ */
+ constructor(width: number, height: number)
+ constructor(arr: number | Uint8ClampedArray, w: number, h?: number) {
+ if (arguments.length === 2) {
+ if (arr instanceof Uint8ClampedArray) {
+ if (arr.length === 0)
+ throw new RangeError('Source length must be a positive multiple of 4.');
+ if (arr.length % 4 !== 0)
+ throw new RangeError('Source length must be a positive multiple of 4.');
+ if (!Number.isFinite(w)) throw new RangeError('The width is zero or not a number.');
+ if (w === 0) throw new RangeError('The width is zero or not a number.');
+ this._width = w;
+ this._height = arr.length / 4 / w;
+ this._data = arr;
+ } else {
+ const width = arr;
+ const height = w;
+ if (!Number.isFinite(height)) throw new RangeError('The height is zero or not a number.');
+ if (height === 0) throw new RangeError('The height is zero or not a number.');
+ if (!Number.isFinite(width)) throw new RangeError('The width is zero or not a number.');
+ if (width === 0) throw new RangeError('The width is zero or not a number.');
+ this._width = width;
+ this._height = height;
+ this._data = new Uint8ClampedArray(width * height * 4);
+ }
+ } else if (arguments.length === 3 && h !== undefined) {
+ if (!(arr instanceof Uint8ClampedArray))
+ throw new TypeError('First argument must be a Uint8ClampedArray when using 3 arguments.');
+ if (arr.length === 0) throw new RangeError('Source length must be a positive multiple of 4.');
+ if (arr.length % 4 !== 0)
+ throw new RangeError('Source length must be a positive multiple of 4.');
+ if (!Number.isFinite(h)) throw new RangeError('The height is zero or not a number.');
+ if (h === 0) throw new RangeError('The height is zero or not a number.');
+ if (!Number.isFinite(w)) throw new RangeError('The width is zero or not a number.');
+ if (w === 0) throw new RangeError('The width is zero or not a number.');
+ if (arr.length !== w * h * 4)
+ throw new RangeError("Source doesn't contain the exact number of pixels needed.");
+ this._width = w;
+ this._height = h;
+ this._data = arr;
+ } else {
+ throw new TypeError('Wrong number of arguments provided.');
+ }
+ }
+}
+
+global.ImageData = ImageData;
+
+function getImageDataInput(width: number, height: number, fill: number, alpha?: number) {
+ const arr = new Uint8ClampedArray(width * height * 4);
+ if (alpha != undefined && alpha != fill) {
+ for (let i = 0; i < arr.length; i += 4) {
+ arr[i + 0] = fill;
+ arr[i + 1] = fill;
+ arr[i + 2] = fill;
+ arr[i + 3] = alpha;
+ }
+ } else {
+ arr.fill(fill);
+ }
+ return arr;
+}
+
+function getImageDataInputAlternatingDots(width: number, height: number) {
+ const arr = new Uint8ClampedArray(width * height * 4);
+ let flip = 1;
+ for (let i = 0; i < arr.length; i += 4) {
+ flip = ~flip;
+ const fill = flip * 255;
+ arr[i + 0] = fill;
+ arr[i + 1] = fill;
+ arr[i + 2] = fill;
+ arr[i + 3] = 255;
+ }
+
+ return arr;
+}
+
+function getSnap(filename: string) {
+ return `./test_files/${filename}.ts.snap`;
+}
+function getImageDataFromFileJson() {
+ const file = testImage1.default;
+ return new ImageData(new Uint8ClampedArray(file.data), file.width);
+}
+
+const imageConversionOptions: ImageConversionOptions = {
+ ditheringMethod: DitheringMethod.none,
+ grayThreshold: 70,
+ trimWhitespace: false
+};
+
+describe('BitmapGRF', () => {
+ describe('RGBA Image Conversion', () => {
+ it('Should downconvert transparent images correctly', () => {
+ const imageData = new ImageData(getImageDataInput(8, 1, 0), 8, 1);
+ const expected = new Uint8Array([(1 << 8) - 1]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(8);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should downconvert black images correctly', () => {
+ const imageData = new ImageData(getImageDataInput(8, 1, 0, 255), 8, 1);
+ const expected = new Uint8Array([0]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(8);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should downconvert white images correctly', () => {
+ const imageData = new ImageData(getImageDataInput(8, 1, 255), 8, 1);
+ const expected = new Uint8Array([(1 << 8) - 1]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(8);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should downconvert checkered images correctly', () => {
+ const imageData = new ImageData(getImageDataInputAlternatingDots(8, 1), 8, 1);
+ const expected = new Uint8Array([85]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(8);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should pad and downconvert transparent images correctly', () => {
+ const imageData = new ImageData(getImageDataInput(5, 1, 0), 5, 1);
+ const expected = new Uint8Array([(1 << 8) - 1]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(5);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should pad and downconvert black images correctly', () => {
+ const imgWidth = 4;
+ const imageData = new ImageData(getImageDataInput(imgWidth, 1, 0, 255), imgWidth, 1);
+ const expected = new Uint8Array([(1 << imgWidth) - 1]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(4);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+
+ it('Should pad and downconvert white images correctly', () => {
+ const imgWidth = 4;
+ const imageData = new ImageData(getImageDataInput(imgWidth, 1, 255), imgWidth, 1);
+ const expected = new Uint8Array([(1 << 8) - 1]);
+ const { monochromeData, imageWidth, imageHeight } = BitmapGRF['toMonochrome'](
+ imageData.data,
+ imageData.width,
+ imageData.height,
+ imageConversionOptions
+ );
+ const { grfData, bytesPerRow } = BitmapGRF['monochromeToGRF'](
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ expect(imageWidth).toBe(4);
+ expect(imageHeight).toBe(1);
+ expect(bytesPerRow).toBe(1);
+ expect(grfData).toEqual(expected);
+ });
+ });
+
+ describe('RGBA Round Trip', () => {
+ it('Should not modify white images round-trip to imageData', () => {
+ const imageData = new ImageData(getImageDataInput(8, 1, 255, 255), 8, 1);
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: false });
+ const outImageData = img.toImageData();
+
+ expect(outImageData.data.length).toBe(8 * 4);
+ expect(outImageData.height).toBe(1);
+ expect(outImageData.width).toBe(8);
+ expect(outImageData.data).toEqual(imageData.data);
+ });
+
+ it('Should not modify black images round-trip to imageData', () => {
+ const imageData = new ImageData(getImageDataInput(8, 1, 0, 255), 8, 1);
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: false });
+ const outImageData = img.toImageData();
+
+ expect(outImageData.data.length).toBe(8 * 4);
+ expect(outImageData.height).toBe(1);
+ expect(outImageData.width).toBe(8);
+ expect(outImageData.data).toEqual(imageData.data);
+ });
+
+ it('Should not modify pattern images round-trip to imageData', () => {
+ // Alternating black and white pixels.
+ const imageWidth = 16;
+ const imageHeight = 2;
+ const imageData = new ImageData(
+ getImageDataInputAlternatingDots(imageWidth, imageHeight),
+ imageWidth,
+ imageHeight
+ );
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: false });
+ const outImageData = img.toImageData();
+
+ expect(outImageData.data.length).toBe(imageWidth * imageHeight * 4);
+ expect(outImageData.height).toBe(imageHeight);
+ expect(outImageData.width).toBe(imageWidth);
+ expect(outImageData.data).toEqual(imageData.data);
+ });
+ });
+
+ describe('Whitespace Trimming', () => {
+ it('Should trim to black pixels', () => {
+ // A single black pixel, surrounded by white on all sides, 10 pixels wide.
+ const imageData = new ImageData(
+ new Uint8ClampedArray([
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ ]), 10, 3);
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: true });
+
+ // Width will always be a multiple of 8 due to byte padding.
+ expect(img.width).toBe(8);
+ expect(img.height).toBe(1);
+ expect(img.boundingBox.width).toBe(10);
+ expect(img.boundingBox.height).toBe(3);
+ expect(img.boundingBox.paddingTop).toBe(1);
+ expect(img.boundingBox.paddingLeft).toBe(1);
+ expect(img.boundingBox.paddingBottom).toBe(1);
+ expect(img.boundingBox.paddingRight).toBe(1);
+ });
+
+ it('Should trim an all-white image', () => {
+ // A completely white image
+ const imageData = new ImageData(
+ new Uint8ClampedArray([
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+ ]), 10, 3);
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: true });
+
+ // Width will always be a multiple of 8 due to byte padding.
+ expect(img.width).toBe(8);
+ expect(img.height).toBe(1);
+ expect(img.boundingBox.width).toBe(10);
+ expect(img.boundingBox.height).toBe(3);
+ expect(img.boundingBox.paddingTop).toBe(0);
+ expect(img.boundingBox.paddingLeft).toBe(0);
+ expect(img.boundingBox.paddingBottom).toBe(2);
+ expect(img.boundingBox.paddingRight).toBe(2);
+ });
+
+ it('Should not trim an all-black image', () => {
+ const imageWidth = 16;
+ const imageHeight = 3;
+ const imageData = new ImageData(
+ getImageDataInput(imageWidth, imageHeight, 0, 255),
+ imageWidth,
+ imageHeight
+ );
+ const img = BitmapGRF.fromCanvasImageData(imageData, { trimWhitespace: true });
+
+ // Width will always be a multiple of 8 due to byte padding.
+ expect(img.width).toBe(imageWidth);
+ expect(img.height).toBe(imageHeight);
+ expect(img.boundingBox.width).toBe(imageWidth);
+ expect(img.boundingBox.height).toBe(imageHeight);
+ expect(img.boundingBox.paddingTop).toBe(0);
+ expect(img.boundingBox.paddingLeft).toBe(0);
+ expect(img.boundingBox.paddingBottom).toBe(0);
+ expect(img.boundingBox.paddingRight).toBe(0);
+ });
+ });
+
+ describe('Raw binary output', () => {
+ it('should transform an image correctly', async () => {
+ const imgData = getImageDataFromFileJson();
+ const img = BitmapGRF.fromCanvasImageData(imgData);
+ await expect(img.toBinaryGRF()).toMatchFileSnapshot(getSnap("test_imgdata_binarygrf"));
+ });
+ });
+
+ describe('rotate', () => {
+ it('should copy zero degree rotate', async () => {
+ const imgData = getImageDataFromFileJson();
+ const img = BitmapGRF.fromCanvasImageData(imgData).rotate(0);
+ await expect(img.toBinaryGRF()).toMatchFileSnapshot(getSnap("test_imgdata_binarygrf"));
+ });
+
+ it('should double rotate 180 back to original data', async () => {
+ const imgData = getImageDataFromFileJson();
+ const img = BitmapGRF.fromCanvasImageData(imgData).rotate(180).rotate(180);
+ await expect(img.toBinaryGRF()).toMatchFileSnapshot(getSnap("test_imgdata_binarygrf"));
+ });
+
+ it('should rotate image correctly', async () => {
+ const imgData = getImageDataFromFileJson();
+ const img = BitmapGRF.fromCanvasImageData(imgData).rotate(180);
+ await expect(img.toBinaryGRF()).toMatchFileSnapshot(getSnap("test_imgdata_binarygrf_rotate180"));
+ });
+
+ it('should reverse bytes', async () => {
+ const imgData = getImageDataInputAlternatingDots(8, 1);
+ const img = BitmapGRF.fromRGBA(imgData, 8);
+
+ // Non-rotated pattern is 0101_0101
+ expect(img.toBinaryGRF()[0]).toBe(85);
+ // thus rotated pattern should be 1010_1010
+ expect(img.rotate(180).toBinaryGRF()[0]).toBe(170);
+ });
+
+ it('reverses bytes correctly', () => {
+ const func = BitmapGRF['revByte'];
+ expect(func(254)).toBe(127);
+ expect(func(240)).toBe(15);
+ expect(func(170)).toBe(85);
+ expect(func(1)).toBe(128);
+ })
+ });
+});
diff --git a/src/Util/BitmapGRF.ts b/src/Util/BitmapGRF.ts
new file mode 100644
index 0000000..f0581af
--- /dev/null
+++ b/src/Util/BitmapGRF.ts
@@ -0,0 +1,487 @@
+import { exhaustiveMatchGuard } from './EnumUtils.js';
+import type { Percent } from './NumericRange.js';
+import { WebReceiptLineError } from './WebReceiptLineError.js';
+
+/** Padding information for a trimmed image. */
+export interface ImageBoundingBox {
+ /** The total original width of the image, including padding. */
+ width: number;
+ /** The total original height of the image, including padding. */
+ height: number;
+
+ /** The number of pixels between the top of the box and the actual image. */
+ paddingTop: number;
+ /** The number of pixels between the right side of the box and the actual image. */
+ paddingRight: number;
+ /** The number of pixels between the bottom of the box and the actual image. */
+ paddingBottom: number;
+ /** The number of pixels between the left side of the box and the actual image. */
+ paddingLeft: number;
+}
+
+/** Settings for converting an image to a GRF. */
+export interface ImageConversionOptions {
+ /** The threshold brightness below which to consider a pixel black. Defaults to 70. */
+ grayThreshold?: Percent;
+ /** Whether to trim whitespace around the image to reduce file size. Trimmed pixels will become padding in the bounding box. */
+ trimWhitespace?: boolean;
+ /** The dithering method to use when converting image to monochrome. */
+ ditheringMethod?: DitheringMethod;
+}
+
+/** List of available dithering methods for converting images to black/white. */
+export enum DitheringMethod {
+ /** No dithering, cutoff with used. */
+ none
+}
+
+/** Represents a GRF bitmap file. */
+export class BitmapGRF {
+ private _bitmap: Uint8Array;
+
+ private _width: number;
+ /** Gets the actual width of the image file, not including any padding. */
+ public get width() {
+ return this._width;
+ }
+
+ private _height: number;
+ /** Gets the actual height of the image file, not inlcuding any padding. */
+ public get height() {
+ return this._height;
+ }
+
+ private _bytesPerRow: number;
+ /** Gets the number of bytes per row (width) of the image file. */
+ public get bytesPerRow() {
+ return this._bytesPerRow;
+ }
+ /** Gets the total number of uncompressed bytes of the image file. Usually used in printer commands. */
+ public get bytesUncompressed() {
+ return this._bitmap.length;
+ }
+
+ private _boundingBox: ImageBoundingBox;
+ /** Gets the bounding box information for this image, for proper alignment of trimmed images. */
+ public get boundingBox() {
+ return this._boundingBox;
+ }
+
+ constructor(
+ bitmapGRF: Uint8Array,
+ imageWidth: number,
+ imageHeight: number,
+ bytesPerRow: number,
+ boundingBox: ImageBoundingBox
+ ) {
+ this._bitmap = bitmapGRF;
+ this._width = imageWidth;
+ this._height = imageHeight;
+ this._bytesPerRow = bytesPerRow;
+ this._boundingBox = Object.freeze(boundingBox);
+ }
+
+ /** Create a copy of this bitmap. */
+ public copy(): BitmapGRF {
+ return new BitmapGRF(
+ this._bitmap.slice(0),
+ this._width,
+ this._height,
+ this._bytesPerRow,
+ this.boundingBox
+ )
+ }
+
+ /** Get the raw bitmap data for this image.
+ *
+ * The image may need to be offset according to the bounding box padding. Use the bytesPerRow for data length calculations.
+ *
+ * Example use:
+ * ```
+ * const grf = new BitmapGRF();
+ * const zplCmd = `^GFC,${grf.bytesUncompressed},${grf.bytesUncompressed},${grf.bytesPerRow},${grf.toBinaryGRF()}`;
+ *
+ * const eplCmd = `GW${grf.boundingBox.paddingLeft},${grf.boundingBox.paddingTop},${grf.bytesPerRow},${grf.height},${grf.toBitmapGRF}`;
+ * ```
+ */
+ public toBinaryGRF(): Uint8Array {
+ // Previous conversions have gotten the data into the right format. Send as-is.
+ return this._bitmap;
+ }
+
+ /** Gets an ImageData representation of this GRF. Can be used to draw into Canvas elements. */
+ public toImageData() {
+ const buffer = new Uint8ClampedArray(this._bitmap.length * 4 * 8);
+ for (let i = 0, n = this._bitmap.length; i < n; i++) {
+ // High bit to low bit (left to right) in the bitmap byte.
+ for (let offset = 7; offset >= 0; offset--) {
+ const outOffset = (i * 8 * 4) + ((7 - offset) * 4);
+ const pixel = ((this._bitmap[i] >> offset) & 1) === 1 ? 255 : 0;
+ buffer[outOffset + 0] = pixel;
+ buffer[outOffset + 1] = pixel;
+ buffer[outOffset + 2] = pixel;
+ buffer[outOffset + 3] = 255; // Always opaque alpha.
+ }
+ }
+
+ return new ImageData(buffer, this.width, this.height);
+ }
+
+ /** Gets a bitmap GRF that has its colors inverted.
+ *
+ * EPL uses 1 as white. ZPL uses 1 as black. Use this to convert between them.
+ */
+ public toInvertedGRF(): BitmapGRF {
+ const buffer = new Uint8Array(this._bitmap.length);
+ for (let i = 0, n = this._bitmap.length; i < n; i++) {
+ buffer[i] = ~this._bitmap[i];
+ }
+
+ return new BitmapGRF(
+ buffer,
+ this.width,
+ this.height,
+ this.bytesPerRow,
+ structuredClone(this.boundingBox)
+ );
+ }
+
+ /**
+ * Create a GRF bitmap from a raw RGBA array-like object.
+ */
+ public static fromRGBA(
+ data: Uint8Array | Uint8ClampedArray | Array,
+ width: number,
+ imageOptions?: ImageConversionOptions
+ ): BitmapGRF {
+ const {
+ grayThreshold = 70,
+ trimWhitespace = true,
+ ditheringMethod = DitheringMethod.none
+ } = imageOptions ?? {};
+
+ width = width | 0;
+ if (!width || width < 0) {
+ throw new BitmapFormatError('Image width must be provided for RGBA data.');
+ }
+ if (data.length % 4 !== 0) {
+ throw new BitmapFormatError(`Array data is not a multiple of 4, is it RGBA data?`);
+ }
+
+ const height = ~~(data.length / width / 4);
+
+ const { monochromeData, imageWidth, imageHeight, boundingBox } = this.toMonochrome(
+ data,
+ width,
+ height,
+ { grayThreshold, trimWhitespace, ditheringMethod }
+ );
+
+ const { grfData, bytesPerRow } = this.monochromeToGRF(
+ monochromeData,
+ imageWidth,
+ imageHeight
+ );
+
+ return new BitmapGRF(grfData, bytesPerRow * 8, imageHeight, bytesPerRow, boundingBox);
+ }
+
+ /**
+ * Create a GRF bitmap from a canvas ImageData object.
+ * @param imageData The canvas ImageData object to convert.
+ * @param grayThreshold The cutoff percentage below which values are considered black. Defaults to 75% of white.
+ * @param trimWhitespace Trim image to save space, storing trim amounts in the bounding box.
+ * @returns The bitmap GRF file.
+ */
+ public static fromCanvasImageData(
+ imageData: ImageData,
+ imageOptions?: ImageConversionOptions
+ ): BitmapGRF {
+ const {
+ grayThreshold = 70,
+ trimWhitespace = true,
+ ditheringMethod = DitheringMethod.none
+ } = imageOptions ?? {};
+ // This property isn't supported in Firefox, so it isn't supported
+ // in the lib types, and I don't feel like dealing with it right now
+ // so TODO: fix this eventually
+ //
+ // Only supports sRGB as RGBA data.
+ // if (imageData.colorSpace !== 'srgb') {
+ // throw new TranspileDocumentError(
+ // 'Unknown color space for given imageData. Expected srgb but got ' +
+ // imageData.colorSpace
+ // );
+ // }
+ //
+ // Proceed on assuming it's an RGBA bitmap of sRGB data.
+ return this.fromRGBA(imageData.data, imageData.width, {
+ grayThreshold,
+ trimWhitespace,
+ ditheringMethod
+ });
+ }
+
+ /** Use an SVG string as a bitmap image, rendered at the width and height provided. */
+ public static async fromSVG(
+ svg: string,
+ widthInDots: number,
+ heightInDots: number,
+ imageConversionOptions?: ImageConversionOptions
+ ) {
+ // Load in the SVG as a disconnected element to query its width and height.
+ // Doens't matter that this is a foreign document, it won't be re-used.
+ const tempcontainer = new Document().createElement('div');
+ tempcontainer.innerHTML = svg;
+ const svgElement = tempcontainer.firstChild as SVGSVGElement;
+ if (svgElement?.tagName?.toLowerCase() !== "svg") {
+ throw new BitmapFormatError(
+ `The top-level element of the SVG file must be an