Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
272 changes: 272 additions & 0 deletions assets/controllers/pages/niimbot_print_controller.js
Original file line number Diff line number Diff line change
@@ -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 <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;
}
}
1 change: 1 addition & 0 deletions config/packages/twig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions config/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
######################################################################################################################
Expand Down Expand Up @@ -108,6 +113,8 @@ parameters:

env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0

env(NIIMBOT_ENABLED): 0

######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions docs/usage/labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading