diff --git a/.env b/.env index 8cd39f31..23666046 100644 --- a/.env +++ b/.env @@ -135,6 +135,12 @@ DEMO_MODE=0 # This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network. ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0 +# Set this to 1 to enable direct thermal printing of labels to Niimbot printers (e.g. B1) from the label generator. +# This is disabled by default, as it is only useful if you own such a printer. When enabled, a "Print to Niimbot" +# panel is shown below the generated label. It requires a Chromium based browser (Chrome/Edge) and a secure context +# (HTTPS or localhost), as it uses the browser's Web Bluetooth API. +NIIMBOT_ENABLED=0 + # Change this to true, if no url rewriting (like mod_rewrite for Apache) is available # In that case all URL contains the index.php front controller in URL NO_URL_REWRITE_AVAILABLE=0 diff --git a/assets/controllers/pages/niimbot_print_controller.js b/assets/controllers/pages/niimbot_print_controller.js new file mode 100644 index 00000000..3ca14316 --- /dev/null +++ b/assets/controllers/pages/niimbot_print_controller.js @@ -0,0 +1,272 @@ +/* + * 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 . + */ + +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; + } +} diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 860cef42..e94b85f3 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -16,6 +16,7 @@ twig: avatar_helper: '@App\Services\UserSystem\UserAvatarHelper' available_themes: '%partdb.available_themes%' saml_enabled: '%partdb.saml.enabled%' + niimbot_enabled: '%partdb.label.niimbot_enabled%' part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator' # Bootstrap grid classes used for horizontal form layouts diff --git a/config/parameters.yaml b/config/parameters.yaml index e654a9b5..c6c5e232 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -32,6 +32,11 @@ parameters: partdb.attachments.dir.media: 'public/media/' # The folder where uploaded attachment files are saved (must be in public folder) partdb.attachments.dir.secure: 'uploads/' # The folder where secured attachment files are saved (must not be in public/) + ###################################################################################################################### + # Label system + ###################################################################################################################### + partdb.label.niimbot_enabled: '%env(bool:NIIMBOT_ENABLED)%' # If true, the "Print to Niimbot" thermal printing panel is shown in the label generator + ###################################################################################################################### # Error pages ###################################################################################################################### @@ -108,6 +113,8 @@ parameters: env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0 + env(NIIMBOT_ENABLED): 0 + ###################################################################################################################### # Bulk Info Provider Import Configuration ###################################################################################################################### diff --git a/docs/configuration.md b/docs/configuration.md index 2b97dec6..7991ecd3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,6 +265,10 @@ See the [information providers]({% link usage/information_provider_system.md %}) should be accessible. If accessed via the wrong hostname, an error will be shown. * `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo instance. This should not be needed for normal installations. +* `NIIMBOT_ENABLED` (default `0`) (env only): If set to `1`, a "Print to Niimbot" panel is shown in the label generator, which + allows printing labels directly to a Niimbot thermal printer (e.g. B1) via the browser's Web Bluetooth API. Disabled by + default, as it is only useful if you own such a printer. Requires a Chromium based browser and a secure context (HTTPS or + localhost). See the [labels page]({% link usage/labels.md %}) for details. * `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`) (env only): Set this value to true, if your webserver does not support rewrite. In this case, all URL paths will contain index.php/, which is needed then. Normally this setting does not need to be changed. diff --git a/docs/usage/labels.md b/docs/usage/labels.md index c804cebb..b76a29cb 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -295,3 +295,44 @@ There is the [Noto](https://www.google.com/get/noto/) font family from Google, w available in different styles (regular, bold, italic, bold-italic). For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese, and Korean characters. + +## Thermal label printing (Niimbot) + +Besides generating a PDF (which you print via your operating system's print dialog), Part-DB can send a label +**directly to a [Niimbot](https://www.niimbot.com/) thermal label printer** (e.g. the B1, B21, D110, …) over +Bluetooth, without any driver installation. This uses the [niimbluelib](https://github.com/MultiMote/niimbluelib) +library and the browser's [Web Bluetooth API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API). + +{: .important } +> Thermal printing is **disabled by default**, as it is only useful if you own a Niimbot printer. An administrator must +> enable it by setting the environment variable `NIIMBOT_ENABLED=1` (see [Configuration]({% link configuration.md %})). + +After you enable it and generate a label in the label generator, a **"Thermal printer (Niimbot)"** panel appears below the preview. +Set the number of copies, print density, label type and rotation, then click **"Print to Niimbot"** and select your +printer from the browser's Bluetooth device chooser. The same PDF that is shown in the preview is rasterized in your +browser at the printer's native resolution (203 dpi for the B1), converted to a black/white bitmap and sent to the +printer. + +### Requirements and limitations + +Because Web Bluetooth runs in the browser, a few conditions must be met: + +* **The feature must be enabled** by an administrator via `NIIMBOT_ENABLED=1` (off by default). +* **A Chromium based browser is required** – Chrome, Edge, Opera or Chrome for Android. Firefox and Safari/iOS do + **not** support Web Bluetooth. +* **A secure context is required** – the page must be served over **HTTPS**, or accessed via `localhost`. Many + Part-DB installations run over plain HTTP on the local network; in that case put Part-DB behind a reverse proxy + with TLS, otherwise the browser will not expose the Bluetooth API and the button is disabled. +* The printer must be **turned on and paired-able** (not already connected to the phone app). + +### Tips + +* **Rotation** – the B1 has a 384 pixel (48 mm) wide print head. If your label is wider than that (e.g. a 50 mm wide + label = 400 px), rotate it by 90° so the shorter side runs across the print head, otherwise it may be clipped. + A warning is shown if the rendered bitmap is wider than the print head. +* **Density** – higher density gives darker prints but may bleed. Leave it on *Auto* to use the printer's default, + or tune it (1–5 on the B1) for your label material. +* **B/W threshold** – the label is converted to pure black/white using this luminance threshold (0–254). Increase it + if thin lines/text disappear, decrease it if the print is too heavy. +* **Multiple labels** – if you generate labels for several elements at once (e.g. IDs `1,2,5-10`), every page of the + resulting document is printed in sequence. diff --git a/package.json b/package.json index f906d814..1fcd8e3f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0", "@jbtronics/bs-treeview": "^1.0.1", + "@mmote/niimbluelib": "0.0.1-alpha.41", "@part-db/html5-qrcode": "^4.0.0", "@zxcvbn-ts/core": "^4.1.2", "@zxcvbn-ts/language-common": "^4.1.2", @@ -68,6 +69,7 @@ "marked": "^18.0.0", "marked-gfm-heading-id": "^4.1.1", "marked-mangle": "^1.0.1", + "pdfjs-dist": "^6.1.200", "pdfmake": "^0.3.7", "stimulus-use": "^0.52.0", "sweetalert2": "^11.26.25", diff --git a/templates/label_system/dialog.html.twig b/templates/label_system/dialog.html.twig index 532a4b63..0c9ebd12 100644 --- a/templates/label_system/dialog.html.twig +++ b/templates/label_system/dialog.html.twig @@ -130,6 +130,77 @@ + + {% if niimbot_enabled %} +
+
+
+
+
+ {% trans %}label_generator.thermal.title{% endtrans %} +
+

