toolshed/frontend/vendor/weblabel.mjs
2026-08-17 12:10:42 +02:00

958 lines
32 KiB
JavaScript

/*
libweblabel — generated bundle, do not edit.
Built by tools/bundle.py from src/web/index.js and the modules it re-exports:
src/web/lib/blob.js
src/web/lib/bitmap.js
src/web/lib/text.js
src/web/lib/pattern.js
src/web/lib/ruler.js
Edit those and rebuild. The wasm driver blobs are separate files loaded at
runtime; see dist/blobs/.
*/
/* ---- src/web/lib/blob.js ------------------------------------------------- */
/*
Loader for USB printer blobs (ABI v1).
Deliberately knows nothing about P-touch printers, Brother, or libptouch.
It loads a wasm blob, reads the device table out of the blob's manifest and
calls the six pw_* entry points. A blob for a different vendor's printers,
built against the same shim and exporting the same ABI, is driven by this
file unchanged - and so is a future version of this driver that supports
more printers, because the device table travels in the manifest rather than
being duplicated here.
Two classes, and the difference is smaller than it looks:
PrinterBlob any blob, holding one driver or many.
MultiPrinterBlob the same, seen as the set of drivers inside it: which
one owns a given printer, what that one alone can do,
and which of its exports to call. A merged blob
(dist/blobs/libweblabel.mjs) is what makes those
questions interesting; PrinterBlob drives it either
way.
The ABI is described in docs/ABI.md.
*/
/* ------------------------------------------------------------------------
WebUSB backend
The JS half of the shim's contract. Also vendor-neutral: it moves bytes and
has no idea what they mean.
------------------------------------------------------------------------ */
export class WebUsbBackend {
constructor({ transferTimeoutMs = 5000 } = {}) {
this.transferTimeoutMs = transferTimeoutMs;
this.devices = []; /* the devices the blob is allowed to see */
this.handles = new Map();
this.nextHandle = 1;
}
/* Scope the blob to specific devices. Drivers typically open the first
match in the list, so passing exactly the device the user picked in the
chooser is what makes the selection stick. */
setDevices(devices) {
this.devices = devices;
}
async list() {
return this.devices.map((d, index) => ({
vendorId: d.vendorId,
productId: d.productId,
busNumber: 0, /* WebUSB does not expose bus topology */
deviceAddress: index,
}));
}
async open(index) {
const device = this.devices[index];
if (!device) {
return -1;
}
try {
if (!device.opened) {
await device.open();
}
if (device.configuration === null) {
await device.selectConfiguration(1);
}
} catch (e) {
this.lastError = e;
return -1;
}
const handle = this.nextHandle++;
this.handles.set(handle, device);
return handle;
}
async close(handle) {
const device = this.handles.get(handle);
this.handles.delete(handle);
if (device && device.opened) {
try {
await device.close();
} catch { /* the device may already be gone */ }
}
}
async claim(handle, interfaceNumber) {
const device = this.handles.get(handle);
if (!device) return -1;
try {
await device.claimInterface(interfaceNumber);
return 0;
} catch (e) {
/* A driver only learns that the claim failed, not why. WebUSB's
own message is the useful part, and on Linux the overwhelmingly
likely cause is a bound kernel driver - usblp binds anything of
printer class - which the browser cannot detach. */
let detail = `claimInterface(${interfaceNumber}) failed: ${e.message}`;
if (typeof navigator !== "undefined" && /Linux/.test(navigator.userAgent || "")) {
detail += ". On Linux this usually means a kernel driver holds the " +
"interface (usblp claims printer-class devices) and the browser " +
"cannot detach it. Unbind it: " +
"echo -n <n-m:1.0> | sudo tee /sys/bus/usb/drivers/usblp/unbind " +
"— see drivers/ptouch/README.md for the persistent udev rule";
}
this.lastError = new Error(detail);
return -1;
}
}
/* Hand the most recent underlying failure to the caller, once. */
takeLastError() {
const e = this.lastError;
this.lastError = null;
return e ? e.message : null;
}
async release(handle, interfaceNumber) {
const device = this.handles.get(handle);
if (!device) return -1;
try {
await device.releaseInterface(interfaceNumber);
return 0;
} catch (e) {
this.lastError = e;
return -1;
}
}
async transferOut(handle, endpoint, bytes) {
const device = this.handles.get(handle);
if (!device) return -1;
try {
const result = await device.transferOut(endpoint, bytes);
if (result.status !== "ok") {
this.lastError = new Error(`transferOut: ${result.status}`);
return -1;
}
return result.bytesWritten;
} catch (e) {
this.lastError = e;
return -1;
}
}
async transferIn(handle, endpoint, length) {
const device = this.handles.get(handle);
if (!device) return -1;
/* WebUSB has no per-transfer timeout and a pending transferIn cannot be
cancelled, so race it against a timer. -7 is LIBUSB_ERROR_TIMEOUT to
the shim, which is what the driver's own timeout handling expects. */
const TIMEOUT = Symbol("timeout");
let timer;
try {
const result = await Promise.race([
device.transferIn(endpoint, length),
new Promise((resolve) => {
timer = setTimeout(() => resolve(TIMEOUT), this.transferTimeoutMs);
}),
]);
if (result === TIMEOUT) {
/* The abandoned transfer would otherwise deliver its data into
the next read. Clearing the endpoint discards it. */
try { await device.clearHalt("in", endpoint); } catch { /* best effort */ }
this.lastError = new Error(`transferIn timed out after ${this.transferTimeoutMs} ms`);
return -7;
}
if (result.status !== "ok") {
this.lastError = new Error(`transferIn: ${result.status}`);
return -1;
}
return new Uint8Array(result.data.buffer, result.data.byteOffset, result.data.byteLength);
} catch (e) {
this.lastError = e;
return -1;
} finally {
clearTimeout(timer);
}
}
}
/* ------------------------------------------------------------------------
Blob
------------------------------------------------------------------------ */
const MAX_LOG = 500;
export class PrinterBlob {
constructor(module, backend) {
this._module = module;
this._backend = backend;
this._queue = Promise.resolve();
this.log = [];
this.manifest = null;
this.abiVersion = 0;
this.openDevice = null; /* {vendorId, productId} while open */
}
/*
url location of the blob's .mjs glue, resolved against baseUrl
backend anything implementing the backend contract; defaults to WebUSB
baseUrl base for resolving url (defaults to the document / this module)
*/
static async load(url, { backend, baseUrl } = {}) {
const base = baseUrl
|| (typeof document !== "undefined" ? document.baseURI : import.meta.url);
const href = new URL(url, base).href;
const usbBackend = backend || new WebUsbBackend();
/* `new this`, so a subclass reaching this through super.load() gets an
instance of itself. */
const blob = new this(null, usbBackend);
const factory = (await import(/* @vite-ignore */ href)).default;
blob._module = await factory({
usbBackend,
print: (text) => blob._record("out", text),
printErr: (text) => blob._record("err", text),
});
blob.abiVersion = await blob._call("pw_abi_version", "number", [], []);
if (blob.abiVersion !== 1) {
throw new Error(`unsupported blob ABI version ${blob.abiVersion}, expected 1`);
}
const manifest = await blob._call("pw_manifest", "string", [], []);
blob.manifest = JSON.parse(manifest);
return blob;
}
_record(stream, text) {
this.log.push({ stream, text });
if (this.log.length > MAX_LOG) {
this.log.shift();
}
}
/* Asyncify unwinds a single wasm stack at a time, so every export call is
serialized. Without this, two overlapping calls corrupt each other. */
_call(name, returnType, argTypes, args) {
const run = () => this._module.ccall(name, returnType, argTypes, args, { async: true });
const result = this._queue.then(run, run);
this._queue = result.then(() => undefined, () => undefined);
return result;
}
/*
Call an export by name, through the same queue everything else uses.
The methods below cover the ABI; this is for the exports they do not,
which is how a caller uses the per-driver symbols that
MultiPrinterBlob.symbolsFor() names. Going around it and calling
Module.ccall() directly is what re-entering a suspended blob looks like.
*/
callExport(name, returnType = "number", argTypes = [], args = []) {
return this._call(name, returnType, argTypes, args);
}
/* The driver's own explanation, plus whatever the backend knows about the
host-level cause. The driver only sees "the claim failed"; the backend
is the layer that saw the actual exception. */
async _lastError() {
const fromDriver = await this._call("pw_last_error", "string", [], []);
const fromBackend = typeof this._backend.takeLastError === "function"
? this._backend.takeLastError() : null;
return [fromDriver, fromBackend].filter(Boolean).join(" — ") || "unknown error";
}
/*
Device filters for navigator.usb.requestDevice(), straight from the
blob. Deduplicated by USB id: a merged blob's table is several drivers'
tables at once, and two of them may know the same printer.
*/
usbFilters() {
const seen = new Set();
const filters = [];
for (const d of this.manifest.devices) {
const key = `${d.vendorId}:${d.productId}`;
if (!seen.has(key)) {
seen.add(key);
filters.push({ vendorId: d.vendorId, productId: d.productId });
}
}
return filters;
}
/** What the blob knows about these USB ids, or null if it does not know them. */
findDevice(vendorId, productId) {
return this.manifest.devices.find(
(d) => d.vendorId === vendorId && d.productId === productId) || null;
}
/** Restrict the blob's view of the bus to these WebUSB devices. */
setDevices(devices) {
this._backend.setDevices(devices);
}
async open(vendorId, productId, { timeoutSeconds = 1 } = {}) {
const rc = await this._call("pw_open", "number",
["number", "number", "number"], [vendorId, productId, timeoutSeconds]);
if (rc !== 0) {
throw new Error(await this._lastError());
}
this.openDevice = { vendorId, productId };
}
async status({ timeoutSeconds = 1 } = {}) {
const json = await this._call("pw_status_json", "string",
["number"], [timeoutSeconds]);
const parsed = JSON.parse(json);
if (parsed === null) {
throw new Error(await this._lastError());
}
return parsed;
}
async close() {
await this._call("pw_close", "number", [], []);
this.openDevice = null;
}
/** Does the blob advertise this capability? */
can(capability) {
return (this.manifest.capabilities || []).includes(capability);
}
/*
Print a bitmap: one byte per pixel, row-major, non-zero = a printed dot.
width runs along the tape, height across it. The caller is responsible
for keeping height within the print area the status reports.
*/
async printBitmap({ data, width, height },
{ chain = false, precut = false, copies = 1 } = {}) {
if (!this.can("print")) {
throw new Error("this blob does not support printing");
}
if (data.length !== width * height) {
throw new Error(`bitmap is ${data.length} bytes, expected ${width * height}`);
}
/* Staged into the wasm heap so the blob can read it directly. Freed
even if the print throws, and after the call has fully finished -
the pointer is live for the whole suspended-stack duration. */
const ptr = this._module._malloc(data.length);
if (!ptr) {
throw new Error(`could not allocate ${data.length} bytes in the blob`);
}
try {
this._module.HEAPU8.set(data, ptr);
const rc = await this._call("pw_print", "number",
["number", "number", "number", "number", "number", "number"],
[ptr, width, height, chain ? 1 : 0, precut ? 1 : 0, copies]);
if (rc !== 0) {
throw new Error(await this._lastError());
}
} finally {
this._module._free(ptr);
}
}
}
/* ------------------------------------------------------------------------
Blobs seen as a set of drivers
dist/blobs/libweblabel.mjs is every driver this build produced, in one
module. It forwards the plain ABI to whichever driver owns the printer that
was opened, so PrinterBlob drives it without knowing that.
What this class covers is the part only the driver set can answer: which
driver a given printer belongs to, what that driver alone can do, and which
of its exports to call for it. A blob holding one driver is a set of one, so
this works on those too and a page never has to choose between the two
classes. See "Merged blobs" in docs/ABI.md.
------------------------------------------------------------------------ */
/* The ABI, as the JavaScript name for each export and the bare C name it is
built from. A merged blob exports every one of these twice: once plain, and
once per driver as pw_<driver>_<name>. */
const ABI_EXPORTS = {
abiVersion: "abi_version",
manifest: "manifest",
devices: "devices",
capabilities: "capabilities",
supports: "supports",
open: "open",
close: "close",
statusJson: "status_json",
print: "print",
lastError: "last_error",
};
export class MultiPrinterBlob extends PrinterBlob {
/*
Does this module hold several drivers?
The merged blob reports them under "drivers" and reaches each one
through prefixed exports; a single-driver blob has only the plain ones.
It is the one thing that changes the answers below.
*/
get merged() {
return Array.isArray(this.manifest.drivers);
}
/* The drivers inside this blob, each one's own manifest verbatim. A
single-driver blob's manifest has that shape already, so it is a set of
one rather than a special case. */
get drivers() {
return this.manifest.drivers || [this.manifest];
}
/** Their names, in the order the build linked them. */
driverNames() {
return this.drivers.map((d) => d.driver.name);
}
/** The manifest of the driver with this name, or null. */
driver(name) {
return this.drivers.find((d) => d.driver.name === name) || null;
}
/*
The driver that owns these USB ids, or null if none does.
Answered from the merged device table, where every entry names the
driver it came from. pw_driver_for() inside the blob answers the same
question from the same tables; this is the cheap synchronous way to ask
it.
*/
driverFor(vendorId, productId) {
const device = this.findDevice(vendorId, productId);
return device ? this.driver(device.driver) : null;
}
/*
Which exports to call for this printer.
const { driver, exports } = blob.symbolsFor(0x04f9, 0x2074);
await blob.callExport(exports.open, "number",
["number", "number", "number"], [0x04f9, 0x2074, 1]);
Going through the plain ABI - blob.open(), blob.status(), and the rest
- does the same dispatch inside the blob and is what most callers want.
This is for a caller that has a reason to address one driver directly:
reading a second driver's manifest while a printer is open, say, or
driving two of them without letting either one's dispatch state decide
which is current.
On a single-driver blob the prefix is empty and these are the plain ABI
names, which is the truth there: the one driver is the dispatch.
Returns null when no driver in this blob knows the device.
*/
symbolsFor(vendorId, productId) {
const driver = this.driverFor(vendorId, productId);
if (!driver) {
return null;
}
const name = driver.driver.name;
const prefix = this.merged ? `pw_${name}_` : "pw_";
const exports = {};
for (const [key, bare] of Object.entries(ABI_EXPORTS)) {
exports[key] = prefix + bare;
}
return { driver: name, prefix, exports, capabilities: driver.capabilities };
}
/*
Does a capability apply?
With no device, the answer is the merged one: some driver in this blob
can do it. With a device - or with one open - it is that device's
driver alone, which is the honest answer for a control that is about to
act on that printer. "chain" is the case that matters: the P-touch
driver honours it and the QL driver does not.
*/
can(capability, device = this.openDevice) {
if (!device) {
return super.can(capability);
}
const driver = this.driverFor(device.vendorId, device.productId);
return Boolean(driver && (driver.capabilities || []).includes(capability));
}
}
/* ---- src/web/lib/bitmap.js ----------------------------------------------- */
/*
Canvas ⇄ the one-byte-per-pixel bitmap the blob ABI takes.
A printhead has one state per dot: a pin either fires or it does not. Canvas
drawing is anti-aliased, so everything drawn there is greyscale until it is
thresholded here. Nothing in this file knows what is being printed.
*/
/* Canvas RGBA to the bitmap: one byte per pixel, row-major, non-zero = a dot. */
export function canvasToBitmap(canvas) {
const { width, height } = canvas;
const rgba = canvas.getContext("2d", { willReadFrequently: true })
.getImageData(0, 0, width, height).data;
const data = new Uint8Array(width * height);
for (let i = 0, p = 0; p < data.length; i += 4, ++p) {
/* Rounded, because the coefficients do not sum to exactly 1 in binary
floating point: without this, a pixel at exactly mid grey flips on
rounding noise instead of landing consistently on one side. */
const luminance = Math.round(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]);
/* Transparent counts as blank; anything darker than mid grey prints. */
data[p] = (rgba[i + 3] > 127 && luminance < 128) ? 1 : 0;
}
return { data, width, height };
}
/*
Paint a bitmap back onto its canvas, so the preview shows exactly the dots
that will be printed, jaggies and all. canvasToBitmap() is idempotent over
this: re-reading the canvas afterwards yields the same bytes.
*/
export function bitmapToCanvas(canvas, bitmap) {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const img = ctx.createImageData(bitmap.width, bitmap.height);
for (let p = 0, i = 0; p < bitmap.data.length; ++p, i += 4) {
const value = bitmap.data[p] ? 0 : 255;
img.data[i] = img.data[i + 1] = img.data[i + 2] = value;
img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
/** How many dots the printhead will fire for this bitmap. */
export function countDots(bitmap) {
let dots = 0;
for (const b of bitmap.data) {
dots += b;
}
return dots;
}
/* ---- src/web/lib/text.js ------------------------------------------------- */
/*
Text laid out the way `ptouch-print --text` does it.
A port of render_text() and its helpers from ptouch-print.c, which cannot be
linked into a blob: that file is libgd and argp all the way down. The CLI
picks a font size that makes the tallest line fill its share of the tape,
builds an image exactly as wide as the widest line, and distributes the lines
down the print area. Everything below mirrors that, including the integer
arithmetic, so the same input yields the same layout.
Verified against the real tool: for the cases in the test suite this picks
the same point size and produces the same label width as
`ptouch-print --force-tape-width N --text ... --write-png`.
One deliberate difference: gd is called through gdImageStringFT_180dpi(),
which hardcodes 180 dpi, so the CLI renders text at half the intended
physical size on a 360 dpi model. Here the printer's real dpi is used.
gd's brect maps onto canvas TextMetrics like this:
brect[1] descent below the baseline actualBoundingBoxDescent
brect[5] -ascent above the baseline -actualBoundingBoxAscent
brect[0] left edge of the ink -actualBoundingBoxLeft
brect[2] right edge of the ink actualBoundingBoxRight
so height = brect[1]-brect[5], needed_width = brect[2]-brect[0] and
offset_x = -brect[0] all carry over directly.
*/
export const MAX_LINES = 4; /* as in ptouch-print.c */
/*
The threshold is 50%, matching what the CLI does. ptouch-print.c renders with
a *negative* colour (`-black`), which tells libgd to disable anti-aliasing
and use FreeType's monochrome rasterizer - verified: the same string rendered
with `black` produces 9 distinct palette entries, with `-black` exactly 2. So
the CLI's text is natively 1-bit and its coverage rule is "is the pixel centre
inside the outline", which 50% approximates.
The one thing that cannot be reproduced is FreeType's dropout control, which
deliberately keeps hairlines that would otherwise fall between pixel centres.
Lowering TEXT_COVERAGE thickens stems if that ever bites.
What stops small text turning to mush is not the threshold but the size: the
text is scaled to the print area rather than to a fixed pixel size, so stems
are several dots wide. fillText()'s maxWidth argument is never used - it
condenses glyphs horizontally to fit, which destroys legibility far faster
than omitting the element does.
*/
const TEXT_COVERAGE = 0.5;
/* DejaVu Sans is the CLI's default font, so labels come out looking the same
either way. */
const TEXT_FONT_STACK = '"DejaVu Sans", "Liberation Sans", Arial, Helvetica, sans-serif';
const TEXT_WEIGHT = "normal";
/* Verbatim from find_fontsize(): measuring against a fixed set of ascenders and
descenders keeps every line the same height whatever it contains. */
const COMMON_CHARS = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnoprqstuvwxyz{|}~";
let measureCtx = null;
const textFont = (size) => `${TEXT_WEIGHT} ${size}px ${TEXT_FONT_STACK}`;
/* Ink box rather than advance width, so text can be packed tightly. Already
rounded to integers, which is what FreeType reports, so the arithmetic below
stays integer as it is in C. */
function ftMetrics(text, fontPx) {
if (!measureCtx) {
measureCtx = document.createElement("canvas").getContext("2d");
}
measureCtx.font = textFont(fontPx);
const m = measureCtx.measureText(text);
const left = Math.ceil(m.actualBoundingBoxLeft ?? 0);
const right = Math.ceil(m.actualBoundingBoxRight ?? m.width);
const ascent = Math.ceil(m.actualBoundingBoxAscent ?? fontPx * 0.8);
const descent = Math.ceil(m.actualBoundingBoxDescent ?? fontPx * 0.2);
return { left, width: left + right, ascent, descent, height: ascent + descent };
}
const ptToPx = (pt, dpi) => pt * dpi / 72;
/* find_fontsize(): the largest whole point size whose line height still fits. */
function findFontSizePt(wantPx, text, dpi) {
const combined = text + COMMON_CHARS;
let save = 0;
for (let pt = 4; pt <= 500; ++pt) {
if (ftMetrics(combined, ptToPx(pt, dpi)).height <= wantPx) {
save = pt;
} else {
break;
}
}
return save === 0 ? -1 : save;
}
/* get_baselineoffset(): how much further below the baseline this text reaches
than a letter that sits on it. 'z' is the CLI's reference glyph. */
function baselineOffset(text, fontPx) {
return ftMetrics(text, fontPx).descent - ftMetrics("z", fontPx).descent;
}
/* The CLI's --text splits on a literal \n as well as a real newline. */
export function parseTextLines(input) {
return input.split(/\\n|\n/).slice(0, MAX_LINES);
}
/* render_text(): the placement of every line, or a throw carrying the same
message the CLI prints. printWidth is the print area of the mounted tape. */
export function layoutTextLabel(lines, printWidth,
{ align = "l", fontSizePt = 0, fontMargin = 0, dpi }) {
let fsz = fontSizePt;
if (fsz <= 0) {
const wantPx = Math.floor((printWidth - 2 * fontMargin) / lines.length);
for (const line of lines) {
const candidate = findFontSizePt(wantPx, line, dpi);
if (candidate < 0) {
throw new Error("could not estimate needed font size");
}
if (fsz === 0 || candidate < fsz) {
fsz = candidate;
}
}
}
const fontPx = ptToPx(fsz, dpi);
const metrics = lines.map((line) => ftMetrics(line, fontPx));
const width = Math.max(...metrics.map((m) => m.width));
const maxHeight = Math.max(...metrics.map((m) => m.height));
if (maxHeight * lines.length > printWidth) {
throw new Error(`Font size ${fsz} too large for ${lines.length} lines`);
}
const unusedPx = printWidth - maxHeight * lines.length;
const placements = lines.map((line, i) => {
const ofs = baselineOffset(line, fontPx);
let pos = i * Math.floor(printWidth / lines.length) + maxHeight - ofs;
pos += Math.floor(Math.floor(unusedPx / lines.length) / 2);
let alignOfs = 0;
if (align === "c") {
alignOfs = Math.floor((width - metrics[i].width) / 2);
} else if (align === "r") {
alignOfs = width - metrics[i].width;
}
return { text: line, x: metrics[i].left + alignOfs, baseline: pos };
});
return { fontSizePt: fsz, fontPx, width, maxHeight, placements };
}
/*
Render the whole text block into one buffer and threshold it in a single
pass, the way the CLI fills one gd image. The result is already 1-bit, so
whoever composes the label can blit it 1:1 and keep it that way.
*/
export function renderTextLabel(lines, printWidth, opts) {
const layout = layoutTextLabel(lines, printWidth, opts);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, layout.width);
canvas.height = printWidth;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = textFont(layout.fontPx);
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#000";
for (const p of layout.placements) {
ctx.fillText(p.text, p.x, p.baseline);
}
/* Opaque white background, so coverage is luminance rather than alpha. */
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const cutoff = Math.round((1 - TEXT_COVERAGE) * 255);
for (let i = 0; i < img.data.length; i += 4) {
const luminance = Math.round(0.299 * img.data[i] + 0.587 * img.data[i + 1]
+ 0.114 * img.data[i + 2]);
const value = luminance <= cutoff ? 0 : 255;
img.data[i] = img.data[i + 1] = img.data[i + 2] = value;
img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return { canvas, layout };
}
/* ---- src/web/lib/pattern.js ---------------------------------------------- */
/*
A test pattern for a thermal printhead.
Everything is laid out left to right and simply omitted when the tape runs
out. Nothing is ever scaled down to fit - a squeezed element is a misleading
test.
*/
/*
ctx the label canvas, one pixel per printhead pin
w, h the area to fill; h is the print area of the mounted tape
textBlock an already-thresholded canvas to blit at the left, or null
*/
export function drawTestPattern(ctx, w, h, textBlock) {
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "#000";
ctx.strokeStyle = "#000";
ctx.lineWidth = 1;
/* Crisp 1px strokes need half-pixel coordinates. */
const line = (x1, y1, x2, y2) => {
ctx.beginPath();
ctx.moveTo(x1 + 0.5, y1 + 0.5);
ctx.lineTo(x2 + 0.5, y2 + 0.5);
ctx.stroke();
};
/* A frame on the outermost pins: if the print comes out clipped or off
centre, this is the thing that shows it. */
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
const gap = 6;
const margin = 5;
let x = margin;
const room = (need) => x + need <= w - margin;
/* The text block comes first, rendered by the same --text layout used in
text-only mode, so the pattern shows real label text next to the geometry
rather than a second, differently-produced caption. Already 1-bit, so a
1:1 blit is all that is needed. */
if (textBlock && room(textBlock.width)) {
ctx.drawImage(textBlock, x, 0);
x += textBlock.width + gap;
}
/* Vertical bars 1..4 px wide - horizontal (along-tape) resolution. */
if (room(4 + 3 * 3 + 4)) {
for (let bar = 1; bar <= 4; ++bar) {
ctx.fillRect(x, 4, bar, h - 8);
x += bar + 3;
}
x += gap - 3;
}
/* Horizontal rules at top, middle and bottom - vertical alignment. */
const rules = 22;
if (room(rules)) {
line(x, 4, x + rules, 4);
line(x, (h >> 1), x + rules, (h >> 1));
line(x, h - 5, x + rules, h - 5);
x += rules + gap;
}
/* Solid block, then a hollow one. */
const box = Math.max(6, Math.min(24, h - 16));
const boxTop = (h - box) >> 1;
if (room(box)) {
ctx.fillRect(x, boxTop, box, box);
x += box + gap;
}
if (room(box)) {
ctx.strokeRect(x + 0.5, boxTop + 0.5, box - 1, box - 1);
x += box + gap;
}
/* Circle - diagonal edges and anti-aliasing, which thresholding must cope
with. */
const r = Math.max(3, Math.min(box, h - 12) / 2);
if (room(2 * r)) {
ctx.beginPath();
ctx.arc(x + r, h / 2, r, 0, Math.PI * 2);
ctx.stroke();
x += 2 * r + gap;
}
/* An X. */
const d = Math.min(24, h - 12);
const top = (h - d) >> 1;
if (room(d)) {
line(x, top, x + d, top + d);
line(x, top + d, x + d, top);
x += d + gap;
}
/* 2px checkerboard. */
const check = Math.min(24, h - 12);
if (room(check)) {
for (let cy = 0; cy < check; cy += 2) {
for (let cx = 0; cx < check; cx += 2) {
if (((cx + cy) / 2) % 2 === 0) {
ctx.fillRect(x + cx, top + cy, 2, 2);
}
}
}
x += check + gap;
}
}
/* ---- src/web/lib/ruler.js ------------------------------------------------ */
/*
Millimetre rulers for the label preview.
They live on their own canvases, deliberately not on the label canvas: that
one is the print bitmap, so anything drawn there would come out on the tape.
One bitmap pixel is one printhead pin, so millimetres follow from the
printer's own dpi: 1 mm = dpi/25.4 pixels, which is 7.09 px at 180 dpi and
14.17 px at 360 dpi.
*/
export const RULER_H = 20; /* height of the along-tape ruler, CSS px */
export const RULER_W = 26; /* width of the across-tape ruler, CSS px */
/* Tick spacing in mm, coarsened as the preview shrinks: minor ticks need room
to stay distinguishable, numbered ticks need room not to collide. */
function tickSteps(screenPxPerMm) {
const candidates = [[1, 5], [2, 10], [5, 25], [10, 50], [20, 100], [50, 250]];
for (const [minor, major] of candidates) {
if (minor * screenPxPerMm >= 4 && major * screenPxPerMm >= 26) {
return { minor, major };
}
}
return { minor: 100, major: 500 };
}
/*
canvas the ruler's own canvas
lengthPx the ruled span in bitmap pixels
axis "x" along the tape (below the label), "y" across it (beside it)
zoom screen pixels per bitmap pixel
dpi the printer's resolution, which is what makes a millimetre a
millimetre
*/
export function drawRuler(canvas, { lengthPx, axis, zoom, dpi }) {
const dpr = window.devicePixelRatio || 1;
const pxPerMm = dpi / 25.4;
const span = lengthPx * zoom; /* CSS px along the ruled axis */
const horizontal = axis === "x";
const cssW = horizontal ? span : RULER_W;
const cssH = horizontal ? RULER_H : span;
/* Backed at device resolution so the tick labels stay sharp; the label
canvas next to it stays 1:1 because it is pixel data, not a drawing. */
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const ink = getComputedStyle(document.body).color;
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
ctx.globalAlpha = 0.65;
ctx.lineWidth = 1;
ctx.font = "9px system-ui, sans-serif";
/* The rule itself, along the edge shared with the preview. */
ctx.beginPath();
if (horizontal) {
ctx.moveTo(0, 0.5);
ctx.lineTo(span, 0.5);
} else {
ctx.moveTo(0.5, 0);
ctx.lineTo(0.5, span);
}
ctx.stroke();
const totalMm = lengthPx / pxPerMm;
const step = tickSteps(pxPerMm * zoom);
for (let mm = 0; mm <= totalMm; mm += step.minor) {
const at = Math.round(mm * pxPerMm * zoom) + 0.5;
const major = mm % step.major === 0;
const tick = major ? 8 : 4;
ctx.globalAlpha = major ? 0.75 : 0.45;
ctx.beginPath();
if (horizontal) {
ctx.moveTo(at, 0);
ctx.lineTo(at, tick);
} else {
ctx.moveTo(0, at);
ctx.lineTo(tick, at);
}
ctx.stroke();
if (!major) {
continue;
}
ctx.globalAlpha = 0.75;
const text = String(mm);
if (horizontal) {
/* Skip a number that would run past the end of the tape. */
if (at + ctx.measureText(text).width + 2 > span) {
continue;
}
ctx.textBaseline = "top";
ctx.fillText(text, at + 2, tick + 1);
} else {
if (at + 4 > span) {
continue;
}
ctx.textBaseline = "middle";
ctx.fillText(text, tick + 2, at + 4);
}
}
}