mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-03 23:21:44 +00:00
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).
272 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|