Part-DB-server/assets/controllers/pages/niimbot_print_controller.js
Jaime Laborda 218b394b13 Add optional direct thermal printing to Niimbot printers from the label generator
Adds a "Print to Niimbot" panel to the label generator dialog that sends the
generated label straight to a Niimbot thermal printer (e.g. B1) over Web
Bluetooth, without a PDF print dialog or printer driver.

The existing server-side DomPDF label is reused as-is: a new Stimulus controller
rasterizes the PDF preview page-by-page with pdf.js at the printer's native
resolution, converts it to a 1-bit bitmap and prints it via the niimbluelib
library. No changes to the PHP label pipeline are required.

The feature is disabled by default and gated behind the NIIMBOT_ENABLED
environment variable, since it is only useful for users who own such a printer.

- Add @mmote/niimbluelib and pdfjs-dist dependencies
- New assets/controllers/pages/niimbot_print_controller.js
- Print options (copies, density, label type, rotation, B/W threshold) in the
  label dialog, gracefully disabled when Web Bluetooth is unavailable
- NIIMBOT_ENABLED env flag (off by default), wired through parameters.yaml and a
  Twig global, documented in .env
- English translations and documentation

Requires a Chromium-based browser and a secure context (HTTPS or localhost).
2026-07-16 11:39:38 +02:00

272 lines
10 KiB
JavaScript

