77import base64
88import json
99import pathlib
10+ import struct
1011import sys
1112import zlib
1213from dataclasses import dataclass
13- from typing import Dict , List , Tuple
14+ from typing import Dict , Iterable , List , Tuple
15+
16+ MAX_COMMENT_BASE64 = 40_000
1417
1518PNG_SIGNATURE = b"\x89 PNG\r \n \x1a \n "
1619
@@ -162,9 +165,7 @@ def load_png(path: pathlib.Path) -> PNGImage:
162165 return PNGImage (width , height , bit_depth , color_type , pixels , bpp )
163166
164167
165- def compare_images (expected_path : pathlib .Path , actual_path : pathlib .Path ) -> Dict [str , bool ]:
166- expected = load_png (expected_path )
167- actual = load_png (actual_path )
168+ def compare_images (expected : PNGImage , actual : PNGImage ) -> Dict [str , bool ]:
168169 equal = (
169170 expected .width == actual .width
170171 and expected .height == actual .height
@@ -181,6 +182,85 @@ def compare_images(expected_path: pathlib.Path, actual_path: pathlib.Path) -> Di
181182 }
182183
183184
185+ def _encode_png (width : int , height : int , bit_depth : int , color_type : int , bpp : int , pixels : bytes ) -> bytes :
186+ import zlib as _zlib
187+
188+ if len (pixels ) != width * height * bpp :
189+ raise PNGError ("Pixel buffer length does not match dimensions" )
190+
191+ def chunk (tag : bytes , payload : bytes ) -> bytes :
192+ crc = _zlib .crc32 (tag + payload ) & 0xFFFFFFFF
193+ return (
194+ len (payload ).to_bytes (4 , "big" )
195+ + tag
196+ + payload
197+ + crc .to_bytes (4 , "big" )
198+ )
199+
200+ raw = bytearray ()
201+ stride = width * bpp
202+ for row in range (height ):
203+ raw .append (0 )
204+ start = row * stride
205+ raw .extend (pixels [start : start + stride ])
206+
207+ ihdr = struct .pack (
208+ ">IIBBBBB" ,
209+ width ,
210+ height ,
211+ bit_depth ,
212+ color_type ,
213+ 0 ,
214+ 0 ,
215+ 0 ,
216+ )
217+
218+ compressed = _zlib .compress (bytes (raw ))
219+ return b"" .join (
220+ [PNG_SIGNATURE , chunk (b"IHDR" , ihdr ), chunk (b"IDAT" , compressed ), chunk (b"IEND" , b"" )]
221+ )
222+
223+
224+ def _downscale_half (width : int , height : int , bpp : int , pixels : bytes ) -> Tuple [int , int , bytes ]:
225+ new_width = max (1 , (width + 1 ) // 2 )
226+ new_height = max (1 , (height + 1 ) // 2 )
227+ new_pixels = bytearray (new_width * new_height * bpp )
228+
229+ for ny in range (new_height ):
230+ for nx in range (new_width ):
231+ accum = [0 ] * bpp
232+ samples = 0
233+ for dy in (0 , 1 ):
234+ sy = min (height - 1 , ny * 2 + dy )
235+ for dx in (0 , 1 ):
236+ sx = min (width - 1 , nx * 2 + dx )
237+ src_index = (sy * width + sx ) * bpp
238+ for channel in range (bpp ):
239+ accum [channel ] += pixels [src_index + channel ]
240+ samples += 1
241+ dst_index = (ny * new_width + nx ) * bpp
242+ for channel in range (bpp ):
243+ new_pixels [dst_index + channel ] = accum [channel ] // samples
244+
245+ return new_width , new_height , bytes (new_pixels )
246+
247+
248+ def build_preview_base64 (image : PNGImage , max_length : int = MAX_COMMENT_BASE64 ) -> str :
249+ width = image .width
250+ height = image .height
251+ bpp = image .bytes_per_pixel
252+ pixels = image .pixels
253+
254+ while True :
255+ png_bytes = _encode_png (width , height , image .bit_depth , image .color_type , bpp , pixels )
256+ encoded = base64 .b64encode (png_bytes ).decode ("ascii" )
257+ if len (encoded ) <= max_length or width <= 1 or height <= 1 :
258+ return encoded
259+ if image .color_type not in {0 , 2 , 4 , 6 }:
260+ return encoded
261+ width , height , pixels = _downscale_half (width , height , bpp , pixels )
262+
263+
184264def build_results (reference_dir : pathlib .Path , actual_entries : List [Tuple [str , pathlib .Path ]], emit_base64 : bool ) -> Dict [str , List [Dict [str , object ]]]:
185265 results : List [Dict [str , object ]] = []
186266 for test_name , actual_path in actual_entries :
@@ -195,10 +275,15 @@ def build_results(reference_dir: pathlib.Path, actual_entries: List[Tuple[str, p
195275 elif not expected_path .exists ():
196276 record .update ({"status" : "missing_expected" })
197277 if emit_base64 :
198- record ["base64" ] = base64 .b64encode (actual_path .read_bytes ()).decode ("ascii" )
278+ try :
279+ record ["base64" ] = build_preview_base64 (load_png (actual_path ))
280+ except Exception :
281+ record ["base64" ] = base64 .b64encode (actual_path .read_bytes ()).decode ("ascii" )
199282 else :
200283 try :
201- outcome = compare_images (expected_path , actual_path )
284+ actual_img = load_png (actual_path )
285+ expected_img = load_png (expected_path )
286+ outcome = compare_images (expected_img , actual_img )
202287 except Exception as exc :
203288 record .update ({"status" : "error" , "message" : str (exc )})
204289 else :
@@ -207,7 +292,10 @@ def build_results(reference_dir: pathlib.Path, actual_entries: List[Tuple[str, p
207292 else :
208293 record .update ({"status" : "different" , "details" : outcome })
209294 if emit_base64 :
210- record ["base64" ] = base64 .b64encode (actual_path .read_bytes ()).decode ("ascii" )
295+ try :
296+ record ["base64" ] = build_preview_base64 (actual_img )
297+ except Exception :
298+ record ["base64" ] = base64 .b64encode (actual_path .read_bytes ()).decode ("ascii" )
211299 results .append (record )
212300 return {"results" : results }
213301
0 commit comments