{% trans %}label_generator.thermal.hint{% endtrans %}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+ {% endif %} {% endif %} {% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 36021868..b4678790 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13745,5 +13745,131 @@ Buerklin-API Authentication server: Warning: Changing values here can break the info retrieval mechanism! You should use the "update from info provider" functionality whenever possible. + + + label_generator.thermal.title + Thermal printer (Niimbot) + + + + + label_generator.thermal.hint + Print this label directly to a Niimbot thermal printer over Bluetooth. Requires a Chromium based browser (e.g. Chrome or Edge) and a secure connection (HTTPS or localhost). + + + + + label_generator.thermal.print + Print to Niimbot + + + + + label_generator.thermal.copies + Copies + + + + + label_generator.thermal.density + Density + + + + + label_generator.thermal.density_auto + Auto + + + + + label_generator.thermal.label_type + Label type + + + + + label_generator.thermal.label_type_gaps + With gaps (die-cut) + + + + + label_generator.thermal.label_type_continuous + Continuous + + + + + label_generator.thermal.label_type_black + Black mark + + + + + label_generator.thermal.rotation + Rotation + + + + + label_generator.thermal.threshold + B/W threshold + + + + + label_generator.thermal.no_bluetooth + Web Bluetooth is not available in this browser. Use a Chromium based browser (Chrome/Edge) over HTTPS or localhost. + + + + + label_generator.thermal.no_label + No label to print. Please generate the label first. + + + + + label_generator.thermal.connecting + Connecting to printer… + + + + + label_generator.thermal.connected + Connected to %model%. + + + + + label_generator.thermal.rendering + Rendering page %page% of %total%… + + + + + label_generator.thermal.printing + Printing page %page% of %total%… + + + + + label_generator.thermal.done + Done. Sent %count% label(s) to the printer. + + + + + label_generator.thermal.too_wide + Warning: the label is %width% px wide but the printer supports only %max% px. It may be clipped — try rotating it or reducing the label width. + + + + + label_generator.thermal.error + Error: %message% + +