diff --git a/assets/controllers/elements/nonprintable_char_input_controller.js b/assets/controllers/elements/nonprintable_char_input_controller.js
new file mode 100644
index 00000000..bd172f1b
--- /dev/null
+++ b/assets/controllers/elements/nonprintable_char_input_controller.js
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2026 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";
+
+/**
+ * Purpose of this controller is to allow users to input non-printable characters like EOT, FS, etc. in a form field and submit them correctly with the form.
+ * The visible input field encodes non-printable characters via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉, so that they can be displayed in the input field without breaking the form submission.
+ * The actual value of the field, which is submitted with the form, is stored in a hidden input and contains the non-printable characters in their original form.
+ */
+export default class extends Controller {
+
+ _hiddenInput;
+
+ connect() {
+ this.element.addEventListener("input", this._update.bind(this));
+
+ // We use a hidden input to store the actual value of the field, which is submitted with the form.
+ // The visible input is just for user interaction and can contain non-printable characters, which are not allowed in the hidden input.
+ this._hiddenInput = document.createElement("input");
+ this._hiddenInput.type = "hidden";
+ this._hiddenInput.name = this.element.name;
+ this.element.removeAttribute("name");
+ this.element.parentNode.insertBefore(this._hiddenInput, this.element.nextSibling);
+
+ this.element.addEventListener("keypress", this._onKeyPress.bind(this));
+ }
+
+ /**
+ * Ensures that non-printable characters like EOT, FS, etc. gets added to the input value when the user types them
+ * @param event
+ * @private
+ */
+ _onKeyPress(event) {
+ const ALLOWED_INPUT_CODES = [4, 28, 29, 30, 31]; //EOT, FS, GS, RS, US
+
+ if (!ALLOWED_INPUT_CODES.includes(event.keyCode)) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const char = String.fromCharCode(event.keyCode);
+ this.element.value += char;
+
+ this._update();
+
+
+ }
+
+ _update() {
+ //Chrome workaround: Remove a leading ∠ character (U+2220) that appears when the user types a non-printable character at the beginning of the input field.
+ if (this.element.value.startsWith("∠")) {
+ this.element.value = this.element.value.substring(1);
+ }
+
+ // Remove non-printable characters from the input value and store them in the hidden input
+ const normalizedValue = this.decodeNonPrintableChars(this.element.value);
+ this._hiddenInput.value = normalizedValue;
+
+ // Encode non-printable characters in the visible input to their Unicode Control picture representation
+ const encodedValue = this.encodeNonPrintableChars(normalizedValue);
+ if (encodedValue !== this.element.value) {
+ this.element.value = encodedValue;
+ }
+ }
+
+ /**
+ * Encodes non-printable characters in the given string via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉.
+ * This allows us to display non-printable characters in the input field without breaking the form submission.
+ * @param str
+ */
+ encodeNonPrintableChars(str) {
+ return str.replace(/[\x00-\x1F\x7F]/g, (char) => {
+ const code = char.charCodeAt(0);
+ return String.fromCharCode(0x2400 + code);
+ });
+ }
+
+ /**
+ * Decodes the Unicode Control picture representation of non-printable characters back to their original form, e.g. ␊ becomes \n and ␉ becomes \t.
+ * @param str
+ */
+ decodeNonPrintableChars(str) {
+ return str.replace(/[\u2400-\u241F\u2421]/g, (char) => {
+ const code = char.charCodeAt(0) - 0x2400;
+ return String.fromCharCode(code);
+ });
+ }
+}
diff --git a/assets/controllers/helpers/scan_special_char_controller.js b/assets/controllers/helpers/scan_special_char_controller.js
new file mode 100644
index 00000000..154b2a94
--- /dev/null
+++ b/assets/controllers/helpers/scan_special_char_controller.js
@@ -0,0 +1,136 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2026 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"
+
+/**
+ * This controller listens for a special non-printable character (SOH / ASCII 1) to be entered anywhere on the page,
+ * which is then used as a trigger to submit the following characters as a barcode / scan input.
+ */
+export default class extends Controller {
+ connect() {
+ // Optional: Log to confirm global attachment
+ console.log("Scanner listener active")
+
+ this.isCapturing = false
+ this.buffer = ""
+
+ window.addEventListener("keypress", this.handleKeydown.bind(this))
+ }
+
+ initialize() {
+ this.isCapturing = false
+ this.buffer = ""
+ this.timeoutId = null
+ }
+
+ handleKeydown(event) {
+
+ // Ignore if the user is typing in a form field
+ const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) ||
+ event.target.isContentEditable;
+ if (isInput) return
+
+ // 1. Detect Start of Header (SOH / Ctrl+A)
+ if (event.key === "\x01" || event.keyCode === 1) {
+ this.startCapturing(event)
+ return
+ }
+
+ // 2. Process characters if in capture mode
+ if (this.isCapturing) {
+ this.resetTimeout() // Push the expiration back with every keypress
+
+ if (event.key === "Enter" || event.keyCode === 13) {
+
+ this.finishCapturing(event)
+ } else if (event.key.length === 1) {
+ this.buffer += event.key
+ }
+ }
+ }
+
+ startCapturing(event) {
+ this.isCapturing = true
+ this.buffer = ""
+ this.resetTimeout()
+ event.preventDefault()
+ console.debug("Scan character detected. Capture started...")
+ }
+
+ finishCapturing(event) {
+ event.preventDefault()
+ const data = this.buffer;
+ this.stopCapturing()
+ this.processCapture(data)
+ }
+
+ stopCapturing() {
+ this.isCapturing = false
+ this.buffer = ""
+ if (this.timeoutId) clearTimeout(this.timeoutId)
+ console.debug("Capture cleared/finished.")
+ }
+
+ resetTimeout() {
+ if (this.timeoutId) clearTimeout(this.timeoutId)
+
+ this.timeoutId = setTimeout(() => {
+ if (this.isCapturing) {
+ console.warn("Capture timed out. Resetting buffer.")
+ this.stopCapturing()
+ }
+ }, 500)
+ }
+
+ processCapture(data) {
+ if (!data) return
+
+ console.debug("Captured scan data: " + data)
+
+ const scanInput = document.getElementById("scan_dialog_input");
+ if (scanInput) { //When we are on the scan dialog page, submit the form there
+ this._submitScanForm(data);
+ } else { //Otherwise use our own form (e.g. on the part list page)
+ this.element.querySelector("input[name='input']").value = data;
+ this.element.requestSubmit();
+ }
+
+
+ }
+
+ _submitScanForm(data) {
+ const scanInput = document.getElementById("scan_dialog_input");
+ if (!scanInput) {
+ console.error("Scan input field not found!")
+ return;
+ }
+
+ scanInput.value = data;
+ scanInput.dispatchEvent(new Event('input', { bubbles: true }));
+
+ const form = document.getElementById("scan_dialog_form");
+ if (!form) {
+ console.error("Scan form not found!")
+ return;
+ }
+
+ form.requestSubmit();
+ }
+}
diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js
index ae51e951..bdc9c78c 100644
--- a/assets/controllers/pages/barcode_scan_controller.js
+++ b/assets/controllers/pages/barcode_scan_controller.js
@@ -114,7 +114,11 @@ export default class extends Controller {
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
this._lastDecodedText = normalized;
- document.getElementById('scan_dialog_input').value = decodedText;
+ const input = document.getElementById('scan_dialog_input');
+ input.value = decodedText;
+ //Trigger nonprintable char input controller to update the hidden input value
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+
//Submit form
document.getElementById('scan_dialog_form').requestSubmit();
}
diff --git a/docs/usage/scanner.md b/docs/usage/scanner.md
new file mode 100644
index 00000000..47b3feff
--- /dev/null
+++ b/docs/usage/scanner.md
@@ -0,0 +1,51 @@
+---
+title: Barcode Scanner
+layout: default
+parent: Usage
+---
+
+# Barcode scanner
+
+When the user has the correct permission there will be a barcode scanner button in the navbar.
+On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to
+scan a barcode.
+
+In info mode (when the "Info" toggle is enabled) you can scan a barcode and Part-DB will parse it and show information
+about it.
+
+Without info mode, the barcode will directly redirect you to the corresponding page.
+
+### Barcode matching
+
+When you scan a barcode, Part-DB will try to match it to an existing part, part lot or storage location first.
+For Part-DB generated barcodes, it will use the internal ID of a part. Alternatively you can also scan a barcode that contains the part's IPN.
+
+You can set a GTIN/EAN code in the part properties and Part-DB will open the part page when you scan the corresponding GTIN/EAN barcode.
+
+On a part lot you can under "Advanced" set a user barcode, that will redirect you to the part lot page when scanned. This allows to reuse
+arbitrary existing barcodes that already exist on the part lots (for example, from the manufacturer) and link them to the part lot in Part-DB.
+
+Part-DB can also parse various distributor barcodes (for example from Digikey and Mouser) and will try to redirect you to the corresponding
+part page based on the distributor part number in the barcode.
+
+### Part creation from barcodes
+For certain barcodes Part-DB can automatically create a new part, when it cannot find a matching part.
+Part-DB will try to retrieve the part information from an information provider and redirects you to the part creation page
+with the retrieved information pre-filled.
+
+## Using an external barcode scanner
+
+Part-DB supports the use of external barcode scanners that emulate keyboard input. To use a barcode scanner with Part-DB,
+simply connect the scanner to your computer and scan a barcode while the cursor is in a text field in Part-DB.
+The scanned barcode will be entered into the text field as if you had typed it on the keyboard.
+
+In scanner fields, it will also try to insert special non-printable characters the scanner send via Alt + key combinations.
+This is required for EIGP114 datamatrix codes.
+
+### Automatically redirect on barcode scanning
+
+If you configure your barcode scanner to send a (Start of heading, 0x01) non-printable character at the beginning
+of the scanned barcode, Part-DB will automatically scan the barcode that comes afterward (and is ended with an enter key)
+and redirects you to the corresponding page.
+This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page.
+If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen.
diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php
index cd1440c6..5f9ce65f 100644
--- a/src/Form/LabelSystem/ScanDialogType.php
+++ b/src/Form/LabelSystem/ScanDialogType.php
@@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType
'attr' => [
'autofocus' => true,
'id' => 'scan_dialog_input',
+ 'style' => 'font-family: var(--bs-font-monospace)',
+ 'data-controller' => 'elements--nonprintable-char-input',
],
]);
diff --git a/templates/base.html.twig b/templates/base.html.twig
index 62f0ce53..afc7a8bf 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -2,7 +2,7 @@
@@ -73,9 +73,17 @@
{{ encore_entry_script_tags('webauthn_tfa') }}
{% endblock %}
-
+
+{# Listen for the special #}
+{% if is_granted("@tools.label_scanner") %}
+
+{% endif %}
+
{% block body %}
@@ -121,13 +129,13 @@
{# Must be outside of the sidebar or it will be hidden too #}