Quelltext des aktuellen Standes
<?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]]]));
});
};