]> BookStack Code Mirror - bookstack/commitdiff
Drawings: Added class to extract drawio data from png files drawio_rendering 5666/head
authorDan Brown <redacted>
Thu, 19 Jun 2025 16:23:56 +0000 (17:23 +0100)
committerDan Brown <redacted>
Thu, 19 Jun 2025 16:23:56 +0000 (17:23 +0100)
app/Exceptions/DrawioPngReaderException.php [new file with mode: 0644]
app/Uploads/DrawioPngReader.php [new file with mode: 0644]
tests/Uploads/DrawioPngReaderTest.php [new file with mode: 0644]
tests/test-data/test.drawio.png [new file with mode: 0644]

diff --git a/app/Exceptions/DrawioPngReaderException.php b/app/Exceptions/DrawioPngReaderException.php
new file mode 100644 (file)
index 0000000..15d1da7
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class DrawioPngReaderException extends \Exception
+{
+}
diff --git a/app/Uploads/DrawioPngReader.php b/app/Uploads/DrawioPngReader.php
new file mode 100644 (file)
index 0000000..8f13577
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use BookStack\Exceptions\DrawioPngReaderException;
+
+/**
+ * Reads the PNG file format: https://www.w3.org/TR/2003/REC-PNG-20031110/
+ * So that it can extract embedded drawing data for alternative use.
+ */
+class DrawioPngReader
+{
+    /**
+     * @param resource $fileStream
+     */
+    public function __construct(
+        protected $fileStream
+    ) {
+    }
+
+    /**
+     * @throws DrawioPngReaderException
+     */
+    public function extractDrawing(): string
+    {
+        $signature = fread($this->fileStream, 8);
+        $pngSignature = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
+        if ($signature !== $pngSignature) {
+            throw new DrawioPngReaderException('File does not appear to be a valid PNG file');
+        }
+
+        $offset = 8;
+        $searching = true;
+
+        while ($searching) {
+            fseek($this->fileStream, $offset);
+
+            $lengthBytes = $this->readData(4);
+            $chunkTypeBytes = $this->readData(4);
+            $length = unpack('Nvalue', $lengthBytes)['value'];
+
+            if ($chunkTypeBytes === 'tEXt') {
+                fseek($this->fileStream, $offset + 8);
+                $data = $this->readData($length);
+                $crc = $this->readData(4);
+                $drawingData = $this->readTextForDrawing($data);
+                if ($drawingData !== null) {
+                    $crcResult = $this->calculateCrc($chunkTypeBytes . $data);
+                    if ($crc !== $crcResult) {
+                        throw new DrawioPngReaderException('Drawing data withing PNG file appears to be corrupted');
+                    }
+                    return $drawingData;
+                }
+            } else if ($chunkTypeBytes === 'IEND') {
+                $searching = false;
+            }
+
+            $offset += 12 + $length; // 12 = length + type + crc bytes
+        }
+
+        throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
+    }
+
+    protected function readTextForDrawing(string $data): ?string
+    {
+        // Check the keyword is mxfile to ensure we're getting the right data
+        if (!str_starts_with($data, "mxfile\u{0}")) {
+            return null;
+        }
+
+        // Extract & cleanup the drawing text
+        $drawingText = substr($data, 7);
+        return urldecode($drawingText);
+    }
+
+    protected function readData(int $length): string
+    {
+        $bytes = fread($this->fileStream, $length);
+        if ($bytes === false || strlen($bytes) < $length) {
+            throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
+        }
+        return $bytes;
+    }
+
+    protected function getCrcTable(): array
+    {
+        $table = [];
+
+        for ($n = 0; $n < 256; $n++) {
+            $c = $n;
+            for ($k = 0; $k < 8; $k++) {
+                if ($c & 1) {
+                    $c = 0xedb88320 ^ ($c >> 1);
+                } else {
+                    $c = $c >> 1;
+                }
+            }
+            $table[$n] = $c;
+        }
+
+        return $table;
+    }
+
+    /**
+     * Calculate a CRC for the given bytes following:
+     * https://www.w3.org/TR/2003/REC-PNG-20031110/#D-CRCAppendix
+     */
+    protected function calculateCrc(string $bytes): string
+    {
+        $table = $this->getCrcTable();
+
+        $length = strlen($bytes);
+        $c = 0xffffffff;
+
+        for ($n = 0; $n < $length; $n++) {
+            $tableIndex = ($c ^ ord($bytes[$n])) & 0xff;
+            $c = $table[$tableIndex] ^ ($c >> 8);
+        }
+
+        return pack('N', $c ^ 0xffffffff);
+    }
+}
diff --git a/tests/Uploads/DrawioPngReaderTest.php b/tests/Uploads/DrawioPngReaderTest.php
new file mode 100644 (file)
index 0000000..49e7531
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Uploads;
+
+use BookStack\Exceptions\DrawioPngReaderException;
+use BookStack\Uploads\DrawioPngReader;
+use Tests\TestCase;
+
+class DrawioPngReaderTest extends TestCase
+{
+    public function test_exact_drawing()
+    {
+        $file = $this->files->testFilePath('test.drawio.png');
+        $stream = fopen($file, 'r');
+
+        $reader = new DrawioPngReader($stream);
+        $drawing = $reader->extractDrawing();
+
+        $this->assertStringStartsWith('<mxfile ', $drawing);
+        $this->assertStringEndsWith("</mxfile>\n", $drawing);
+    }
+
+    public function test_extract_drawing_with_non_drawing_image_throws_exception()
+    {
+        $file = $this->files->testFilePath('test-image.png');
+        $stream = fopen($file, 'r');
+        $reader = new DrawioPngReader($stream);
+
+        $exception = null;
+        try {
+            $drawing = $reader->extractDrawing();
+        } catch (\Exception $e) {
+            $exception = $e;
+        }
+
+        $this->assertInstanceOf(DrawioPngReaderException::class, $exception);
+        $this->assertEquals($exception->getMessage(), 'Unable to find drawing data within PNG file');
+    }
+
+    public function test_extract_drawing_with_non_png_image_throws_exception()
+    {
+        $file = $this->files->testFilePath('test-image.jpg');
+        $stream = fopen($file, 'r');
+        $reader = new DrawioPngReader($stream);
+
+        $exception = null;
+        try {
+            $drawing = $reader->extractDrawing();
+        } catch (\Exception $e) {
+            $exception = $e;
+        }
+
+        $this->assertInstanceOf(DrawioPngReaderException::class, $exception);
+        $this->assertEquals($exception->getMessage(), 'File does not appear to be a valid PNG file');
+    }
+}
diff --git a/tests/test-data/test.drawio.png b/tests/test-data/test.drawio.png
new file mode 100644 (file)
index 0000000..5af0674
Binary files /dev/null and b/tests/test-data/test.drawio.png differ