/* 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 | 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__. */ 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); } } }