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).
This commit is contained in:
Jaime Laborda 2026-07-13 15:02:16 +02:00
parent a356b94c34
commit 218b394b13
9 changed files with 530 additions and 0 deletions

6
.env
View file

@ -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

View file

@ -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;
}
}

View file

@ -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

View file

@ -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
######################################################################################################################

View file

@ -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.

View file

@ -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 (15 on the B1) for your label material.
* **B/W threshold** the label is converted to pure black/white using this luminance threshold (0254). 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.

View file

@ -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",

View file

@ -130,6 +130,77 @@
</a>
</div>
</div>
{% if niimbot_enabled %}
<div class="row mt-3" {{ stimulus_controller('pages/niimbot_print', {
messages: {
no_bluetooth: 'label_generator.thermal.no_bluetooth'|trans,
no_label: 'label_generator.thermal.no_label'|trans,
connecting: 'label_generator.thermal.connecting'|trans,
connected: 'label_generator.thermal.connected'|trans,
rendering: 'label_generator.thermal.rendering'|trans,
printing: 'label_generator.thermal.printing'|trans,
done: 'label_generator.thermal.done'|trans,
too_wide: 'label_generator.thermal.too_wide'|trans,
error: 'label_generator.thermal.error'|trans
}
}) }}>
<div class="{{ col_input }} {{ offset_label }}">
<div class="card border-secondary">
<div class="card-body">
<h6 class="card-title mb-1">
<i class="fas fa-print fa-fw"></i> {% trans %}label_generator.thermal.title{% endtrans %}
</h6>
<p class="text-muted small mb-2">{% trans %}label_generator.thermal.hint{% endtrans %}</p>
<div class="row g-2 align-items-end">
<div class="col-6 col-md-3">
<label class="form-label small mb-0" for="niimbot_copies">{% trans %}label_generator.thermal.copies{% endtrans %}</label>
<input type="number" min="1" value="1" class="form-control form-control-sm" id="niimbot_copies" {{ stimulus_target('pages/niimbot_print', 'copies') }}>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-0" for="niimbot_density">{% trans %}label_generator.thermal.density{% endtrans %}</label>
<select class="form-select form-select-sm" id="niimbot_density" {{ stimulus_target('pages/niimbot_print', 'density') }}>
<option value="">{% trans %}label_generator.thermal.density_auto{% endtrans %}</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-0" for="niimbot_labeltype">{% trans %}label_generator.thermal.label_type{% endtrans %}</label>
<select class="form-select form-select-sm" id="niimbot_labeltype" {{ stimulus_target('pages/niimbot_print', 'labelType') }}>
<option value="1">{% trans %}label_generator.thermal.label_type_gaps{% endtrans %}</option>
<option value="3">{% trans %}label_generator.thermal.label_type_continuous{% endtrans %}</option>
<option value="2">{% trans %}label_generator.thermal.label_type_black{% endtrans %}</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-0" for="niimbot_rotation">{% trans %}label_generator.thermal.rotation{% endtrans %}</label>
<select class="form-select form-select-sm" id="niimbot_rotation" {{ stimulus_target('pages/niimbot_print', 'rotation') }}>
<option value="0">0°</option>
<option value="90">90°</option>
<option value="180">180°</option>
<option value="270">270°</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-0" for="niimbot_threshold">{% trans %}label_generator.thermal.threshold{% endtrans %}</label>
<input type="number" min="1" max="254" value="128" class="form-control form-control-sm" id="niimbot_threshold" {{ stimulus_target('pages/niimbot_print', 'threshold') }}>
</div>
</div>
<div class="mt-2">
<button type="button" class="btn btn-primary btn-sm" {{ stimulus_target('pages/niimbot_print', 'button') }} {{ stimulus_action('pages/niimbot_print', 'print') }}>
<i class="fas fa-print fa-fw"></i> {% trans %}label_generator.thermal.print{% endtrans %}
</button>
</div>
<div class="mt-2 small text-muted" {{ stimulus_target('pages/niimbot_print', 'status') }}></div>
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -13745,5 +13745,131 @@ Buerklin-API Authentication server:
<target>Warning: Changing values here can break the info retrieval mechanism! You should use the "update from info provider" functionality whenever possible.</target>
</segment>
</unit>
<unit id="niimbotThTitle" name="label_generator.thermal.title">
<segment state="translated">
<source>label_generator.thermal.title</source>
<target>Thermal printer (Niimbot)</target>
</segment>
</unit>
<unit id="niimbotThHint" name="label_generator.thermal.hint">
<segment state="translated">
<source>label_generator.thermal.hint</source>
<target>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).</target>
</segment>
</unit>
<unit id="niimbotThPrint" name="label_generator.thermal.print">
<segment state="translated">
<source>label_generator.thermal.print</source>
<target>Print to Niimbot</target>
</segment>
</unit>
<unit id="niimbotThCopies" name="label_generator.thermal.copies">
<segment state="translated">
<source>label_generator.thermal.copies</source>
<target>Copies</target>
</segment>
</unit>
<unit id="niimbotThDensity" name="label_generator.thermal.density">
<segment state="translated">
<source>label_generator.thermal.density</source>
<target>Density</target>
</segment>
</unit>
<unit id="niimbotThDensityAuto" name="label_generator.thermal.density_auto">
<segment state="translated">
<source>label_generator.thermal.density_auto</source>
<target>Auto</target>
</segment>
</unit>
<unit id="niimbotThLabelType" name="label_generator.thermal.label_type">
<segment state="translated">
<source>label_generator.thermal.label_type</source>
<target>Label type</target>
</segment>
</unit>
<unit id="niimbotThLabelTypeGaps" name="label_generator.thermal.label_type_gaps">
<segment state="translated">
<source>label_generator.thermal.label_type_gaps</source>
<target>With gaps (die-cut)</target>
</segment>
</unit>
<unit id="niimbotThLabelTypeContinuous" name="label_generator.thermal.label_type_continuous">
<segment state="translated">
<source>label_generator.thermal.label_type_continuous</source>
<target>Continuous</target>
</segment>
</unit>
<unit id="niimbotThLabelTypeBlack" name="label_generator.thermal.label_type_black">
<segment state="translated">
<source>label_generator.thermal.label_type_black</source>
<target>Black mark</target>
</segment>
</unit>
<unit id="niimbotThRotation" name="label_generator.thermal.rotation">
<segment state="translated">
<source>label_generator.thermal.rotation</source>
<target>Rotation</target>
</segment>
</unit>
<unit id="niimbotThThreshold" name="label_generator.thermal.threshold">
<segment state="translated">
<source>label_generator.thermal.threshold</source>
<target>B/W threshold</target>
</segment>
</unit>
<unit id="niimbotThNoBluetooth" name="label_generator.thermal.no_bluetooth">
<segment state="translated">
<source>label_generator.thermal.no_bluetooth</source>
<target>Web Bluetooth is not available in this browser. Use a Chromium based browser (Chrome/Edge) over HTTPS or localhost.</target>
</segment>
</unit>
<unit id="niimbotThNoLabel" name="label_generator.thermal.no_label">
<segment state="translated">
<source>label_generator.thermal.no_label</source>
<target>No label to print. Please generate the label first.</target>
</segment>
</unit>
<unit id="niimbotThConnecting" name="label_generator.thermal.connecting">
<segment state="translated">
<source>label_generator.thermal.connecting</source>
<target>Connecting to printer…</target>
</segment>
</unit>
<unit id="niimbotThConnected" name="label_generator.thermal.connected">
<segment state="translated">
<source>label_generator.thermal.connected</source>
<target>Connected to %model%.</target>
</segment>
</unit>
<unit id="niimbotThRendering" name="label_generator.thermal.rendering">
<segment state="translated">
<source>label_generator.thermal.rendering</source>
<target>Rendering page %page% of %total%…</target>
</segment>
</unit>
<unit id="niimbotThPrinting" name="label_generator.thermal.printing">
<segment state="translated">
<source>label_generator.thermal.printing</source>
<target>Printing page %page% of %total%…</target>
</segment>
</unit>
<unit id="niimbotThDone" name="label_generator.thermal.done">
<segment state="translated">
<source>label_generator.thermal.done</source>
<target>Done. Sent %count% label(s) to the printer.</target>
</segment>
</unit>
<unit id="niimbotThTooWide" name="label_generator.thermal.too_wide">
<segment state="translated">
<source>label_generator.thermal.too_wide</source>
<target>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.</target>
</segment>
</unit>
<unit id="niimbotThError" name="label_generator.thermal.error">
<segment state="translated">
<source>label_generator.thermal.error</source>
<target>Error: %message%</target>
</segment>
</unit>
</file>
</xliff>