diff --git a/classes/Modules/NetworkPrinter/Bootstrap.php b/classes/Modules/NetworkPrinter/Bootstrap.php new file mode 100644 index 000000000..e51b7fbad --- /dev/null +++ b/classes/Modules/NetworkPrinter/Bootstrap.php @@ -0,0 +1,70 @@ + + */ + public static function registerServices(): array + { + return [ + 'NetworkPrinterFactory' => 'onInitNetworkPrinterFactory', + ]; + } + + /** + * Liefert eine Factory-Closure, die fuer eine gegebene Drucker-ID eine + * NetworkPrinter-Instanz baut. Die Klasse \NetworkPrinter erbt von + * \PrinterBase und erwartet als Constructor-Argumente ($app, $id) — die + * eigentlichen Verbindungsdaten (host, port, protocol, ...) zieht der + * Drucker selbst aus der Tabelle `drucker` per id. + * + * \NetworkPrinter ist global namespaced (kein Xentral\...\NetworkPrinter), + * darum hier root-namespaced referenziert. + * + * @return callable(int): \NetworkPrinter + */ + public static function onInitNetworkPrinterFactory(ContainerInterface $container): callable + { + require_once self::LIBRARY_ENTRY; + + /** @var ApplicationCore $app */ + $app = $container->get('LegacyApplication'); + + return static function (int $printerId) use ($app): \NetworkPrinter { + return new \NetworkPrinter($app, $printerId); + }; + } +} diff --git a/classes/Modules/NetworkPrinter/README.md b/classes/Modules/NetworkPrinter/README.md new file mode 100644 index 000000000..fd7ec454e --- /dev/null +++ b/classes/Modules/NetworkPrinter/README.md @@ -0,0 +1,125 @@ +# NetworkPrinter Module Wrapper + +Duenner OpenXE-Modul-Wrapper fuer die `NetworkPrinter`-Library, die unter +`www/lib/Printer/NetworkPrinter/` als Drucker-Plugin liegt. Dieser Wrapper +fuegt nichts an der Library hinzu — er macht sie lediglich ueber den OpenXE +DI-Container erreichbar, damit andere Module den Drucker per Service-Lookup +holen koennen, ohne den `require_once`-Pfad an mehreren Stellen zu duplizieren. + +## Architecture Overview + +OpenXE laedt Drucker-Plugins zur Laufzeit ueber +`pages/drucker.php::loadPrinterModul($modul, $id)` per direkter +`require_once`-Kette aus `www/lib/Printer//.php`. Die `NetworkPrinter`-Library +folgt diesem Schema: ein globaler Klassenname (`class NetworkPrinter extends +PrinterBase`), kein Composer, kein PSR-4-Namespace. Das ist Absicht — nur so +findet `loadPrinterModul()` die Klasse per Filesystem-Konvention. + +Dieser Modul-Wrapper liegt parallel dazu unter +`classes/Modules/NetworkPrinter/`. Er enthaelt keine Geschaeftslogik, sondern +nur einen `Bootstrap.php`, der per OpenXE-Service-Auto-Discovery (`registerServices()`) +die Library laedt und eine Factory-Closure als DI-Service registriert. + +### Warum kein Code-Umzug + +1. **Update-Sicherheit**: Die Library ist als Pull-Request + [openxe-org/openxe#257](https://github.com/openxe-org/openxe/pull/257) + bei upstream offen. Beim Merge wuerde ein parallel umgezogener Code-Stand + im Fork harte Konflikte erzeugen. +2. **Existierende Aufrufer**: `loadPrinterModul()` erwartet die Klasse in + `www/lib/Printer//.php`. Ein Umzug nach `classes/Modules/...` + wuerde das Plugin fuer den Drucker-Spooler unsichtbar machen. +3. **Globale Klassen**: Die Library nutzt root-namespaced + `class NetworkPrinter extends PrinterBase`. Ein nachtraegliches + `namespace Xentral\...` zu setzen wuerde die Auto-Loading-Konvention + brechen. + +Der Wrapper referenziert die Library ausschliesslich per Pfad, nicht per +Symbol-Import. Dadurch ueberlebt er einen upstream-Merge ohne Aenderung. + +## File Layout + +``` +classes/Modules/NetworkPrinter/ + Bootstrap.php # DI-Registrierung + Factory-Closure + README.md # diese Datei + +www/lib/Printer/NetworkPrinter/ (UNVERAENDERT, Plugin-Pfad) + NetworkPrinter.php # Hauptklasse, extends PrinterBase + PrinterType.php + Protocol.php + Driver/ + DriverInterface.php + EscPosDriver.php + IppDriver.php + LprDriver.php + RawDriver.php + Exception/ + PrinterException.php + PrinterCommunicationException.php + PrinterConfigException.php + PrinterConnectionException.php + PrinterProtocolException.php + Status/ + StatusMonitor.php + Util/ + ConnectionTest.php + IppEncoder.php + PdfBatcher.php +``` + +## Requirements + +- PHP 7.4 bis 8.5 +- Keine Composer-Dependencies +- Keine zusaetzlichen PHP-Extensions ueber das hinaus, was OpenXE selbst + voraussetzt (`sockets`, `openssl`, optional `snmp` fuer Status-Monitoring) + +## Usage + +```php +/** @var \Xentral\Core\DependencyInjection\ContainerInterface $container */ +$factory = $container->get('NetworkPrinterFactory'); + +// $printerId = id eines Eintrags in der Tabelle `drucker` mit anbindung='NetworkPrinter' +$printer = $factory($printerId); + +// Standard-OpenXE-Plugin-Methode aus PrinterBase / NetworkPrinter +$printer->printDocument($pdfPath, $copies); +``` + +Die Factory liefert eine `callable(int $printerId): \NetworkPrinter`. Die +eigentliche Verbindungs-Konfiguration (host, port, protocol, lpr_queue, +auth_*) zieht `NetworkPrinter` ueber `PrinterBase::getSettings()` selbst aus +der `drucker`-Tabelle anhand der ID — der Aufrufer muss nichts uebergeben +ausser der ID. + +## Installation + +1. Branch `feature/network-printer-module` auschecken (oder die Files manuell + nach `classes/Modules/NetworkPrinter/` kopieren). +2. Service-Cache invalidieren, damit OpenXE den neuen Bootstrap erkennt: + ``` + rm "{WFuserdata}/tmp/{WFdbname}/cache_services.php" + ``` + (`{WFuserdata}` und `{WFdbname}` aus `www/conf/_config.php`.) +3. Beim naechsten Request baut OpenXE den Service-Container neu auf und + `NetworkPrinterFactory` ist verfuegbar. + +Kein Datenbank-Migration-Step, kein Install-Script. Der Drucker-Eintrag in +der `drucker`-Tabelle wird wie bei allen Druckern ueber das OpenXE-Backend +unter Stammdaten -> Drucker -> Anbindung "Netzwerkdrucker (IP)" angelegt. + +## Status + +Dieser Modul-Wrapper ist update-safe. Die zugrunde liegende Library +(`www/lib/Printer/NetworkPrinter/`) ist bei upstream als Pull-Request +[openxe-org/openxe#257](https://github.com/openxe-org/openxe/pull/257) +offen. Wenn der PR gemerged wird, bleibt der Wrapper kompatibel, weil er nur +auf den Pfad `www/lib/Printer/NetworkPrinter/NetworkPrinter.php` und auf den +globalen Klassennamen `\NetworkPrinter` referenziert — beides bleibt nach +einem upstream-Merge unveraendert. + +## License + +Siehe OpenXE-Hauptlizenz im Root des Repositories. diff --git a/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php b/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php new file mode 100644 index 000000000..4122a8e4a --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php @@ -0,0 +1,38 @@ + true, 'color' => true, 'tray' => true] + */ + public function getCapabilities(): array; +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php new file mode 100644 index 000000000..10e39aff0 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php @@ -0,0 +1,107 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + } + + /** + * {@inheritdoc} + */ + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + $fp = @stream_socket_client($address, $errno, $errstr, 5); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('Verbindung zu Bondrucker %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + $dataLen = strlen($data); + $written = 0; + + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + $meta = stream_get_meta_data($fp); + if (!empty($meta['timed_out'])) { + throw new PrinterCommunicationException( + sprintf('Timeout beim Senden an Bondrucker %s', $address) + ); + } + throw new PrinterCommunicationException( + sprintf('Schreibfehler an Bondrucker %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + + fflush($fp); + return true; + } finally { + fclose($fp); + } + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, $errstr, 3 + ); + if ($fp === false) { + return false; + } + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + */ + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + 'paper_width' => true, + 'auto_cut' => true, + ]; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php new file mode 100644 index 000000000..470015115 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php @@ -0,0 +1,200 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + $this->username = $username; + $this->password = $password; + $this->path = $path; + } + + /** + * {@inheritdoc} + * + * Sendet ein Dokument (PDF) per IPP Print-Job an den Drucker. + * Unterstuetzte Optionen: + * - copies (int) Anzahl Kopien + * - duplex (bool) Duplexdruck (two-sided-long-edge) + * - color (bool) Farbdruck / Monochrom + * - media (string) Medienformat z.B. 'iso_a4_210x297mm' + * - tray (string) Medienfach z.B. 'tray-1' + * - staple (bool) Heften + * - orientation (string) 'portrait' oder 'landscape' + * - job-name (string) Auftragsname + * + * @throws PrinterConnectionException Drucker nicht erreichbar + * @throws PrinterProtocolException IPP-Fehler oder ungueltiger Status + */ + public function send(string $data, array $options = []): bool + { + $printerUri = sprintf('ipp://%s:%d%s', $this->host, $this->port, $this->path); + + // IPP-Header bauen + PDF-Daten anhaengen + $ippHeader = IppEncoder::buildPrintJobRequest($printerUri, $options); + $ippRequest = $ippHeader . $data; + + $response = IppEncoder::sendRequest( + $this->host, + $this->port, + $this->path, + $ippRequest, + $this->username, + $this->password, + $this->timeout + ); + + $parsed = IppEncoder::parseResponse($response); + + if (!$parsed['status_ok']) { + $statusHex = sprintf('0x%04X', $parsed['status_code']); + throw new PrinterProtocolException( + sprintf( + 'IPP Print-Job fehlgeschlagen: Status %s fuer %s', + $statusHex, + $printerUri + ) + ); + } + + return true; + } + + /** + * {@inheritdoc} + * + * Prueft Erreichbarkeit per TCP-Connect auf Host:Port. + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, + $errstr, + 3 + ); + + if ($fp === false) { + return false; + } + + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + * + * IPP-Treiber unterstuetzt alle erweiterten Druckoptionen. + */ + public function getCapabilities(): array + { + return [ + 'duplex' => true, + 'color' => true, + 'tray' => true, + 'staple' => true, + 'media' => true, + 'copies' => true, + 'orientation' => true, + ]; + } + + /** + * Ruft Druckerattribute per IPP Get-Printer-Attributes ab. + * + * @param array $requestedAttributes Gewuenschte Attribute (leer = Standardset) + * + * @return array Assoziatives Array der Druckerattribute + * + * @throws PrinterConnectionException Drucker nicht erreichbar + * @throws PrinterProtocolException IPP-Fehler + */ + public function getPrinterAttributes(array $requestedAttributes = []): array + { + if (empty($requestedAttributes)) { + $requestedAttributes = [ + 'printer-name', + 'printer-make-and-model', + 'printer-state', + 'printer-state-reasons', + 'printer-is-accepting-jobs', + ]; + } + + $printerUri = sprintf('ipp://%s:%d%s', $this->host, $this->port, $this->path); + + $ippRequest = IppEncoder::buildGetPrinterAttributesRequest($printerUri, $requestedAttributes); + + $response = IppEncoder::sendRequest( + $this->host, + $this->port, + $this->path, + $ippRequest, + $this->username, + $this->password, + $this->timeout + ); + + $parsed = IppEncoder::parseResponse($response); + + if (!$parsed['status_ok']) { + $statusHex = sprintf('0x%04X', $parsed['status_code']); + throw new PrinterProtocolException( + sprintf( + 'IPP Get-Printer-Attributes fehlgeschlagen: Status %s fuer %s', + $statusHex, + $printerUri + ) + ); + } + + return $parsed['attributes']; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php new file mode 100644 index 000000000..454dbafce --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php @@ -0,0 +1,167 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + $this->queue = $queue; + } + + /** + * {@inheritdoc} + * + * @throws PrinterConnectionException + * @throws PrinterCommunicationException + * @throws PrinterProtocolException + */ + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + $fp = @stream_socket_client($address, $errno, $errstr, 5); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('LPR-Verbindung zu %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + $jobNumber = random_int(100, 999); + $hostname = gethostname() ?: 'openxe'; + $username = 'openxe'; + $filename = 'document.pdf'; + + // Queue-Name sanitizen (verhindert LPR-Command-Injection via Newline) + $queue = preg_replace('/[^a-zA-Z0-9_\-]/', '', $this->queue); + if ($queue === '') { + $queue = 'lp'; + } + + // 1. Receive-a-printer-job + $this->lprWrite($fp, "\x02" . $queue . "\n"); + $this->lprReadAck($fp); + + // 2. Control File + $controlFile = ''; + $controlFile .= 'H' . $hostname . "\n"; + $controlFile .= 'P' . $username . "\n"; + $controlFile .= 'l' . $filename . "\n"; + $controlFile .= 'U' . 'dfA' . $jobNumber . $hostname . "\n"; + $controlFile .= 'N' . $filename . "\n"; + $controlFileName = 'cfA' . $jobNumber . $hostname; + + $this->lprWrite($fp, sprintf("\x02%d %s\n", strlen($controlFile), $controlFileName)); + $this->lprReadAck($fp); + $this->lprWrite($fp, $controlFile . "\x00"); + $this->lprReadAck($fp); + + // 3. Data File + $dataFileName = 'dfA' . $jobNumber . $hostname; + $this->lprWrite($fp, sprintf("\x03%d %s\n", strlen($data), $dataFileName)); + $this->lprReadAck($fp); + + $dataLen = strlen($data); + $written = 0; + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written, 8192)); + if ($chunk === false || $chunk === 0) { + throw new PrinterCommunicationException( + sprintf('LPR Schreibfehler an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + $this->lprWrite($fp, "\x00"); + $this->lprReadAck($fp); + + return true; + } finally { + fclose($fp); + } + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, $errstr, 3 + ); + if ($fp === false) { + return false; + } + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + */ + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + ]; + } + + /** + * @param resource $fp + * @param string $data + * @throws PrinterCommunicationException + */ + private function lprWrite($fp, string $data): void + { + $written = @fwrite($fp, $data); + if ($written === false || $written < strlen($data)) { + throw new PrinterCommunicationException('LPR: Schreibfehler'); + } + } + + /** + * @param resource $fp + * @throws PrinterProtocolException + */ + private function lprReadAck($fp): void + { + $ack = fread($fp, 1); + if ($ack === false || $ack === '' || ord($ack) !== 0) { + throw new PrinterProtocolException( + sprintf('LPR-Server lehnte Befehl ab (ACK: 0x%s)', $ack !== false ? bin2hex($ack) : 'EOF') + ); + } + } +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php b/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php new file mode 100644 index 000000000..f16eacb90 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php @@ -0,0 +1,117 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + } + + /** + * {@inheritdoc} + */ + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + + $fp = @stream_socket_client( + $address, + $errno, + $errstr, + 5 // Connect-Timeout: 5 Sekunden + ); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('Verbindung zu %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + + $dataLen = strlen($data); + $written = 0; + + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + $meta = stream_get_meta_data($fp); + if (!empty($meta['timed_out'])) { + throw new PrinterCommunicationException( + sprintf('Timeout beim Senden an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + throw new PrinterCommunicationException( + sprintf('Schreibfehler an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + + fflush($fp); + + return true; + } finally { + fclose($fp); + } + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, + $errstr, + 3 // Schneller Connect-Check: 3 Sekunden + ); + + if ($fp === false) { + return false; + } + + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + */ + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + ]; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php b/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php new file mode 100644 index 000000000..4d42aa50c --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php @@ -0,0 +1,7 @@ + [ + 'typ' => 'text', + 'bezeichnung' => 'IP-Adresse / Hostname', + 'placeholder' => '192.168.1.100', + 'size' => '40', + ], + 'port' => [ + 'typ' => 'text', + 'bezeichnung' => 'Port', + 'placeholder' => '631', + 'size' => '10', + ], + 'printer_type' => [ + 'typ' => 'select', + 'bezeichnung' => 'Druckertyp', + 'optionen' => [ + 'document' => 'Dokumentendrucker', + 'label' => 'Etikettendrucker', + 'receipt' => 'Bondrucker', + ], + ], + 'protocol' => [ + 'typ' => 'select', + 'bezeichnung' => 'Protokoll', + 'optionen' => [ + 'ipp' => 'IPP (Internet Printing Protocol)', + 'raw' => 'RAW / JetDirect (Port 9100)', + 'escpos' => 'ESC/POS (Bondrucker)', + 'lpr' => 'LPR / LPD (RFC 1179)', + ], + ], + 'lpr_queue' => [ + 'typ' => 'text', + 'bezeichnung' => 'LPR Queue-Name (nur LPR)', + 'placeholder' => 'lp', + 'size' => 20, + 'default' => 'lp', + 'info' => 'Standard: lp', + ], + 'timeout' => [ + 'typ' => 'text', + 'bezeichnung' => 'Timeout in Sekunden', + 'placeholder' => '30', + 'size' => '10', + 'default' => '30', + ], + 'auth_username' => [ + 'typ' => 'text', + 'bezeichnung' => 'Benutzername (optional)', + 'placeholder' => '', + 'size' => '30', + ], + 'auth_password' => [ + 'typ' => 'text', + 'bezeichnung' => 'Passwort (optional)', + 'placeholder' => '', + 'size' => '30', + ], + 'duplex' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Duplexdruck', + 'heading' => 'Druckoptionen (nur IPP)', + ], + 'color' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Farbdruck', + ], + 'tray' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierfach', + 'optionen' => [ + 'auto' => 'Automatisch', + 'tray-1' => 'Fach 1', + 'tray-2' => 'Fach 2', + 'manual' => 'Manuell', + ], + ], + 'media' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierformat', + 'optionen' => [ + 'iso_a4_210x297mm' => 'A4', + 'iso_a5_148x210mm' => 'A5', + 'iso_a6_105x148mm' => 'A6', + 'na_letter_8.5x11in' => 'Letter', + 'na_4x6_4x6in' => '4x6 Zoll (Label)', + 'om_100x150mm_100x150mm' => '100x150mm (Label)', + 'om_100x200mm_100x200mm' => '100x200mm (Label)', + ], + 'default' => 'iso_a4_210x297mm', + ], + 'staple' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Heften', + ], + 'label_language' => [ + 'typ' => 'select', + 'bezeichnung' => 'Etikettensprache', + 'heading' => 'Etiketten-Optionen', + 'optionen' => [ + 'zpl' => 'ZPL (Zebra)', + 'epl2' => 'EPL2 (Eltron)', + ], + ], + 'paper_width' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierbreite', + 'heading' => 'Bondrucker-Optionen', + 'optionen' => [ + '80' => '80mm', + '58' => '58mm', + ], + ], + 'auto_cut' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Automatischer Schnitt', + 'default' => '1', + ], + 'batch_2up' => [ + 'typ' => 'checkbox', + 'bezeichnung' => '2 Labels pro A4-Blatt', + 'default' => '', + 'info' => '2 A5-Labels zu einem A4-Blatt zusammenfassen (Hochformat, Label oben + Label unten).', + 'heading' => 'Batch-Druck (nur A4-Dokumentendrucker)', + ], + 'batch_timeout' => [ + 'typ' => 'text', + 'bezeichnung' => 'Wartezeit auf zweites Label (Sek.)', + 'placeholder' => '30', + 'size' => 5, + 'default' => '30', + 'info' => 'Nach dieser Zeit wird ein einzelnes Label allein auf der oberen Haelfte des A4-Blattes gedruckt.', + ], + 'batch_rotation' => [ + 'typ' => 'select', + 'bezeichnung' => 'Label-Rotation', + 'optionen' => [ + 'auto' => 'Automatisch (Hochformat-Labels drehen)', + 'none' => 'Keine Rotation', + 'cw' => '90 Grad im Uhrzeigersinn', + 'ccw' => '90 Grad gegen den Uhrzeigersinn', + ], + 'default' => 'auto', + 'info' => 'Automatisch dreht nur wenn Quell-Orientierung nicht zur A4-Haelfte passt. Teste mit "auto" zuerst.', + ], + 'test_connection' => [ + 'typ' => 'custom', + 'function' => 'renderTestButton', + ], + ]; + } + + /** + * Sendet ein Dokument an den Drucker. + * + * @param string $dokument Dateipfad oder rohe Druckdaten + * @param int $anzahl Anzahl Kopien + * + * @return bool true bei Erfolg, false bei Fehler + */ + public function printDocument($dokument, $anzahl): bool + { + try { + $settings = $this->getResolvedSettings(); + $this->validateSettings($settings); + + // Batch-Modus: zwei A5-Labels auf ein A4-Blatt kombinieren + if (!empty($settings['batch_2up']) && is_file($dokument)) { + $batchResult = $this->processBatch($dokument, $settings); + if ($batchResult === null) { + // Label in Warteschlange gelegt — noch nicht physisch drucken + $this->logInfo( + sprintf( + 'Label in Batch-Warteschlange: Drucker-ID=%d, User=%d', + $this->id, + $this->getUserId() + ) + ); + return true; + } + // Batch fertig: kombiniertes oder einzelnes PDF wurde erzeugt + $dokument = $batchResult; + } + + $data = $this->loadDocumentData($dokument); + $driver = $this->createDriver($settings); + $options = $this->buildPrintOptions($settings, (int)$anzahl); + + $driver->send($data, $options); + + // Temporaere Batch-Output-Datei aufraeumen + if (!empty($settings['batch_2up']) + && strpos(basename($dokument), 'np_out_') === 0 + && is_file($dokument) + ) { + @unlink($dokument); + } + + $this->logInfo( + sprintf( + 'Druckauftrag erfolgreich: Drucker-ID=%d, Protokoll=%s, Host=%s, Kopien=%d', + $this->id, + $settings['protocol'], + $settings['host'], + (int)$anzahl + ) + ); + + return true; + + } catch (PrinterException $e) { + $this->logError( + sprintf( + 'Druckfehler (PrinterException): Drucker-ID=%d — %s', + $this->id, + $e->getMessage() + ) + ); + return false; + + } catch (\Exception $e) { + $this->logError( + sprintf( + 'Druckfehler (Exception): Drucker-ID=%d — %s', + $this->id, + $e->getMessage() + ) + ); + return false; + } + } + + /** + * Fragt den aktuellen Druckerstatus ab. + * + * @return array|null Status-Array oder null bei fehlender Konfiguration + */ + public function getStatus(): ?array + { + $settings = $this->getResolvedSettings(); + if (empty($settings['host'])) { + return null; + } + + $monitor = new StatusMonitor(); + return $monitor->getStatus($settings); + } + + /** + * Fuehrt einen vollstaendigen Verbindungstest durch. + * + * @return array Ergebnis mit success, message, details + */ + public function testConnection(): array + { + $settings = $this->getResolvedSettings(); + + // Validierung auch im Test-Pfad erzwingen (SSRF-Schutz) + try { + $this->validateSettings($settings); + } catch (PrinterConfigException $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + 'details' => [], + ]; + } + + $test = new ConnectionTest(); + return $test->test($settings); + } + + /** + * Rendert den Verbindungsstatus als Inline-Check. + * Wird von PrinterBase::Settings() ueber typ=custom aufgerufen. + * Prueft beim Seitenaufbau ob der Drucker per TCP erreichbar ist + * und zeigt das Ergebnis sofort an. + * + * @return string HTML mit Status-Anzeige + */ + public function renderTestButton(): string + { + $settings = $this->getResolvedSettings(); + $host = $settings['host'] ?? ''; + $port = (int)($settings['port'] ?? 9100); + + if ($host === '') { + return '
' + . 'Hinweis: Bitte IP-Adresse eingeben und speichern.' + . '
'; + } + + // Schneller TCP-Connect-Check (max 3s) + $startTime = microtime(true); + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $host, $port), + $errno, $errstr, 3 + ); + $connectTime = round((microtime(true) - $startTime) * 1000); + + $html = ''; + if ($fp !== false) { + fclose($fp); + $html .= '
' + . '✓ Drucker erreichbar — ' + . htmlspecialchars($host) . ':' . $port + . ' (' . $connectTime . 'ms)' + . '
'; + } else { + $html .= '
' + . '✗ Drucker nicht erreichbar — ' + . htmlspecialchars($host) . ':' . $port + . '
'; + } + + $html .= '
' + . 'Testdruck: Button "Testseite drucken" oben bei Anbindung verwenden.' + . '
'; + + return $html; + } + + /** + * Liest $this->settings und ergaenzt fehlende Felder mit Standardwerten. + * + * @return array Vollstaendige Settings + */ + private function getResolvedSettings(): array + { + $s = is_array($this->settings) ? $this->settings : []; + + if (!isset($s['host'])) { + $s['host'] = ''; + } + if (!isset($s['protocol']) || $s['protocol'] === '') { + $s['protocol'] = Protocol::IPP; + } + if (!isset($s['port']) || (int)$s['port'] === 0) { + $s['port'] = Protocol::getDefaultPort($s['protocol']); + } + $s['timeout'] = (int)($s['timeout'] ?? 30); + if ($s['timeout'] < 1 || $s['timeout'] > 300) { + $s['timeout'] = 30; + } + if (!isset($s['auth_username'])) { + $s['auth_username'] = ''; + } + if (!isset($s['auth_password'])) { + $s['auth_password'] = ''; + } + + return $s; + } + + /** + * Prueft die Mindestanforderungen an die Konfiguration. + * + * @param array $settings + * + * @throws PrinterConfigException + */ + private function validateSettings(array $settings): void + { + if (empty($settings['host'])) { + throw new PrinterConfigException('Keine IP-Adresse konfiguriert'); + } + + if (!Protocol::isValid($settings['protocol'])) { + throw new PrinterConfigException( + sprintf('Ungueltiges Protokoll: %s', $settings['protocol']) + ); + } + + // Port-Validierung + $port = (int)$settings['port']; + if ($port < 1 || $port > 65535) { + throw new PrinterConfigException( + sprintf('Ungueltiger Port: %d (erlaubt: 1-65535)', $port) + ); + } + + // Host-Validierung: Loopback und Metadaten-Endpoints blockieren + $host = $settings['host']; + + // Bekannte Metadaten-Hostnames blockieren + $blockedHostnames = ['metadata.google.internal', 'metadata']; + if (in_array(strtolower($host), $blockedHostnames, true)) { + throw new PrinterConfigException( + sprintf('Blockierter Host: %s', $host) + ); + } + + // Hostname zu IP aufloesen fuer Validierung + $resolvedIp = filter_var($host, FILTER_VALIDATE_IP) + ? $host + : gethostbyname($host); + + // Wenn Aufloesung fehlschlaegt, gibt gethostbyname den Input zurueck + if ($resolvedIp === $host && !filter_var($host, FILTER_VALIDATE_IP)) { + throw new PrinterConfigException( + sprintf('Hostname nicht aufloesbar: %s', $host) + ); + } + + // IP normalisieren und pruefen + $ipLong = ip2long($resolvedIp); + if ($ipLong !== false) { + // Loopback: 127.0.0.0/8 + if (($ipLong >> 24) === 127) { + throw new PrinterConfigException('Loopback-Adressen sind nicht erlaubt'); + } + // Link-local: 169.254.0.0/16 + if (($ipLong >> 16) === (169 * 256 + 254)) { + throw new PrinterConfigException('Link-Local-Adressen sind nicht erlaubt'); + } + } else { + // IPv6 pruefen + $lowerHost = strtolower($resolvedIp); + if ($lowerHost === '::1' + || strpos($lowerHost, '::ffff:127.') === 0 + || strpos($lowerHost, '::ffff:7f') === 0 + || $lowerHost === '::ffff:169.254.169.254' + ) { + throw new PrinterConfigException('Loopback/Link-Local IPv6-Adressen sind nicht erlaubt'); + } + } + } + + /** + * Laedt den Inhalt des Druckdokuments. + * Ist $dokument ein vorhandener Dateipfad, wird der Inhalt gelesen. + * Andernfalls werden die Daten unveraendert durchgereicht. + * + * @param string $dokument + * + * @return string + */ + private function loadDocumentData(string $dokument): string + { + if (is_file($dokument)) { + // Dateien ueber 100 MB ablehnen (Speicherschutz) + $size = filesize($dokument); + if ($size !== false && $size > 104857600) { + throw new PrinterConfigException( + sprintf('Dokument zu gross: %d Bytes (max. 100 MB)', $size) + ); + } + $content = file_get_contents($dokument); + if ($content === false) { + throw new PrinterConfigException( + sprintf('Dokument nicht lesbar: %s', basename($dokument)) + ); + } + return $content; + } + + // Rohdaten (z.B. ESC/POS-Stream direkt als String uebergeben) + return $dokument; + } + + /** + * Verarbeitet ein Label im Batch-Modus. + * + * @param string $pdfPath + * @param array $settings + * + * @return string|null Pfad zum finalen PDF, oder null wenn gequeued + */ + private function processBatch(string $pdfPath, array $settings): ?string + { + $timeout = (int)($settings['batch_timeout'] ?? 30); + if ($timeout < 1) { + $timeout = 30; + } + + $rotation = isset($settings['batch_rotation']) ? (string)$settings['batch_rotation'] : 'auto'; + if (!in_array($rotation, ['auto', 'none', 'cw', 'ccw'], true)) { + $rotation = 'auto'; + } + + $queueDir = $this->getQueueDir(); + $userId = $this->getUserId(); + + $batcher = new PdfBatcher($queueDir, (int)$this->id, $userId, $timeout); + $batcher->setRotation($rotation); + + return $batcher->process($pdfPath); + } + + /** + * @return int + */ + private function getUserId(): int + { + if (isset($this->app->User) && method_exists($this->app->User, 'GetID')) { + return (int)$this->app->User->GetID(); + } + return 0; + } + + /** + * @return string + */ + private function getQueueDir(): string + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'GetTMP')) { + $tmp = (string)$this->app->erp->GetTMP(); + if ($tmp !== '') { + return rtrim($tmp, '/\\'); + } + } + return sys_get_temp_dir(); + } + + /** + * Erzeugt den passenden Treiber anhand des konfigurierten Protokolls. + * + * @param array $settings + * + * @return DriverInterface + * + * @throws PrinterConfigException + */ + private function createDriver(array $settings): DriverInterface + { + $host = (string)$settings['host']; + $port = (int)$settings['port']; + $timeout = (int)$settings['timeout']; + $username = (string)$settings['auth_username']; + $password = (string)$settings['auth_password']; + + switch ($settings['protocol']) { + case Protocol::IPP: + return new IppDriver($host, $port, $timeout, $username, $password); + + case Protocol::RAW: + return new RawDriver($host, $port, $timeout); + + case Protocol::ESCPOS: + return new EscPosDriver($host, $port, $timeout); + + case Protocol::LPR: + $queue = $settings['lpr_queue'] ?? 'lp'; + return new LprDriver($host, $port, $timeout, $queue); + + default: + throw new PrinterConfigException( + 'Netzwerkdrucker: Unbekanntes Protokoll "' . $settings['protocol'] . '"' + ); + } + } + + /** + * Sammelt Druckoptionen aus den Settings und der Auftragsanzahl. + * + * @param array $settings + * @param int $copies + * + * @return array + */ + private function buildPrintOptions(array $settings, int $copies): array + { + $options = []; + + $options['copies'] = max(1, $copies); + + if (!empty($settings['duplex'])) { + $options['duplex'] = true; + } + if (!empty($settings['color'])) { + $options['color'] = true; + } + if (!empty($settings['media'])) { + $options['media'] = (string)$settings['media']; + } + if (!empty($settings['tray']) && $settings['tray'] !== 'auto') { + $options['tray'] = (string)$settings['tray']; + } + if (!empty($settings['staple'])) { + $options['staple'] = true; + } + if (!empty($settings['paper_width'])) { + $options['paper_width'] = (string)$settings['paper_width']; + } + if (isset($settings['auto_cut'])) { + $options['auto_cut'] = (bool)$settings['auto_cut']; + } + + return $options; + } + + /** + * Schreibt eine Info-Meldung ins Drucker-Log. + * + * @param string $message + */ + private function logInfo(string $message): void + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'LogFile')) { + $this->app->erp->LogFile('printer_network', $message); + } + } + + /** + * Schreibt eine Fehlermeldung ins Fehler-Log. + * + * @param string $message + */ + private function logError(string $message): void + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'LogFile')) { + $this->app->erp->LogFile('printer_error', $message); + } + } +} diff --git a/www/lib/Printer/NetworkPrinter/PrinterType.php b/www/lib/Printer/NetworkPrinter/PrinterType.php new file mode 100644 index 000000000..d183d663d --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/PrinterType.php @@ -0,0 +1,32 @@ + Empfohlenes Protokoll pro Typ */ + const DEFAULT_PROTOCOLS = [ + self::DOCUMENT => Protocol::IPP, + self::LABEL => Protocol::RAW, + self::RECEIPT => Protocol::ESCPOS, + ]; + + /** @var array Mapping von drucker.art (DB) auf PrinterType */ + const FROM_DB_ART = [ + 0 => self::DOCUMENT, + 2 => self::LABEL, + ]; + + /** + * @param string $type + * @return string + */ + public static function getDefaultProtocol(string $type): string + { + return self::DEFAULT_PROTOCOLS[$type] ?? Protocol::RAW; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Protocol.php b/www/lib/Printer/NetworkPrinter/Protocol.php new file mode 100644 index 000000000..7597500ab --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Protocol.php @@ -0,0 +1,38 @@ + Default-Ports pro Protokoll */ + const DEFAULT_PORTS = [ + self::IPP => 631, + self::RAW => 9100, + self::ESCPOS => 9100, + self::LPR => 515, + ]; + + /** + * @param string $protocol + * @return bool + */ + public static function isValid(string $protocol): bool + { + return in_array($protocol, [self::IPP, self::RAW, self::ESCPOS, self::LPR], true); + } + + /** + * @param string $protocol + * @return int + */ + public static function getDefaultPort(string $protocol): int + { + return self::DEFAULT_PORTS[$protocol] ?? 9100; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Status/IppStatus.php b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php new file mode 100644 index 000000000..bd3bd8f47 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php @@ -0,0 +1,108 @@ + 'idle', + 4 => 'printing', + 5 => 'stopped', + ]; + $state = isset($stateMap[$printerStateRaw]) ? $stateMap[$printerStateRaw] : 'unknown'; + + $stateReasons = isset($attrs['printer-state-reasons']) + ? $attrs['printer-state-reasons'] + : null; + if ($stateReasons !== null && !is_array($stateReasons)) { + $stateReasons = [$stateReasons]; + } + + $acceptingJobs = isset($attrs['printer-is-accepting-jobs']) + ? (bool)$attrs['printer-is-accepting-jobs'] + : false; + + $queuedJobs = isset($attrs['queued-job-count']) + ? (int)$attrs['queued-job-count'] + : 0; + + $name = isset($attrs['printer-name']) + ? (string)$attrs['printer-name'] + : ''; + + $model = isset($attrs['printer-make-and-model']) + ? (string)$attrs['printer-make-and-model'] + : ''; + + return [ + 'online' => true, + 'name' => $name, + 'model' => $model, + 'state' => $state, + 'state_reasons' => $stateReasons, + 'accepting_jobs' => $acceptingJobs, + 'queued_jobs' => $queuedJobs, + 'source' => 'ipp', + ]; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php new file mode 100644 index 000000000..af68a5d77 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php @@ -0,0 +1,211 @@ +exceptions_enabled = true; + + $stateRaw = $this->snmpGetValue($session, self::OID_PRINTER_STATUS); + $stateInt = ($stateRaw !== null) ? (int)$this->cleanSnmpValue($stateRaw) : 2; + + $stateMap = [ + 1 => 'other', + 2 => 'unknown', + 3 => 'idle', + 4 => 'printing', + 5 => 'warmup', + ]; + $state = isset($stateMap[$stateInt]) ? $stateMap[$stateInt] : 'unknown'; + + $nameRaw = $this->snmpGetValue($session, self::OID_PRINTER_NAME); + $serialRaw = $this->snmpGetValue($session, self::OID_SERIAL_NUMBER); + + $name = ($nameRaw !== null) ? $this->cleanSnmpValue($nameRaw) : ''; + $serial = ($serialRaw !== null) ? $this->cleanSnmpValue($serialRaw) : ''; + + $supplies = $this->querySupplies($session); + + $paperLevel = null; + $paperMax = null; + $paperRaw = $this->snmpGetValue($session, self::OID_INPUT_CURRENT_LEVEL); + $paperMaxRaw = $this->snmpGetValue($session, self::OID_INPUT_MAX_CAPACITY); + if ($paperRaw !== null) { + $paperLevel = (int)$this->cleanSnmpValue($paperRaw); + } + if ($paperMaxRaw !== null) { + $paperMax = (int)$this->cleanSnmpValue($paperMaxRaw); + } + + $paper = null; + if ($paperLevel !== null && $paperMax !== null && $paperMax > 0) { + $paper = [ + 'level' => $paperLevel, + 'max' => $paperMax, + 'percent' => round(($paperLevel / $paperMax) * 100, 1), + ]; + } elseif ($paperLevel !== null) { + $paper = [ + 'level' => $paperLevel, + 'max' => $paperMax, + 'percent' => null, + ]; + } + + $pageCountRaw = $this->snmpGetValue($session, self::OID_PAGE_COUNT); + $pageCount = ($pageCountRaw !== null) + ? (int)$this->cleanSnmpValue($pageCountRaw) + : null; + + $session->close(); + + return [ + 'online' => true, + 'source' => 'snmp', + 'state' => $state, + 'name' => $name, + 'serial' => $serial, + 'supplies' => $supplies, + 'paper' => $paper, + 'page_count' => $pageCount, + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Fragt Toner/Tinten-Level ab (alle Farben). + * + * @param \SNMP $session Aktive SNMP-Session + * + * @return array + */ + private function querySupplies(\SNMP $session): array + { + $supplies = []; + + try { + $descs = @$session->walk(self::OID_MARKER_SUPPLIES_DESC); + $maxes = @$session->walk(self::OID_MARKER_SUPPLIES_MAX); + $levels = @$session->walk(self::OID_MARKER_SUPPLIES_LEVEL); + + if (!is_array($descs) || empty($descs)) { + return []; + } + + $descValues = array_values($descs); + $maxValues = is_array($maxes) ? array_values($maxes) : []; + $levelValues = is_array($levels) ? array_values($levels) : []; + + foreach ($descValues as $i => $descRaw) { + $desc = $this->cleanSnmpValue((string)$descRaw); + $max = isset($maxValues[$i]) + ? (int)$this->cleanSnmpValue((string)$maxValues[$i]) + : null; + $level = isset($levelValues[$i]) + ? (int)$this->cleanSnmpValue((string)$levelValues[$i]) + : null; + + $percent = null; + if ($level !== null && $max !== null && $max > 0) { + $percent = round(($level / $max) * 100, 1); + } + + $supplies[] = [ + 'description' => $desc, + 'level' => $level, + 'max' => $max, + 'percent' => $percent, + ]; + } + } catch (\Exception $e) { + // Return whatever was collected + } + + return $supplies; + } + + /** + * Holt einen einzelnen OID-Wert mit Fehlerunterdrueckung. + * + * @param \SNMP $session Aktive SNMP-Session + * @param string $oid Abzufragender OID + * + * @return string|null Rohwert oder null bei Fehler + */ + private function snmpGetValue(\SNMP $session, string $oid): ?string + { + try { + $result = @$session->get($oid); + if ($result === false || $result === null) { + return null; + } + return (string)$result; + } catch (\Exception $e) { + return null; + } + } + + /** + * Entfernt SNMP-Typ-Prefixe wie STRING:, INTEGER: etc. + * + * Beispiele: + * 'STRING: "HP LaserJet"' -> 'HP LaserJet' + * 'INTEGER: 3' -> '3' + * + * @param string $value Rohwert-String + * + * @return string Bereinigter Wert + */ + private function cleanSnmpValue(string $value): string + { + // Remove type prefix (e.g. "STRING: ", "INTEGER: ", "Gauge32: ", etc.) + $value = preg_replace('/^[A-Za-z0-9]+:\s*/', '', $value); + // Remove surrounding quotes + $value = trim($value, '"'); + return trim($value); + } +} diff --git a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php new file mode 100644 index 000000000..7cca5fac2 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php @@ -0,0 +1,154 @@ +ippStatus = $ippStatus ?? new IppStatus(); + $this->snmpStatus = $snmpStatus ?? new SnmpStatus(); + } + + /** + * Fragt den vollstaendigen Druckerstatus ab. + * + * @param array $settings Drucker-Einstellungen (aus drucker.json) + * + * @return array Status-Array mit Keys: online, name, model, state, + * supplies, paper, page_count, source, snmp_available + */ + public function getStatus(array $settings): array + { + $host = isset($settings['host']) ? (string)$settings['host'] : ''; + $port = isset($settings['port']) ? (int)$settings['port'] : 631; + $protocol = isset($settings['protocol']) ? (string)$settings['protocol'] : 'ipp'; + + $username = $settings['auth_username'] ?? ($settings['auth']['username'] ?? ''); + $password = $settings['auth_password'] ?? ($settings['auth']['password'] ?? ''); + + $ippPath = isset($settings['path']) ? (string)$settings['path'] : '/ipp/print'; + + $result = [ + 'online' => false, + 'name' => '', + 'model' => '', + 'state' => 'unknown', + 'supplies' => [], + 'paper' => null, + 'page_count' => null, + 'source' => null, + 'snmp_available' => SnmpStatus::isExtensionAvailable(), + ]; + + if ($host === '') { + return $result; + } + + $ippResult = null; + $snmpResult = null; + + // Step 1: Try IPP if protocol is ipp + if ($protocol === 'ipp') { + $ippResult = $this->ippStatus->query($host, $port, $username, $password, $ippPath); + if ($ippResult !== null) { + $result['online'] = (bool)$ippResult['online']; + $result['name'] = (string)$ippResult['name']; + $result['model'] = (string)$ippResult['model']; + $result['state'] = (string)$ippResult['state']; + $result['source'] = 'ipp'; + } + } + + // Step 2: Always try SNMP — merges supplies/paper/page_count + $snmpCommunity = isset($settings['snmp_community']) + ? (string)$settings['snmp_community'] + : 'public'; + $snmpResult = $this->snmpStatus->query($host, $snmpCommunity); + + if ($snmpResult !== null) { + // If IPP did not succeed, use SNMP for online/state/name + if (!$result['online']) { + $result['online'] = (bool)$snmpResult['online']; + $result['state'] = (string)$snmpResult['state']; + if ($result['name'] === '' && isset($snmpResult['name'])) { + $result['name'] = (string)$snmpResult['name']; + } + if ($result['source'] === null) { + $result['source'] = 'snmp'; + } + } + + // Always merge SNMP-only fields + if (!empty($snmpResult['supplies'])) { + $result['supplies'] = $snmpResult['supplies']; + } + if ($snmpResult['paper'] !== null) { + $result['paper'] = $snmpResult['paper']; + } + if ($snmpResult['page_count'] !== null) { + $result['page_count'] = $snmpResult['page_count']; + } + } + + // Step 3: TCP fallback if still not online + if (!$result['online']) { + $tcpOnline = $this->checkTcpConnect($host, $port, 3); + if ($tcpOnline) { + $result['online'] = true; + if ($result['source'] === null) { + $result['source'] = 'tcp'; + } + } + } + + return $result; + } + + /** + * Checks whether a TCP connection can be established. + * + * @param string $host Hostname or IP + * @param int $port TCP port + * @param int $timeout Timeout in seconds + * + * @return bool + */ + private function checkTcpConnect(string $host, int $port, int $timeout): bool + { + try { + $address = sprintf('tcp://%s:%d', $host, $port); + $socket = @stream_socket_client( + $address, + $errno, + $errstr, + $timeout, + STREAM_CLIENT_CONNECT + ); + if ($socket !== false) { + fclose($socket); + return true; + } + } catch (\Exception $e) { + // Fall through + } + return false; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php new file mode 100644 index 000000000..d04d1fc7a --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php @@ -0,0 +1,125 @@ +statusMonitor = new StatusMonitor(); + } + + /** + * Fuehrt einen vollstaendigen Verbindungstest durch. + * + * @param array $settings JSON-Settings aus drucker.json + * @return array Ergebnis mit: success, message, details + */ + public function test(array $settings): array + { + $host = $settings['host'] ?? ''; + $port = (int)($settings['port'] ?? 9100); + + if ($host === '') { + return [ + 'success' => false, + 'message' => 'Keine IP-Adresse konfiguriert', + 'details' => [], + ]; + } + + $result = [ + 'success' => false, + 'message' => '', + 'details' => [], + ]; + + // 1. TCP-Connect-Check + $startTime = microtime(true); + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $host, $port), + $errno, $errstr, 3 + ); + $connectTime = round((microtime(true) - $startTime) * 1000); + + if ($fp === false) { + $result['message'] = sprintf( + 'Drucker nicht erreichbar: %s:%d', + $host, $port + ); + return $result; + } + + fclose($fp); + $result['details']['connect_time_ms'] = $connectTime; + $result['details']['tcp'] = 'OK'; + + // 2. Status-Abfrage + $status = $this->statusMonitor->getStatus($settings); + + $result['success'] = true; + $result['details']['online'] = $status['online']; + $result['details']['source'] = $status['source']; + + // Nachricht zusammenbauen + $parts = []; + $parts[] = 'Verbunden'; + + if (!empty($status['name'])) { + $parts[] = $status['name']; + } + if (!empty($status['model'])) { + $parts[] = $status['model']; + } + if (!empty($status['state']) && $status['state'] !== 'unknown') { + $stateLabels = [ + 'idle' => 'Bereit', + 'printing' => 'Druckt', + 'stopped' => 'Angehalten', + 'warmup' => 'Aufwaermphase', + ]; + $parts[] = 'Status: ' . ($stateLabels[$status['state']] ?? $status['state']); + } + + $result['message'] = implode(' — ', $parts); + + // Toner-Info + if (!empty($status['supplies'])) { + $tonerParts = []; + foreach ($status['supplies'] as $supply) { + $percent = isset($supply['percent']) ? $supply['percent'] : 0; + $tonerParts[] = sprintf('%s: %d%%', $supply['description'], $percent); + } + $result['details']['supplies'] = implode(', ', $tonerParts); + } + + // Papier-Info + if (!empty($status['paper'])) { + $result['details']['paper'] = sprintf( + '%d/%d (%d%%)', + $status['paper']['level'], + $status['paper']['max'], + $status['paper']['percent'] + ); + } + + // Seitenzaehler + if (isset($status['page_count'])) { + $result['details']['page_count'] = $status['page_count']; + } + + // SNMP-Hinweis + if (!$status['snmp_available']) { + $result['details']['snmp_hint'] = 'PHP-Extension snmp nicht installiert — erweiterter Status nicht verfuegbar'; + } + + return $result; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php new file mode 100644 index 000000000..1551c8581 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -0,0 +1,475 @@ + 1) { + $body .= self::encodeIntegerAttribute('copies', (int)$options['copies']); + } + + // duplex -> sides + if (!empty($options['duplex'])) { + $sides = 'two-sided-long-edge'; + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'sides', $sides); + } else { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'sides', 'one-sided'); + } + + // color -> print-color-mode + if (isset($options['color'])) { + $colorMode = $options['color'] ? 'color' : 'monochrome'; + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'print-color-mode', $colorMode); + } + + // media + if (!empty($options['media'])) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'media', (string)$options['media']); + } + + // tray -> media-source + if (!empty($options['tray'])) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'media-source', (string)$options['tray']); + } + + // staple -> finishings (enum) + if (!empty($options['staple'])) { + $body .= self::encodeEnumAttribute('finishings', self::FINISHING_STAPLE); + } + + // orientation -> orientation-requested (enum) + if (!empty($options['orientation'])) { + $orientation = (strtolower((string)$options['orientation']) === 'landscape') + ? self::ORIENTATION_LANDSCAPE + : self::ORIENTATION_PORTRAIT; + $body .= self::encodeEnumAttribute('orientation-requested', $orientation); + } + + // End of attributes + $body .= pack('C', self::TAG_END_ATTRS); + + return $body; + } + + /** + * Baut einen Get-Printer-Attributes IPP-Request. + * + * @param string $printerUri Vollstaendiger IPP-URI + * @param array $requestedAttributes Liste der angeforderten Attributnamen (leer = alle) + * + * @return string Binaerer IPP-Request + */ + public static function buildGetPrinterAttributesRequest(string $printerUri, array $requestedAttributes = []): string + { + static $requestId = 100; + $requestId++; + + $body = pack('CCnN', 0x01, 0x01, self::OP_GET_PRINTER_ATTRS, $requestId); + + $body .= pack('C', self::TAG_OPERATION_ATTRS); + $body .= self::encodeAttribute(self::TAG_CHARSET, 'attributes-charset', 'utf-8'); + $body .= self::encodeAttribute(self::TAG_NATURAL_LANG, 'attributes-natural-language', 'en'); + $body .= self::encodeAttribute(self::TAG_URI, 'printer-uri', $printerUri); + $body .= self::encodeAttribute(self::TAG_NAME_WITHOUT_LANG, 'requesting-user-name', 'OpenXE'); + + // requested-attributes (multi-value: first has full name, subsequent have name-length=0) + if (!empty($requestedAttributes)) { + $first = true; + foreach ($requestedAttributes as $attrName) { + if ($first) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'requested-attributes', (string)$attrName); + $first = false; + } else { + // Additional values: value-tag + name-length=0 + value + $val = (string)$attrName; + if (strlen($val) > 65535) { + $val = substr($val, 0, 65535); + } + $body .= pack('C', self::TAG_KEYWORD); + $body .= pack('n', 0); // name-length = 0 (additional value) + $body .= pack('n', strlen($val)) . $val; + } + } + } + + $body .= pack('C', self::TAG_END_ATTRS); + + return $body; + } + + /** + * Sendet einen IPP-Request per HTTP POST. + * + * @param string $host Druckerhostname oder IP-Adresse + * @param int $port TCP-Port (normalerweise 631) + * @param string $path HTTP-Pfad (z.B. '/ipp/print') + * @param string $ippData Roher binaerer IPP-Datenstrom + * @param string $username Optionaler Benutzername fuer HTTP-Auth + * @param string $password Optionales Passwort fuer HTTP-Auth + * @param int $timeout Request-Timeout in Sekunden + * + * @return string Roher HTTP-Response-Body + * + * @throws PrinterConnectionException Bei Curl-Fehler + * @throws PrinterProtocolException Bei nicht-200 HTTP-Status + */ + public static function sendRequest( + string $host, + int $port, + string $path, + string $ippData, + string $username = '', + string $password = '', + int $timeout = 30 + ): string { + $url = sprintf('http://%s:%d%s', $host, $port, $path); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $ippData); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/ipp']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if ($username !== '') { + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC | CURLAUTH_DIGEST); + curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password); + } + + $response = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + $curlErrno = curl_errno($ch); + curl_close($ch); + + if ($response === false) { + throw new PrinterConnectionException( + sprintf('IPP curl-Fehler fuer %s: %s (%d)', $url, $curlError, $curlErrno) + ); + } + + if ($httpCode !== 200) { + throw new PrinterProtocolException( + sprintf('IPP HTTP-Fehler: Status %d fuer %s', $httpCode, $url) + ); + } + + return $response; + } + + /** + * Parst eine IPP-Response und gibt Status + Attribute zurueck. + * + * @param string $response Rohe binaere IPP-Response + * + * @return array ['status_code' => int, 'status_ok' => bool, 'attributes' => array] + */ + public static function parseResponse(string $response): array + { + if (strlen($response) < 8) { + return ['status_code' => -1, 'status_ok' => false, 'attributes' => []]; + } + + $offset = 0; + + // Version: 2 bytes + $versionMajor = ord($response[$offset]); + $versionMinor = ord($response[$offset + 1]); + $offset += 2; + + // Status-code: 2 bytes big-endian + $unpacked = unpack('n', substr($response, $offset, 2)); + $statusCode = $unpacked[1]; + $offset += 2; + + // Request-id: 4 bytes (skip) + $offset += 4; + + $attributes = []; + $currentGroup = ''; + + while ($offset < strlen($response)) { + $tag = ord($response[$offset]); + $offset++; + + // Group delimiter tags + if ($tag === self::TAG_OPERATION_ATTRS) { + $currentGroup = 'operation'; + continue; + } + if ($tag === self::TAG_JOB_ATTRS) { + $currentGroup = 'job'; + continue; + } + if ($tag === self::TAG_PRINTER_ATTRS) { + $currentGroup = 'printer'; + continue; + } + if ($tag === self::TAG_END_ATTRS) { + break; + } + + // Need at least 2 bytes for name-length + if ($offset + 2 > strlen($response)) { + break; + } + + $nameLenUnpacked = unpack('n', substr($response, $offset, 2)); + $nameLen = $nameLenUnpacked[1]; + $offset += 2; + + $attrName = ''; + if ($nameLen > 0) { + if ($offset + $nameLen > strlen($response)) { + break; + } + $attrName = substr($response, $offset, $nameLen); + $offset += $nameLen; + } + + // Value-length: 2 bytes + if ($offset + 2 > strlen($response)) { + break; + } + $valLenUnpacked = unpack('n', substr($response, $offset, 2)); + $valLen = $valLenUnpacked[1]; + $offset += 2; + + $rawValue = ''; + if ($valLen > 0) { + if ($offset + $valLen > strlen($response)) { + break; + } + $rawValue = substr($response, $offset, $valLen); + $offset += $valLen; + } + + $decodedValue = self::decodeValue($tag, $rawValue); + + if ($attrName !== '') { + // First occurrence of this attribute name + $key = $currentGroup . '.' . $attrName; + if (isset($attributes[$key])) { + // Already exists -> convert to array + if (!is_array($attributes[$key])) { + $attributes[$key] = [$attributes[$key]]; + } + $attributes[$key][] = $decodedValue; + } else { + $attributes[$key] = $decodedValue; + } + // Also store without group prefix for convenience + if (!isset($attributes[$attrName])) { + $attributes[$attrName] = $decodedValue; + } elseif (!is_array($attributes[$attrName])) { + $attributes[$attrName] = [$attributes[$attrName], $decodedValue]; + } else { + $attributes[$attrName][] = $decodedValue; + } + } else { + // Additional value for previous attribute (name-length=0) + // Append to the last known attribute + end($attributes); + $lastKey = key($attributes); + if ($lastKey !== null) { + if (!is_array($attributes[$lastKey])) { + $attributes[$lastKey] = [$attributes[$lastKey]]; + } + $attributes[$lastKey][] = $decodedValue; + } + } + } + + $statusOk = ($statusCode === self::STATUS_OK || $statusCode === self::STATUS_OK_IGNORED_SUBSTITUTED); + + return [ + 'status_code' => $statusCode, + 'status_ok' => $statusOk, + 'attributes' => $attributes, + ]; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Encodes a single IPP attribute (string value) as binary. + * + * Format: value-tag (1B) + name-length (2B BE) + name + value-length (2B BE) + value + * + * @param int $tag Value-tag byte + * @param string $name Attribute name + * @param string $value Attribute value (string) + * + * @return string Binary encoded attribute + */ + private static function encodeAttribute(int $tag, string $name, string $value): string + { + // IPP-Spec: Attribute-Werte duerfen max 1023 Bytes lang sein (name-without-language) + // pack('n', ...) erlaubt max 65535 — Overflow verhindern + if (strlen($value) > 65535) { + $value = substr($value, 0, 65535); + } + if (strlen($name) > 65535) { + $name = substr($name, 0, 65535); + } + + return chr($tag) + . pack('n', strlen($name)) + . $name + . pack('n', strlen($value)) + . $value; + } + + /** + * Encodes an IPP integer attribute (4 bytes big-endian). + * + * @param string $name Attribute name + * @param int $value Integer value + * + * @return string Binary encoded attribute + */ + private static function encodeIntegerAttribute(string $name, int $value): string + { + return pack('C', self::TAG_INTEGER) + . pack('n', strlen($name)) . $name + . pack('n', 4) + . pack('N', $value); + } + + /** + * Encodes an IPP enum attribute (4 bytes big-endian, same wire format as integer). + * + * @param string $name Attribute name + * @param int $value Enum value + * + * @return string Binary encoded attribute + */ + private static function encodeEnumAttribute(string $name, int $value): string + { + return pack('C', self::TAG_ENUM) + . pack('n', strlen($name)) . $name + . pack('n', 4) + . pack('N', $value); + } + + /** + * Decodes a raw binary value based on its value-tag. + * + * @param int $tag Value-tag byte + * @param string $rawValue Raw binary value + * + * @return mixed Decoded PHP value (int, bool, or string) + */ + private static function decodeValue(int $tag, string $rawValue) + { + switch ($tag) { + case self::TAG_INTEGER: + if (strlen($rawValue) < 4) { + return 0; + } + $u = unpack('N', $rawValue); + $val = (int)$u[1]; + // Handle signed 32-bit + if ($val >= 0x80000000) { + $val -= 0x100000000; + } + return $val; + + case self::TAG_ENUM: + if (strlen($rawValue) < 4) { + return 0; + } + $u = unpack('N', $rawValue); + return (int)$u[1]; + + case self::TAG_BOOLEAN: + return strlen($rawValue) > 0 && ord($rawValue[0]) !== 0x00; + + case self::TAG_TEXT_WITHOUT_LANG: + case self::TAG_NAME_WITHOUT_LANG: + case self::TAG_KEYWORD: + case self::TAG_URI: + case self::TAG_CHARSET: + case self::TAG_NATURAL_LANG: + case self::TAG_MIME_TYPE: + return $rawValue; + + default: + return $rawValue; + } + } +} diff --git a/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php b/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php new file mode 100644 index 000000000..13813e096 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php @@ -0,0 +1,406 @@ +queueDir = rtrim($queueDir, '/\\'); + $this->printerId = $printerId; + $this->userId = $userId; + $this->timeout = $timeout; + } + + /** + * Setzt den Rotation-Modus fuer Label-Platzierung. + * + * @param string $rotation auto|none|cw|ccw + */ + public function setRotation(string $rotation): void + { + if (in_array($rotation, ['auto', 'none', 'cw', 'ccw'], true)) { + $this->rotation = $rotation; + } + } + + /** + * Verarbeitet ein Label und gibt den Pfad zum finalen PDF zurueck + * (entweder ein kombiniertes A4 mit 2 Labels oder null wenn das Label + * in die Queue gestellt wurde und noch nicht gedruckt werden soll). + * + * @param string $currentPdfPath Pfad zum aktuellen Label-PDF + * + * @return string|null Pfad zum zu druckenden PDF, oder null wenn Label gequeued wurde + */ + public function process(string $currentPdfPath): ?string + { + $pendingFile = $this->getPendingFile(); + $timestampFile = $this->getTimestampFile(); + + // Pruefen ob pending Label existiert + if (is_file($pendingFile) && is_file($timestampFile)) { + $pendingTs = (int)@file_get_contents($timestampFile); + $age = time() - $pendingTs; + + if ($age < $this->timeout) { + // Beide Labels kombinieren + $combined = $this->combineTwoLabels($pendingFile, $currentPdfPath); + $this->clearQueue(); + return $combined; + } + + // Stale: altes Label einzeln drucken lassen, aktuelles als neues pending + $staleSingle = $this->combineSingleLabel($pendingFile); + $this->storePending($currentPdfPath); + return $staleSingle; + } + + // Kein pending: aktuelles Label in Queue legen + $this->storePending($currentPdfPath); + return null; + } + + /** + * Gibt alle abgelaufenen Queue-Eintraege als Liste zurueck und + * leert diese Eintraege. Sollte periodisch oder per Cron aufgerufen + * werden um verwaiste Labels nach langer Inaktivitaet zu drucken. + * + * @return array Liste von [printer_id, user_id, pdf_path] Arrays + */ + public function flushStaleGlobal(): array + { + $flushed = []; + if (!is_dir($this->queueDir)) { + return $flushed; + } + + $handle = @opendir($this->queueDir); + if ($handle === false) { + return $flushed; + } + + $now = time(); + while (false !== ($file = readdir($handle))) { + if (substr($file, -3) !== '.ts') { + continue; + } + if (strpos($file, 'np_batch_') !== 0) { + continue; + } + $tsPath = $this->queueDir . DIRECTORY_SEPARATOR . $file; + $pdfPath = substr($tsPath, 0, -3) . '.pdf'; + + $ts = (int)@file_get_contents($tsPath); + if ($now - $ts < $this->timeout) { + continue; + } + if (!is_file($pdfPath)) { + @unlink($tsPath); + continue; + } + + // Dateiname parsen: np_batch_{printerId}_{userId}.ts + $base = basename($file, '.ts'); + $parts = explode('_', $base); + if (count($parts) !== 4) { + continue; + } + + $singlePdf = $this->combineSingleLabel($pdfPath); + $flushed[] = [ + 'printer_id' => (int)$parts[2], + 'user_id' => (int)$parts[3], + 'pdf_path' => $singlePdf, + ]; + + @unlink($pdfPath); + @unlink($tsPath); + } + closedir($handle); + + return $flushed; + } + + /** + * Leert die Queue fuer den aktuellen Drucker+User. + */ + public function clearQueue(): void + { + @unlink($this->getPendingFile()); + @unlink($this->getTimestampFile()); + } + + /** + * Speichert das aktuelle Label als pending in der Queue. + * + * @param string $pdfPath + */ + private function storePending(string $pdfPath): void + { + if (!is_dir($this->queueDir)) { + @mkdir($this->queueDir, 0755, true); + } + @copy($pdfPath, $this->getPendingFile()); + @file_put_contents($this->getTimestampFile(), (string)time()); + } + + /** + * Kombiniert zwei A5-Labels auf ein A4-Blatt (Hochformat). + * Label 1 oben, Label 2 unten. + * + * @param string $pdf1 Erstes Label-PDF + * @param string $pdf2 Zweites Label-PDF + * + * @return string Pfad zum kombinierten A4-PDF + */ + private function combineTwoLabels(string $pdf1, string $pdf2): string + { + $pdf = $this->createFpdi(); + $pdf->AddPage('P', 'A4'); + + // A4 Hochformat: 210 breit, 297 hoch + // Obere Haelfte: (0, 0) bis (210, 148.5) + // Untere Haelfte: (0, 148.5) bis (210, 297) + $this->placeLabelOnPage($pdf, $pdf1, 0, 0, 210, 148.5); + $this->placeLabelOnPage($pdf, $pdf2, 0, 148.5, 210, 148.5); + + $outPath = $this->makeOutputPath('combined'); + $pdf->Output($outPath, 'F'); + return $outPath; + } + + /** + * Erstellt ein A4-PDF mit einem einzelnen Label auf der oberen Haelfte. + * + * @param string $pdfPath Label-PDF + * + * @return string Pfad zum A4-PDF + */ + private function combineSingleLabel(string $pdfPath): string + { + $pdf = $this->createFpdi(); + $pdf->AddPage('P', 'A4'); + + $this->placeLabelOnPage($pdf, $pdfPath, 0, 0, 210, 148.5); + + $outPath = $this->makeOutputPath('single'); + $pdf->Output($outPath, 'F'); + return $outPath; + } + + /** + * Platziert ein importiertes PDF auf einer Zielflaeche des Dokuments. + * Quell-PDFs im Hochformat werden um 90 Grad gedreht, damit sie in + * eine Querformat-Zielflaeche (A5 Landscape) passen. + * + * @param object $pdf FPDI-Instanz + * @param string $srcPdf Pfad zum Quell-PDF + * @param float $x Ziel-X (oben links) + * @param float $y Ziel-Y (oben links) + * @param float $w Ziel-Breite + * @param float $h Ziel-Hoehe + */ + private function placeLabelOnPage($pdf, string $srcPdf, float $x, float $y, float $w, float $h): void + { + $pageCount = $pdf->setSourceFile($srcPdf); + if ($pageCount < 1) { + return; + } + $tpl = $pdf->ImportPage(1); + + $size = $pdf->getTemplateSize($tpl); + if (!is_array($size) || !isset($size['w'], $size['h']) || $size['w'] <= 0 || $size['h'] <= 0) { + // Fallback ohne Groessen-Info: direkt strecken + $pdf->useTemplate($tpl, $x, $y, $w, $h); + return; + } + + $srcW = (float)$size['w']; + $srcH = (float)$size['h']; + $srcIsPortrait = $srcH > $srcW; + $dstIsPortrait = $h > $w; + + // Rotation-Entscheidung basierend auf User-Einstellung + $rotationAngle = 0; + switch ($this->rotation) { + case 'none': + $rotationAngle = 0; + break; + case 'cw': + $rotationAngle = -90; + break; + case 'ccw': + $rotationAngle = 90; + break; + case 'auto': + default: + // Auto: drehen wenn Quell-Orientierung nicht zur Zielflaeche passt + if ($srcIsPortrait !== $dstIsPortrait) { + $rotationAngle = -90; + } + break; + } + + $needsRotation = ($rotationAngle !== 0); + + if ($needsRotation) { + // Nach 90-Grad-Rotation vertauschen sich Breite und Hoehe + $effectiveSrcW = $srcH; + $effectiveSrcH = $srcW; + } else { + $effectiveSrcW = $srcW; + $effectiveSrcH = $srcH; + } + + // Aspect Ratio beibehalten, in Zielflaeche einpassen + $srcRatio = $effectiveSrcW / $effectiveSrcH; + $dstRatio = $w / $h; + + if ($srcRatio > $dstRatio) { + $useW = $w; + $useH = $w / $srcRatio; + } else { + $useH = $h; + $useW = $h * $srcRatio; + } + $offsetX = ($w - $useW) / 2; + $offsetY = ($h - $useH) / 2; + + $placeX = $x + $offsetX; + $placeY = $y + $offsetY; + + if ($needsRotation && method_exists($pdf, 'Rotate')) { + // Rotation um den Mittelpunkt der Zielflaeche + $centerX = $placeX + $useW / 2; + $centerY = $placeY + $useH / 2; + + $pdf->Rotate($rotationAngle, $centerX, $centerY); + + // Nach Rotation muss das Template mit vertauschten Dimensionen + // platziert werden (ungedreht gesehen). + $unrotatedW = $useH; + $unrotatedH = $useW; + $rotatedX = $centerX - $unrotatedW / 2; + $rotatedY = $centerY - $unrotatedH / 2; + + $pdf->useTemplate($tpl, $rotatedX, $rotatedY, $unrotatedW, $unrotatedH); + + $pdf->Rotate(0); + } else { + $pdf->useTemplate($tpl, $placeX, $placeY, $useW, $useH); + } + } + + /** + * Erstellt eine neue FPDI-Instanz. + * Laedt die noetigen OpenXE-PDF-Klassen falls noch nicht geladen. + * + * Die Klassen-Hierarchie ist: + * SuperFPDF -> PDF_EPS -> PDF -> PDF_Rotate -> fpdi -> fpdf_tpl -> FPDFWAWISION + * + * @return object + */ + private function createFpdi() + { + if (!class_exists('PDF_EPS') || !class_exists('FPDFWAWISION')) { + $rootDir = dirname(dirname(dirname(dirname(dirname(__DIR__))))); + $pdfDir = $rootDir . '/www/lib/pdf'; + + // FPDFWAWISION muss vor fpdf_tpl.php geladen werden + if (!class_exists('FPDFWAWISION')) { + if (is_file($pdfDir . '/fpdf_3.php')) { + require_once $pdfDir . '/fpdf_3.php'; + } elseif (is_file($pdfDir . '/fpdf.php')) { + require_once $pdfDir . '/fpdf.php'; + } + } + + // Restliche Kette: fpdf_final -> rotation -> fpdi -> fpdf_tpl + if (!class_exists('PDF_EPS') && is_file($pdfDir . '/fpdf_final.php')) { + require_once $pdfDir . '/fpdf_final.php'; + } + } + + if (!class_exists('SuperFPDF')) { + $rootDir = dirname(dirname(dirname(dirname(dirname(__DIR__))))); + $superFpdfPath = $rootDir . '/www/lib/dokumente/class.superfpdf.php'; + if (is_file($superFpdfPath)) { + require_once $superFpdfPath; + } + } + + if (class_exists('SuperFPDF')) { + return new SuperFPDF('P', 'mm', 'A4'); + } + if (class_exists('PDF_EPS')) { + return new PDF_EPS('P', 'mm', 'A4'); + } + + throw new \RuntimeException('Keine FPDI-kompatible PDF-Klasse gefunden'); + } + + /** + * @return string + */ + private function getPendingFile(): string + { + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_batch_' . $this->printerId . '_' . $this->userId . '.pdf'; + } + + /** + * @return string + */ + private function getTimestampFile(): string + { + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_batch_' . $this->printerId . '_' . $this->userId . '.ts'; + } + + /** + * @param string $suffix + * @return string + */ + private function makeOutputPath(string $suffix): string + { + if (!is_dir($this->queueDir)) { + @mkdir($this->queueDir, 0755, true); + } + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_out_' . $this->printerId . '_' . $this->userId + . '_' . $suffix . '_' . uniqid('', true) . '.pdf'; + } +}