Skip to content

Commit a9f5e98

Browse files
committed
Drawings: Added class to extract drawio data from png files
1 parent c4839c7 commit a9f5e98

4 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace BookStack\Exceptions;
4+
5+
class DrawioPngReaderException extends \Exception
6+
{
7+
}

app/Uploads/DrawioPngReader.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
namespace BookStack\Uploads;
4+
5+
use BookStack\Exceptions\DrawioPngReaderException;
6+
7+
/**
8+
* Reads the PNG file format: https://www.w3.org/TR/2003/REC-PNG-20031110/
9+
* So that it can extract embedded drawing data for alternative use.
10+
*/
11+
class DrawioPngReader
12+
{
13+
/**
14+
* @param resource $fileStream
15+
*/
16+
public function __construct(
17+
protected $fileStream
18+
) {
19+
}
20+
21+
/**
22+
* @throws DrawioPngReaderException
23+
*/
24+
public function extractDrawing(): string
25+
{
26+
$signature = fread($this->fileStream, 8);
27+
$pngSignature = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
28+
if ($signature !== $pngSignature) {
29+
throw new DrawioPngReaderException('File does not appear to be a valid PNG file');
30+
}
31+
32+
$offset = 8;
33+
$searching = true;
34+
35+
while ($searching) {
36+
fseek($this->fileStream, $offset);
37+
38+
$lengthBytes = $this->readData(4);
39+
$chunkTypeBytes = $this->readData(4);
40+
$length = unpack('Nvalue', $lengthBytes)['value'];
41+
42+
if ($chunkTypeBytes === 'tEXt') {
43+
fseek($this->fileStream, $offset + 8);
44+
$data = $this->readData($length);
45+
$crc = $this->readData(4);
46+
$drawingData = $this->readTextForDrawing($data);
47+
if ($drawingData !== null) {
48+
$crcResult = $this->calculateCrc($chunkTypeBytes . $data);
49+
if ($crc !== $crcResult) {
50+
throw new DrawioPngReaderException('Drawing data withing PNG file appears to be corrupted');
51+
}
52+
return $drawingData;
53+
}
54+
} else if ($chunkTypeBytes === 'IEND') {
55+
$searching = false;
56+
}
57+
58+
$offset += 12 + $length; // 12 = length + type + crc bytes
59+
}
60+
61+
throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
62+
}
63+
64+
protected function readTextForDrawing(string $data): ?string
65+
{
66+
// Check the keyword is mxfile to ensure we're getting the right data
67+
if (!str_starts_with($data, "mxfile\u{0}")) {
68+
return null;
69+
}
70+
71+
// Extract & cleanup the drawing text
72+
$drawingText = substr($data, 7);
73+
return urldecode($drawingText);
74+
}
75+
76+
protected function readData(int $length): string
77+
{
78+
$bytes = fread($this->fileStream, $length);
79+
if ($bytes === false || strlen($bytes) < $length) {
80+
throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
81+
}
82+
return $bytes;
83+
}
84+
85+
protected function getCrcTable(): array
86+
{
87+
$table = [];
88+
89+
for ($n = 0; $n < 256; $n++) {
90+
$c = $n;
91+
for ($k = 0; $k < 8; $k++) {
92+
if ($c & 1) {
93+
$c = 0xedb88320 ^ ($c >> 1);
94+
} else {
95+
$c = $c >> 1;
96+
}
97+
}
98+
$table[$n] = $c;
99+
}
100+
101+
return $table;
102+
}
103+
104+
/**
105+
* Calculate a CRC for the given bytes following:
106+
* https://www.w3.org/TR/2003/REC-PNG-20031110/#D-CRCAppendix
107+
*/
108+
protected function calculateCrc(string $bytes): string
109+
{
110+
$table = $this->getCrcTable();
111+
112+
$length = strlen($bytes);
113+
$c = 0xffffffff;
114+
115+
for ($n = 0; $n < $length; $n++) {
116+
$tableIndex = ($c ^ ord($bytes[$n])) & 0xff;
117+
$c = $table[$tableIndex] ^ ($c >> 8);
118+
}
119+
120+
return pack('N', $c ^ 0xffffffff);
121+
}
122+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Tests\Uploads;
4+
5+
use BookStack\Exceptions\DrawioPngReaderException;
6+
use BookStack\Uploads\DrawioPngReader;
7+
use Tests\TestCase;
8+
9+
class DrawioPngReaderTest extends TestCase
10+
{
11+
public function test_exact_drawing()
12+
{
13+
$file = $this->files->testFilePath('test.drawio.png');
14+
$stream = fopen($file, 'r');
15+
16+
$reader = new DrawioPngReader($stream);
17+
$drawing = $reader->extractDrawing();
18+
19+
$this->assertStringStartsWith('<mxfile ', $drawing);
20+
$this->assertStringEndsWith("</mxfile>\n", $drawing);
21+
}
22+
23+
public function test_extract_drawing_with_non_drawing_image_throws_exception()
24+
{
25+
$file = $this->files->testFilePath('test-image.png');
26+
$stream = fopen($file, 'r');
27+
$reader = new DrawioPngReader($stream);
28+
29+
$exception = null;
30+
try {
31+
$drawing = $reader->extractDrawing();
32+
} catch (\Exception $e) {
33+
$exception = $e;
34+
}
35+
36+
$this->assertInstanceOf(DrawioPngReaderException::class, $exception);
37+
$this->assertEquals($exception->getMessage(), 'Unable to find drawing data within PNG file');
38+
}
39+
40+
public function test_extract_drawing_with_non_png_image_throws_exception()
41+
{
42+
$file = $this->files->testFilePath('test-image.jpg');
43+
$stream = fopen($file, 'r');
44+
$reader = new DrawioPngReader($stream);
45+
46+
$exception = null;
47+
try {
48+
$drawing = $reader->extractDrawing();
49+
} catch (\Exception $e) {
50+
$exception = $e;
51+
}
52+
53+
$this->assertInstanceOf(DrawioPngReaderException::class, $exception);
54+
$this->assertEquals($exception->getMessage(), 'File does not appear to be a valid PNG file');
55+
}
56+
}

tests/test-data/test.drawio.png

1.55 KB
Loading

0 commit comments

Comments
 (0)