diff --git a/.env b/.env
index c175012b..e1ebae46 100644
--- a/.env
+++ b/.env
@@ -144,6 +144,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 64c791c1..baf677c5 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 d6309351..4544bf49 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -279,6 +279,10 @@ See the [information providers]({% link usage/information_provider_system.md %})
Part-DB displays a warning on the homepage (visible to administrators only) as long as this value is not set.
* `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 b52aaccf..9d1d7bd0 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 e4298c5d..3cea3f37 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -13883,5 +13883,131 @@ Buerklin-API Authentication server:
Prefer metric footprint when both metric and imperial are available
+
+
+ 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%
+
+