Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert grayscale algorithm changes #37

Merged
merged 3 commits into from
Jan 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
<!-- Here's some actual demo code. Note that it is typescript, not javascript. -->
<script type="text/typescript" id="cool_demo_code_to_show_off_the_lib">
import {
BitmapGRF,
DarknessPercent,
IDocument,
ILabelDocumentBuilder,
Expand Down Expand Up @@ -408,19 +409,30 @@

const textarea = this.labelForm.querySelector('#labelFormText') as HTMLTextAreaElement;
const canvas = this.labelForm.querySelector("#labelCanvas") as HTMLCanvasElement;
const c = canvas.getContext('2d');
const pageContext = canvas.getContext('2d');
pageContext.clearRect(0, 0, canvas.width, canvas.height);

// We're doing this the quick and dirty way, clearing the rectangle each time and
// redrawing all of the text. A more complex app could do this more gracefully!
c.clearRect(0, 0, canvas.width, canvas.height);
c.font = `30pt ${this.fontName}`;
c.imageSmoothingEnabled = false;
c.fillStyle = "#000000";
// We'd like the preview image to be as close as possible to what the printer will
// actually print. To do this we don't draw text to the page's canvas directly,
// instead we draw to an offscreen canvas, render that canvas to a monochrome bitmap,
// then render *that* back to the page's canvas. This is a more accurate picture of
// what the label will end up looking like.

// A more complex app could do this more gracefully!
const offscreenContext = new OffscreenCanvas(canvas.width, canvas.height).getContext('2d');
offscreenContext.font = `30pt ${this.fontName}`;
offscreenContext.fillStyle = "#000000";

// fillText doesn't do newlines, do that manually
textarea.value.split('\n').forEach((l, i) => {
c.fillText(l, 5, (i * 31) + 30);
offscreenContext.fillText(l, 5, (i * 31) + 30);
})

// Now into the monochrome bitmap.
const offscreenImg = offscreenContext.getImageData(0, 0, canvas.width, canvas.height);
const monoImg = BitmapGRF.fromCanvasImageData(offscreenImg, { trimWhitespace: false });
// And finally back onto the page.
pageContext.putImageData(monoImg.toImageData(), 0, 0);
}

/** Render the canvas to a document for printing */
Expand Down
77 changes: 59 additions & 18 deletions src/Documents/BitmapGRF.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable prettier/prettier */
import { Percent } from '../NumericRange.js';
import { WebZlpError } from '../WebZlpError.js';

Expand Down Expand Up @@ -97,6 +98,24 @@ export class BitmapGRF {
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.
Expand Down Expand Up @@ -189,7 +208,7 @@ export class BitmapGRF {
imageOptions?: ImageConversionOptions
): BitmapGRF {
const {
grayThreshold = 90,
grayThreshold = 70,
trimWhitespace = true,
ditheringMethod = DitheringMethod.none
} = imageOptions ?? {};
Expand Down Expand Up @@ -217,7 +236,7 @@ export class BitmapGRF {
imageHeight
);

return new BitmapGRF(grfData, imageWidth, imageHeight, bytesPerRow, boundingBox);
return new BitmapGRF(grfData, bytesPerRow * 8, imageHeight, bytesPerRow, boundingBox);
}

/**
Expand All @@ -232,7 +251,7 @@ export class BitmapGRF {
imageOptions?: ImageConversionOptions
): BitmapGRF {
const {
grayThreshold = 90,
grayThreshold = 70,
trimWhitespace = true,
ditheringMethod = DitheringMethod.none
} = imageOptions ?? {};
Expand Down Expand Up @@ -322,11 +341,12 @@ export class BitmapGRF {
y = 0;
for (let i = 0, n = width * height * 4; i < n; i += 4) {
// Alpha blend with white.
const a = rgba[i + 3] / 255;
const r = rgba[i] * 0.3 * a + 255 * (1 - a);
const g = rgba[i + 1] * 0.59 * a + 255 * (1 - a);
const b = rgba[i + 2] * 0.11 * a + 255 * (1 - a);
const gray = r + g + b;
const gray = this.colorToGrayscale(
rgba[i + 0],
rgba[i + 1],
rgba[i + 2],
rgba[i + 3]
);

if (gray <= threshold) {
if (minx > x) minx = x;
Expand All @@ -350,13 +370,14 @@ export class BitmapGRF {
let i = (y * width + minx) * 4;
for (let x = minx; x <= maxx; x++) {
// Alpha blend with white.
const a = rgba[i + 3] / 255.0;
const r = rgba[i] * 0.3 * a + 255.0 * (1 - a);
const g = rgba[i + 1] * 0.59 * a + 255.0 * (1 - a);
const b = rgba[i + 2] * 0.11 * a + 255.0 * (1 - a);
const gray = r + g + b;

buffer[idx++] = gray <= threshold ? 0 : 1;
const gray = this.colorToGrayscale(
rgba[i + 0],
rgba[i + 1],
rgba[i + 2],
rgba[i + 3]
);

buffer[idx++] = gray >= threshold ? 1 : 0;
i += 4;
}
}
Expand All @@ -370,13 +391,33 @@ export class BitmapGRF {
height: height,
paddingLeft: minx,
paddingTop: miny,
// TODO: Are these correct offesets or does it need + 1?
paddingRight: width - maxx,
paddingBottom: height - maxy
paddingRight: width - (this.roundUpToByte(cx) + minx),
paddingBottom: height - (cy + miny)
}
};
}

private static roundUpToByte(value: number) {
return Math.ceil(value / 8) * 8;
}

private static colorToGrayscale(
r: number,
g: number,
b: number,
a: number,
backgroundGray?: number
): number {
backgroundGray = backgroundGray ?? 255.0;
const alpha = a / 255.0;
// Values from the Color FAQ: https://poynton.ca/notes/colour_and_gamma/ColorFAQ.html
const red = Math.pow(r / 255.0, 2.2) * 0.2126;
const blu = Math.pow(g / 255.0, 2.2) * 0.7152;
const grn = Math.pow(b / 255.0, 2.2) * 0.0722;
const gray = Math.pow(red + blu + grn, 0.454545) * 255;
return ((1 - alpha) * backgroundGray) + (alpha * gray);
}

/** Lookup table for binary to hex values. */
private static hexmap = (() => {
const arr = Array(256);
Expand Down
130 changes: 128 additions & 2 deletions test/Documents/BitmapGRF.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class ImageData {
}
}

global.ImageData = ImageData;

function getImageDataInput(width: number, height: number, fill: number, alpha?: number) {
const arr = new Uint8ClampedArray(width * height * 4);
if (alpha != null && alpha != fill) {
Expand All @@ -85,13 +87,28 @@ function getImageDataInput(width: number, height: number, fill: number, alpha?:
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;
}

const imageConversionOptions: ImageConversionOptions = {
ditheringMethod: DitheringMethod.none,
grayThreshold: 99,
grayThreshold: 70,
trimWhitespace: false
};

describe('rgbaImageConversion', () => {
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]);
Expand Down Expand Up @@ -155,6 +172,27 @@ describe('rgbaImageConversion', () => {
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, boundingBox } = 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]);
Expand Down Expand Up @@ -220,3 +258,91 @@ describe('rgbaImageConversion', () => {
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.
/* eslint-disable prettier/prettier */
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);
/* eslint-enable prettier/prettier */
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 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);
});
});