/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
import {NiimbotBluetoothClient, ImageEncoder, LabelType} from "@mmote/niimbluelib";
import * as pdfjsLib from "pdfjs-dist";
// Let webpack emit the pdf.js worker as a hashed asset and resolve its URL for us.
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
/**
* Prints the label PDF (rendered server side, shown in #pdf_preview) directly to a
* Niimbot thermal printer (e.g. B1) using the Web Bluetooth API via the niimbluelib library.
*
* The PDF is rasterized page by page with pdf.js at the printer's native resolution,
* converted to a 1-bit black/white bitmap and sent to the printer.
*
* Requires a secure context (HTTPS or localhost) and a Chromium based browser.
*/
export default class extends Controller {
static targets = ["button", "status", "density", "labelType", "rotation", "copies", "threshold"];
static values = {
// The default printer resolution used only as a fallback until the real model is known.
dpi: {type: Number, default: 203},
// Translated, user facing strings passed in from Twig (avoids depending on the JS translation catalog).
messages: Object,
};
connect() {
// Web Bluetooth is only available in a secure context on Chromium based browsers.
if (typeof navigator === "undefined" || !navigator.bluetooth) {
this._setStatus(this._msg("no_bluetooth"), "danger");
if (this.hasButtonTarget) {
this.buttonTarget.disabled = true;
}
}
}
async print(event) {
event.preventDefault();
if (typeof navigator === "undefined" || !navigator.bluetooth) {
this._setStatus(this._msg("no_bluetooth"), "danger");
return;
}
// Read the source PDF (data URI) the same way the download button does.
const preview = document.getElementById("pdf_preview");
if (!preview || !preview.data) {
this._setStatus(this._msg("no_label"), "danger");
return;
}
// Read all settings synchronously *before* the first await, so that the
// requestDevice() call inside client.connect() still runs within the user gesture.
const copies = this._intFromTarget("copies", 1, 1);
const rotation = ((this._intFromTarget("rotation", 0, 0) % 360) + 360) % 360;
const threshold = this._intFromTarget("threshold", 128, 128);
const labelType = this._intFromTarget("labelType", LabelType.WithGaps, LabelType.WithGaps);
const requestedDensity = this._intFromTarget("density", 0, 0);
const pdfBytes = this._dataUriToBytes(preview.data);
this._busy(true);
const client = new NiimbotBluetoothClient();
let connected = false;
let printTask = null;
try {
this._setStatus(this._msg("connecting"));
await client.connect();
connected = true;
const meta = client.getModelMetadata();
const dpi = (meta && meta.dpi) ? meta.dpi : this.dpiValue;
const printhead = meta ? meta.printheadPixels : null;
const modelName = meta ? meta.model : (client.getPrinterInfo().modelId ?? "?");
// Density: use requested value, otherwise the model default, clamped to the model range.
let density = requestedDensity > 0 ? requestedDensity : (meta ? meta.densityDefault : 3);
if (meta) {
density = Math.min(Math.max(density, meta.densityMin), meta.densityMax);
}
this._setStatus(this._msg("connected", {"%model%": modelName}));
const pdf = await pdfjsLib.getDocument({data: pdfBytes}).promise;
const numPages = pdf.numPages;
const taskName = client.getPrintTaskType() ?? "B1";
printTask = client.abstraction.newPrintTask(taskName, {
totalPages: numPages * copies,
labelType: labelType,
density: density,
statusPollIntervalMs: 150,
statusTimeoutMs: 8000,
});
await printTask.printInit();
for (let i = 1; i <= numPages; i++) {
this._setStatus(this._msg("rendering", {"%page%": i, "%total%": numPages}));
const page = await pdf.getPage(i);
let canvas = await this._renderPage(page, dpi);
canvas = this._rotate(canvas, rotation);
this._threshold(canvas, threshold);
const encoded = ImageEncoder.encodeCanvas(canvas, "top");
if (printhead && encoded.cols > printhead) {
this._setStatus(
this._msg("too_wide", {"%width%": encoded.cols, "%max%": printhead}),
"warning"
);
}
this._setStatus(this._msg("printing", {"%page%": i, "%total%": numPages}));
await printTask.printPage(encoded, copies);
await printTask.waitForPageFinished();
}
await printTask.waitForFinished();
this._setStatus(this._msg("done", {"%count%": numPages * copies}), "success");
} catch (e) {
console.error(e);
const message = (e && e.message) ? e.message : String(e);
this._setStatus(this._msg("error", {"%message%": message}), "danger");
} finally {
if (connected) {
try {
await client.abstraction.printEnd();
} catch (e) {
// Ignore cleanup errors, they would mask the original error.
}
try {
await client.disconnect();
} catch (e) {
// Ignore.
}
}
this._busy(false);
}
}
/**
* Renders a single PDF page to a canvas at the given resolution (dpi).
* The canvas is filled white first, because thermal printers only burn the non-white pixels.
*/
async _renderPage(page, dpi) {
const viewport = page.getViewport({scale: dpi / 72});
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(viewport.width));
canvas.height = Math.max(1, Math.round(viewport.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({canvasContext: ctx, viewport}).promise;
return canvas;
}
/**
* Rotates a canvas by 0/90/180/270 degrees clockwise and returns a new canvas.
* Done before thresholding so the resulting bitmap stays crisp.
*/
_rotate(src, deg) {
if (deg % 360 === 0) {
return src;
}
const swap = deg === 90 || deg === 270;
const dst = document.createElement("canvas");
dst.width = swap ? src.height : src.width;
dst.height = swap ? src.width : src.height;
const ctx = dst.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, dst.width, dst.height);
ctx.translate(dst.width / 2, dst.height / 2);
ctx.rotate(deg * Math.PI / 180);
ctx.drawImage(src, -src.width / 2, -src.height / 2);
return dst;
}
/**
* Converts a canvas to pure black/white in place using a luminance threshold.
*/
_threshold(canvas, threshold) {
const ctx = canvas.getContext("2d");
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const luminance = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
const value = luminance < threshold ? 0 : 255;
d[i] = value;
d[i + 1] = value;
d[i + 2] = value;
d[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
_dataUriToBytes(dataUri) {
const base64 = dataUri.substring(dataUri.indexOf(",") + 1);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
_intFromTarget(name, fallback, emptyValue) {
const targetName = "has" + name.charAt(0).toUpperCase() + name.slice(1) + "Target";
if (!this[targetName]) {
return fallback;
}
const raw = this[name + "Target"].value;
if (raw === "" || raw === null || raw === undefined) {
return emptyValue;
}
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
_busy(busy) {
if (this.hasButtonTarget) {
this.buttonTarget.disabled = busy;
}
for (const name of ["density", "labelType", "rotation", "copies", "threshold"]) {
const targetName = name + "Target";
const hasName = "has" + name.charAt(0).toUpperCase() + name.slice(1) + "Target";
if (this[hasName]) {
this[targetName].disabled = busy;
}
}
}
_msg(key, replacements = {}) {
let text = (this.messagesValue && this.messagesValue[key]) ? this.messagesValue[key] : key;
for (const [search, value] of Object.entries(replacements)) {
text = text.replace(search, value);
}
return text;
}
_setStatus(text, type = "muted") {
if (!this.hasStatusTarget) {
return;
}
this.statusTarget.textContent = text;
this.statusTarget.className = "small text-" + type;
}
}