Part-DB-server/templates/label_system/dialog.html.twig
Jaime Laborda 218b394b13 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).
2026-07-16 11:39:38 +02:00

214 lines
12 KiB
Twig

{% extends 'main_card.html.twig' %}
{% block title %}{% trans %}label_generator.title{% endtrans %}{% endblock %}
{%- block card_title -%}
<i class="fas fa-qrcode fa-fw"></i> {% trans %}label_generator.title{% endtrans %}
{% if profile %}({{ profile.name }}){% endif %}
{%- endblock -%}
{% block card_content %}
{{ form_start(form) }}
{# Default submit to use when pressing enter. #}
<input type="submit" name="label_dialog[update]" class="d-none">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" id="common-tab" role="tab" aria-controls="common" aria-selected="true" href="#common"
>{% trans %}label_generator.common{% endtrans %}</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" id="advanced-tab" role="tab" aria-controls="advanced" aria-selected="false" href="#advanced"
>{% trans %}label_generator.advanced{% endtrans %}</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" id="profiles-tab" role="tab" aria-controls="profiles" aria-selected="false" href="#profiles"
>{% trans %}label_generator.profiles{% endtrans %}</a>
</li>
</ul>
<div class="tab-content mt-2">
<div class="tab-pane active" id="common" role="tabpanel" aria-labelledby="common-tab">
{{ form_row(form.target_id) }}
{{ form_row(form.options.supported_element) }}
<div class="mb-2 row">
{{ form_label(form.options.width) }}
<div class="{{ col_input }}">
<div class="input-group">
{{ form_widget(form.options.width) }}
<span class="input-group-text">x</span>
{{ form_widget(form.options.height) }}
<span class="input-group-text">mm</span>
</div>
</div>
</div>
{{ form_row(form.options.barcode_type) }}
{{ form_row(form.options.lines) }}
</div>
<div class="tab-pane" id="advanced" role="tabpanel" aria-labelledby="advanced-tab">
{{ form_row(form.options.additional_css) }}
{{ form_widget(form.options) }}
</div>
<div class="tab-pane" id="profiles" role="tabpanel" aria-labelledby="profiles-tab">
<div class="form-group row">
<label class="{{ col_label }} col-form-label">{% trans %}label_generator.selected_profile{% endtrans %}</label>
<div class="{{ col_input }}">
<span class="form-control-plaintext">{{ profile.name ?? '-' }}
{% if profile and is_granted("edit", profile) %}
<a href="{{ entity_url(profile, 'edit') }}" title="{% trans %}label_generator.edit_profile{% endtrans %}"
><i class="fas fa-edit"></i></a>
{% endif %}
</span>
</div>
</div>
<div class="form-group row">
<div class="{{ offset_label }} {{ col_input }}">
<div class="dropdown">
<button class="btn btn-info dropdown-toggle" type="button" id="loadProfilesButton"
{% if not is_granted("@labels.create_labels") %}disabled{% endif %}
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{% trans %}label_generator.load_profile{% endtrans %}
</button>
<div class="dropdown-menu" aria-labelledby="loadProfilesButton">
{% if is_granted("@labels.create_labels") %}
{% for type in enum_cases("App\\Entity\\LabelSystem\\LabelSupportedElement") %}
{% set profiles = label_profile_dropdown_helper.dropdownProfiles(type) %}
{% if profiles is not empty %}
<h6 class="dropdown-header">{{ (type.value~'.label') | trans }}</h6>
{% endif %}
{% for profile in profiles %}
<a class="dropdown-item" href="{{ path('label_dialog_profile', {'profile': profile.id }) }}">{{ profile.name }}</a>
{% endfor %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
{% if is_granted("@labels.read_profiles") %}
<div class="form-group row">
<div class="{{ offset_label }} {{ col_input }}">
<a class="btn btn-link" href="{{ path('label_profile_new') }}">{% trans %}label_generator.edit_profiles{% endtrans %}</a>
</div>
</div>
{% endif %}
{% if form.update_profile is defined %}
{{ form_row(form.update_profile) }}
{% endif %}
<div class="form-group row">
<div class="{{ offset_label }} {{ col_input }}">
<div class="input-group">
{{ form_widget(form.save_profile_name) }}
{{ form_widget(form.save_profile) }}
</div>
{{ form_errors(form.save_profile_name) }}
</div>
</div>
</div>
</div>
{{ form_end(form) }}
{% if pdf_data %}
<div class="row">
<div class="{{ col_input }} {{ offset_label }}">
<a data-turbo="false" class="btn btn-secondary" href="#" {{ stimulus_controller('pages/label_download_btn')}} {{ stimulus_action('pages/label_download_btn', 'download')}} download="{{ filename ?? '' }}">
{% trans %}label_generator.download{% endtrans %}
</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 %}
{% block additional_content %}
{% if pdf_data %}
<div class="card mt-2 p-1 border-secondary" style="resize: vertical; overflow: scroll; height: 280px">
<object id="pdf_preview" data="{{ pdf_data | data_uri(mime='application/pdf') }}" style="height: inherit">
</object>
</div>
{% endif %}
{% endblock %}