mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-03 15:11:40 +00:00
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:
parent
a356b94c34
commit
218b394b13
9 changed files with 530 additions and 0 deletions
6
.env
6
.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
|
||||
|
|
|
|||
272
assets/controllers/pages/niimbot_print_controller.js
Normal file
272
assets/controllers/pages/niimbot_print_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
######################################################################################################################
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue