1313{
1414 public function embed (string $ source ): EmbeddedPdfImage
1515 {
16- if (!is_file ($ source ) || !is_readable ($ source )) {
17- throw new InvalidArgumentException (sprintf ('Image source "%s" must be a readable file. ' , $ source ));
18- }
19-
20- $ contents = file_get_contents ($ source );
21- if ($ contents === false ) {
22- throw new InvalidArgumentException (sprintf ('Failed to read image source "%s". ' , $ source ));
23- }
24-
25- $ imageInfo = getimagesizefromstring ($ contents );
26- if ($ imageInfo === false || !isset ($ imageInfo ['mime ' ])) {
27- throw new InvalidArgumentException (sprintf ('Unable to detect the image format for "%s". ' , $ source ));
28- }
16+ $ this ->assertReadableSource ($ source );
2917
30- $ mime = $ imageInfo ['mime ' ];
18+ $ contents = $ this ->readSourceContents ($ source );
19+ $ imageInfo = $ this ->detectImageInfo ($ contents , $ source );
20+ $ mime = $ this ->resolveMimeType ($ imageInfo , $ source );
3121
3222 return match ($ mime ) {
3323 'image/jpeg ' => $ this ->embedJpeg ($ contents , $ imageInfo ),
@@ -45,20 +35,16 @@ private function embedJpeg(string $contents, array $imageInfo): EmbeddedPdfImage
4535 {
4636 $ width = $ imageInfo [0 ] ?? null ;
4737 $ height = $ imageInfo [1 ] ?? null ;
48- if (!is_int ($ width ) || !is_int ($ height )) {
38+
39+ if (!is_int ($ width )) {
4940 throw new InvalidArgumentException ('JPEG metadata must expose width and height. ' );
5041 }
5142
52- $ channels = $ imageInfo ['channels ' ] ?? 3 ;
53- if (!is_int ($ channels )) {
54- $ channels = 3 ;
43+ if (!is_int ($ height )) {
44+ throw new InvalidArgumentException ('JPEG metadata must expose width and height. ' );
5545 }
5646
57- $ colorSpace = match ($ channels ) {
58- 1 => '/DeviceGray ' ,
59- 4 => '/DeviceCMYK ' ,
60- default => '/DeviceRGB ' ,
61- };
47+ $ colorSpace = $ this ->resolveJpegColorSpace ($ imageInfo ['channels ' ] ?? null );
6248
6349 return new EmbeddedPdfImage (
6450 dictionary: [
@@ -136,11 +122,15 @@ private function parsePng(string $contents): array
136122 {
137123 $ this ->assertPngSignature ($ contents );
138124
125+ $ contentLength = strlen ($ contents );
139126 $ offset = 8 ;
140127 $ header = null ;
141128 $ idat = '' ;
129+ $ iendOffset = null ;
130+
131+ while (($ contentLength - $ offset ) >= 12 ) {
132+ $ this ->assertNoPngChunksAfterIend ($ iendOffset );
142133
143- while ($ offset + 8 <= strlen ($ contents )) {
144134 ['data ' => $ data , 'type ' => $ type ] = $ this ->readPngChunk ($ contents , $ offset );
145135
146136 if ($ type === 'IHDR ' ) {
@@ -152,10 +142,16 @@ private function parsePng(string $contents): array
152142 }
153143
154144 if ($ type === 'IEND ' ) {
155- break ;
145+ $ iendOffset = $ offset ;
156146 }
157147 }
158148
149+ if ($ iendOffset === null ) {
150+ throw new InvalidArgumentException ('PNG trailer chunk is missing. ' );
151+ }
152+
153+ $ this ->assertPngEndsAtIend ($ iendOffset , $ contentLength );
154+
159155 if ($ header === null ) {
160156 throw new InvalidArgumentException ('PNG metadata is incomplete. ' );
161157 }
@@ -186,21 +182,23 @@ private function assertPngSignature(string $contents): void
186182 */
187183 private function readPngChunk (string $ contents , int &$ offset ): array
188184 {
189- $ chunkLength = unpack ('Nvalue ' , substr ($ contents , $ offset , 4 ));
185+ $ chunkLengthBytes = substr ($ contents , $ offset , 4 );
186+ $ chunkLength = $ this ->parseChunkLength ($ chunkLengthBytes );
187+
190188 $ offset += 4 ;
191189 $ type = substr ($ contents , $ offset , 4 );
192190 $ offset += 4 ;
193191
194- if ($ chunkLength === false || ! isset ( $ chunkLength [ ' value ' ]) ) {
195- throw new InvalidArgumentException ('Invalid PNG chunk length . ' );
192+ if (strlen ( $ type ) !== 4 ) {
193+ throw new InvalidArgumentException ('Invalid PNG chunk type . ' );
196194 }
197195
198- $ data = substr ($ contents , $ offset , $ chunkLength[ ' value ' ] );
199- if (strlen ($ data ) !== $ chunkLength[ ' value ' ] ) {
196+ $ data = substr ($ contents , $ offset , $ chunkLength );
197+ if (strlen ($ data ) !== $ chunkLength ) {
200198 throw new InvalidArgumentException ('PNG chunk data is truncated. ' );
201199 }
202200
203- $ offset += $ chunkLength[ ' value ' ] + 4 ;
201+ $ offset += $ chunkLength + 4 ;
204202
205203 return [
206204 'data ' => $ data ,
@@ -221,13 +219,14 @@ private function readPngChunk(string $contents, int &$offset): array
221219 */
222220 private function parsePngHeader (string $ data ): array
223221 {
222+ if (strlen ($ data ) !== 13 ) {
223+ throw new InvalidArgumentException ('Unable to parse the PNG IHDR chunk. ' );
224+ }
225+
224226 $ header = unpack (
225227 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace ' ,
226228 $ data ,
227229 );
228- if ($ header === false ) {
229- throw new InvalidArgumentException ('Unable to parse the PNG IHDR chunk. ' );
230- }
231230
232231 return $ header ;
233232 }
@@ -285,8 +284,8 @@ private function createImageDictionary(int $width, int $height, string $colorSpa
285284 */
286285 private function unfilterPngScanlines (string $ idat , int $ height , int $ rowLength , int $ bytesPerPixel ): array
287286 {
288- $ inflated = gzuncompress ($ idat );
289- if ($ inflated === false ) {
287+ $ inflated = @ gzuncompress ($ idat );
288+ if (! is_string ( $ inflated) ) {
290289 throw new InvalidArgumentException ('PNG image data could not be decompressed. ' );
291290 }
292291
@@ -323,12 +322,13 @@ private function unfilterPngRow(
323322 ): string {
324323 $ row = '' ;
325324 $ rowLength = strlen ($ filteredRow );
325+ $ previousRowWithPadding = str_repeat ("\x00" , $ bytesPerPixel ) . $ previousRow ;
326326
327327 for ($ index = 0 ; $ index < $ rowLength ; $ index ++) {
328328 $ rawByte = ord ($ filteredRow [$ index ]);
329329 $ left = $ index >= $ bytesPerPixel ? ord ($ row [$ index - $ bytesPerPixel ]) : 0 ;
330330 $ above = ord ($ previousRow [$ index ]);
331- $ upperLeft = $ index >= $ bytesPerPixel ? ord ($ previousRow [$ index - $ bytesPerPixel ]) : 0 ;
331+ $ upperLeft = ord ($ previousRowWithPadding [$ index]) ;
332332
333333 $ decodedByte = match ($ filterType ) {
334334 0 => $ rawByte ,
@@ -354,14 +354,110 @@ private function paethPredictor(int $left, int $above, int $upperLeft): int
354354 $ aboveDistance = abs ($ prediction - $ above );
355355 $ upperLeftDistance = abs ($ prediction - $ upperLeft );
356356
357- if ($ leftDistance <= $ aboveDistance && $ leftDistance <= $ upperLeftDistance ) {
358- return $ left ;
357+ $ bestDistance = $ leftDistance ;
358+ $ bestValue = $ left ;
359+
360+ if ($ aboveDistance < $ bestDistance ) {
361+ $ bestDistance = $ aboveDistance ;
362+ $ bestValue = $ above ;
363+ }
364+
365+ if ($ upperLeftDistance < $ bestDistance ) {
366+ return $ upperLeft ;
367+ }
368+
369+ return $ bestValue ;
370+ }
371+
372+ private function parseChunkLength (string $ chunkLengthBytes ): int
373+ {
374+ if (strlen ($ chunkLengthBytes ) !== 4 ) {
375+ throw new InvalidArgumentException ('Invalid PNG chunk length. ' );
376+ }
377+
378+ return (ord ($ chunkLengthBytes [0 ]) << 24 )
379+ | (ord ($ chunkLengthBytes [1 ]) << 16 )
380+ | (ord ($ chunkLengthBytes [2 ]) << 8 )
381+ | ord ($ chunkLengthBytes [3 ]);
382+ }
383+
384+ private function assertNoPngChunksAfterIend (?int $ iendOffset ): void
385+ {
386+ if ($ iendOffset !== null ) {
387+ throw new InvalidArgumentException ('PNG data after IEND is not supported. ' );
388+ }
389+ }
390+
391+ private function assertPngEndsAtIend (int $ iendOffset , int $ contentLength ): void
392+ {
393+ if ($ iendOffset !== $ contentLength ) {
394+ throw new InvalidArgumentException ('PNG data after IEND is not supported. ' );
395+ }
396+ }
397+
398+ private function assertReadableSource (string $ source ): void
399+ {
400+ if (!is_file ($ source )) {
401+ throw new InvalidArgumentException (sprintf ('Image source "%s" must be an existing file. ' , $ source ));
402+ }
403+
404+ if (!is_readable ($ source )) {
405+ throw new InvalidArgumentException (sprintf ('Image source "%s" must be readable. ' , $ source ));
406+ }
407+ }
408+
409+ private function readSourceContents (string $ source ): string
410+ {
411+ $ contents = @file_get_contents ($ source );
412+ if (!is_string ($ contents )) {
413+ throw new InvalidArgumentException (sprintf ('Failed to read image source "%s". ' , $ source ));
414+ }
415+
416+ return $ contents ;
417+ }
418+
419+ /**
420+ * @return array<int|string, mixed>
421+ */
422+ private function detectImageInfo (string $ contents , string $ source ): array
423+ {
424+ $ imageInfo = getimagesizefromstring ($ contents );
425+ if (!is_array ($ imageInfo )) {
426+ throw new InvalidArgumentException (sprintf ('Unable to detect the image format for "%s". ' , $ source ));
427+ }
428+
429+ return $ imageInfo ;
430+ }
431+
432+ /**
433+ * @param array<int|string, mixed> $imageInfo
434+ */
435+ private function resolveMimeType (array $ imageInfo , string $ source ): string
436+ {
437+ if (!array_key_exists ('mime ' , $ imageInfo )) {
438+ throw new InvalidArgumentException (sprintf (
439+ 'Image metadata for "%s" does not expose a mime type. ' ,
440+ $ source ,
441+ ));
359442 }
360443
361- if ($ aboveDistance <= $ upperLeftDistance ) {
362- return $ above ;
444+ $ mime = $ imageInfo ['mime ' ];
445+ if (!is_string ($ mime )) {
446+ throw new InvalidArgumentException (sprintf (
447+ 'Image metadata for "%s" must expose the mime type as a string. ' ,
448+ $ source ,
449+ ));
363450 }
364451
365- return $ upperLeft ;
452+ return $ mime ;
453+ }
454+
455+ private function resolveJpegColorSpace (mixed $ channels ): string
456+ {
457+ return match ($ channels ) {
458+ 1 => '/DeviceGray ' ,
459+ 4 => '/DeviceCMYK ' ,
460+ default => '/DeviceRGB ' ,
461+ };
366462 }
367463}
0 commit comments