OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

PdfRendererTest.php

7 KB
<?php

declare(strict_types=1);

use OpenNIT\Export\PdfRenderer;

/**
 * Sichert die zentrale PDF-Erzeugung ab.
 *
 * Der Schwerpunkt liegt auf den Eigenschaften, die fachlich zählen und beim
 * Wechsel der PDF-Bibliothek erfahrungsgemäß kippen: eingebettete Bilder aus
 * data:-URIs (Unterschriften auf Übergabe- und Leihbelegen), die Beschränkung
 * lokaler Dateizugriffe auf ein Verzeichnis und das Seitenformat.
 */
return static function (): void {

    // Ohne installierte Abhängigkeiten lässt sich nichts rendern; die reinen
    // Konfigurations-Helfer werden trotzdem geprüft.
    $haveDompdf = class_exists(\Dompdf\Dompdf::class);
    if (!$haveDompdf) {
        echo "  (übersprungen: dompdf nicht installiert – composer install ausführen)\n";
    }

    /** 1×1-PNG als data:-URI – dieselbe Form, in der Unterschriften gespeichert werden. */
    $png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
         . 'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';

    /**
     * Rendert unkomprimiert, damit sich Text und Objekte im Rohdatenstrom
     * prüfen lassen.
     */
    $render = static function (string $html, string $orientation = 'portrait', ?string $chroot = null): string {
        $dompdf = PdfRenderer::create($chroot);
        $dompdf->loadHtml($html, 'UTF-8');
        $dompdf->setPaper('A4', $orientation);
        $dompdf->render();
        return (string) $dompdf->output(['compress' => 0]);
    };

    /** Text steht als Identity-H im Strom, also mit Nullbyte vor jedem Zeichen. */
    $text = static fn(string $pdf): string => str_replace("\0", '', $pdf);

    if ($haveDompdf) {
        test('erzeugt ein gültiges PDF mit dem übergebenen Text', function () use ($render, $text) {
            $pdf = $render('<html><body><h1>PRUEFTEXT</h1></body></html>');
            assertTrue(str_starts_with($pdf, '%PDF-'));
            assertTrue(str_contains($text($pdf), 'PRUEFTEXT'));
        });

        test('bettet Unterschriften aus data:-URIs ein', function () use ($render, $png) {
            $pdf = $render('<html><body><img src="' . $png . '" style="width:40px;height:20px;"></body></html>');
            assertTrue(str_contains($pdf, '/Image'));
        });

        test('ohne Bild entsteht auch kein Bildobjekt', function () use ($render) {
            $pdf = $render('<html><body><p>ohne Bild</p></body></html>');
            assertFalse(str_contains($pdf, '/Image'));
        });

        test('Umlaute landen einfach kodiert im PDF', function () use ($render) {
            // Dompdf schreibt Text als Identity-H, also zwei Byte je Zeichen mit
            // dem Unicode-Codepoint. Nach dem Entfernen der Nullbytes steht ein
            // korrekt kodiertes "ä" (U+00E4) als einzelnes Byte 0xE4 im Strom;
            // eine doppelt kodierte Eingabe ergäbe stattdessen 0xC3 0xA4.
            $pdf = $render("<html><body><p>\u{00e4}\u{00f6}\u{00fc} \u{00df}</p></body></html>");
            $stripped = str_replace("\0", '', $pdf);

            assertTrue(str_contains($stripped, chr(0xE4)));
            assertTrue(str_contains($stripped, chr(0xF6)));
            assertTrue(str_contains($stripped, chr(0xDF)));
            assertFalse(str_contains($stripped, "\xC3\xA4"));
        });

        test('Querformat liefert eine breitere als hohe Seite', function () use ($render) {
            $pdf = $render('<html><body>quer</body></html>', 'landscape');
            assertTrue((bool) preg_match('#/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)#', $pdf, $m));
            assertTrue(((float) $m[3] - (float) $m[1]) > ((float) $m[4] - (float) $m[2]));
        });

        test('lokale Bilder unterhalb des Chroots werden eingebunden', function () use ($render) {
            $dir  = sys_get_temp_dir() . '/opennit-pdf-' . bin2hex(random_bytes(4));
            mkdir($dir, 0700, true);
            $file = $dir . '/probe.png';
            file_put_contents($file, base64_decode(
                'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
            ));

            $pdf = $render('<html><body><img src="' . $file . '" style="width:20px;height:20px;"></body></html>', 'portrait', $dir);
            assertTrue(str_contains($pdf, '/Image'));

            unlink($file);
            rmdir($dir);
        });

        test('Dateien außerhalb des Chroots bleiben ausgeschlossen', function () use ($render) {
            $dir = sys_get_temp_dir() . '/opennit-pdf-' . bin2hex(random_bytes(4));
            mkdir($dir, 0700, true);

            // Liegt bewusst außerhalb von $dir und darf nicht ins PDF gelangen.
            $outside = sys_get_temp_dir() . '/opennit-outside-' . bin2hex(random_bytes(4)) . '.png';
            file_put_contents($outside, base64_decode(
                'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
            ));

            $pdf = $render('<html><body><img src="' . $outside . '"></body></html>', 'portrait', $dir);
            assertTrue(str_starts_with($pdf, '%PDF-'));
            assertFalse(str_contains($pdf, '/Image'));

            unlink($outside);
            rmdir($dir);
        });

        test('ein SVG aus einer data:-URI liest keine lokalen Dateien aus', function () use ($render) {
            // Der Sanitizer lässt "data:image/" bewusst zu, damit Bilder aus dem
            // Editor erhalten bleiben – darunter fällt auch SVG. Ein solches SVG
            // darf keine Datei einbinden, die außerhalb des Chroots liegt.
            $dir    = sys_get_temp_dir() . '/opennit-pdf-' . bin2hex(random_bytes(4));
            mkdir($dir, 0700, true);
            $secret = sys_get_temp_dir() . '/opennit-secret-' . bin2hex(random_bytes(4)) . '.txt';
            $marker = 'GEHEIM-' . bin2hex(random_bytes(6));
            file_put_contents($secret, $marker);

            $svg = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"'
                 . ' width="80" height="20"><image xlink:href="' . $secret . '" width="80" height="20"/></svg>';
            $uri = 'data:image/svg+xml;base64,' . base64_encode($svg);

            $pdf = $render('<html><body><img src="' . $uri . '"></body></html>', 'portrait', $dir);

            // Auch die komprimierten Datenströme durchsuchen.
            $haystack = $pdf;
            if (preg_match_all('/stream\r?\n(.*?)\r?\nendstream/s', $pdf, $m)) {
                foreach ($m[1] as $raw) {
                    $inflated = @gzuncompress($raw);
                    if ($inflated !== false) {
                        $haystack .= $inflated;
                    }
                }
            }
            assertFalse(str_contains($haystack, $marker));

            unlink($secret);
            rmdir($dir);
        });

    }

    test('paperSize nimmt die Konfiguration und fällt sonst auf A4 zurück', function () {
        assertSame('A5', PdfRenderer::paperSize(['app' => ['pdf_paper_size' => 'A5']]));
        assertSame('A4', PdfRenderer::paperSize([]));
        assertSame('A4', PdfRenderer::paperSize(['app' => ['pdf_paper_size' => '']]));
    });

    test('publicChroot liefert nur vorhandene Verzeichnisse', function () {
        assertSame(null, PdfRenderer::publicChroot([]));
        assertSame(null, PdfRenderer::publicChroot(['app' => ['paths' => ['public' => '/nicht/vorhanden']]]));
        $tmp = realpath(sys_get_temp_dir());
        assertSame($tmp, PdfRenderer::publicChroot(['app' => ['paths' => ['public' => $tmp]]]));
    });
};