stash
This commit is contained in:
parent
9803f23b8d
commit
25cef95711
4 changed files with 6 additions and 2 deletions
958
frontend/vendor/weblabel.js
vendored
Normal file
958
frontend/vendor/weblabel.js
vendored
Normal file
|
|
@ -0,0 +1,958 @@
|
|||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue