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