Pixseal 1.0.0__pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,770 @@
1
+ # 바이너리 구조체 패킹/언패킹에 사용
2
+ import struct
3
+
4
+ # PNG 압축/CRC 계산에 사용
5
+ import zlib
6
+
7
+ # 바이트 스트림 처리를 위한 클래스
8
+ from io import BytesIO
9
+
10
+ # 파일 경로 객체 처리
11
+ from pathlib import Path
12
+
13
+ # 타입 힌트 정의
14
+ from typing import Iterable, List, Optional, Sequence, Tuple, Union
15
+
16
+ # PNG 파일 시그니처(매직 넘버)
17
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
18
+
19
+
20
+ # PNG 필터에서 Paeth 예측값을 계산한다.
21
+ # Paeth 필터(타입 4)는 주변 3픽셀의 값으로 현재 값을 예측한다.
22
+ # a=왼쪽, b=위, c=좌상단이며 p=a+b-c 를 기준으로 가장 가까운 값을 선택한다.
23
+ def _paethPredictor(a: int, b: int, c: int) -> int:
24
+ # 기본 예측값 계산
25
+ p = a + b - c
26
+ # 각 후보와의 거리 계산
27
+ pa = abs(p - a)
28
+ pb = abs(p - b)
29
+ pc = abs(p - c)
30
+ # 가장 가까운 후보를 선택
31
+ if pa <= pb and pa <= pc:
32
+ return a
33
+ if pb <= pc:
34
+ return b
35
+ return c
36
+
37
+
38
+ # PNG 필터 타입에 따라 한 줄(스캔라인)을 복원한다.
39
+ # PNG는 각 스캔라인 앞에 "필터 타입 1바이트"가 붙고,
40
+ # 필터가 적용된 바이트를 원본으로 되돌리는 과정이 필요하다.
41
+ def _applyPngFilter(
42
+ filterType: int, rowData: bytearray, prevRow: Sequence[int], bytesPerPixel: int
43
+ ) -> bytearray:
44
+ # 복원된 결과를 담을 버퍼
45
+ recon = bytearray(len(rowData))
46
+ # 각 바이트를 순회하며 필터 해제
47
+ for i in range(len(rowData)):
48
+ # 왼쪽 픽셀 값(없으면 0)
49
+ left = recon[i - bytesPerPixel] if i >= bytesPerPixel else 0
50
+ # 윗줄 픽셀 값(없으면 0)
51
+ up = prevRow[i] if prevRow else 0
52
+ # 좌상단 픽셀 값(없으면 0)
53
+ upLeft = prevRow[i - bytesPerPixel] if (prevRow and i >= bytesPerPixel) else 0
54
+
55
+ # 필터 타입에 따라 복원 방식 선택
56
+ # 0(None): 변환 없음
57
+ # 1(Sub): 왼쪽 픽셀 값 더하기
58
+ # 2(Up): 위쪽 픽셀 값 더하기
59
+ # 3(Average): 왼쪽과 위쪽 평균 더하기
60
+ # 4(Paeth): Paeth 예측값 더하기
61
+ if filterType == 0:
62
+ recon[i] = rowData[i]
63
+ elif filterType == 1:
64
+ recon[i] = (rowData[i] + left) & 0xFF
65
+ elif filterType == 2:
66
+ recon[i] = (rowData[i] + up) & 0xFF
67
+ elif filterType == 3:
68
+ recon[i] = (rowData[i] + ((left + up) >> 1)) & 0xFF
69
+ elif filterType == 4:
70
+ recon[i] = (rowData[i] + _paethPredictor(left, up, upLeft)) & 0xFF
71
+ else:
72
+ # 알 수 없는 필터는 오류 처리
73
+ raise ValueError(f"Unsupported PNG filter: {filterType}")
74
+ return recon
75
+
76
+
77
+ def _encodePngFilter(
78
+ filterType: int, rowData: Sequence[int], prevRow: Sequence[int], bytesPerPixel: int
79
+ ) -> bytearray:
80
+ if filterType == 0:
81
+ return bytearray(rowData)
82
+ filtered = bytearray(len(rowData))
83
+ for i in range(len(rowData)):
84
+ left = rowData[i - bytesPerPixel] if i >= bytesPerPixel else 0
85
+ up = prevRow[i] if prevRow else 0
86
+ upLeft = prevRow[i - bytesPerPixel] if (prevRow and i >= bytesPerPixel) else 0
87
+ if filterType == 1:
88
+ filtered[i] = (rowData[i] - left) & 0xFF
89
+ elif filterType == 2:
90
+ filtered[i] = (rowData[i] - up) & 0xFF
91
+ elif filterType == 3:
92
+ filtered[i] = (rowData[i] - ((left + up) >> 1)) & 0xFF
93
+ elif filterType == 4:
94
+ filtered[i] = (rowData[i] - _paethPredictor(left, up, upLeft)) & 0xFF
95
+ else:
96
+ raise ValueError(f"Unsupported PNG filter: {filterType}")
97
+ return filtered
98
+
99
+
100
+ # PNG의 하나의 청크(type+data+crc)를 읽는다.
101
+ # PNG는 [length(4)][type(4)][data(length)][crc(4)] 구조로 반복된다.
102
+ # length는 data 길이만 의미하며, type/CRC는 포함하지 않는다.
103
+ def _readChunk(stream) -> Tuple[bytes, bytes]:
104
+ # 길이(4바이트)를 읽는다.
105
+ lengthBytes = stream.read(4)
106
+ if len(lengthBytes) == 0:
107
+ # 더 이상 읽을 데이터가 없으면 종료 신호
108
+ return b"", b""
109
+ if len(lengthBytes) != 4:
110
+ raise ValueError("Unexpected EOF while reading chunk length")
111
+ # 빅엔디안 4바이트 정수로 변환
112
+ length = struct.unpack(">I", lengthBytes)[0]
113
+ # 청크 타입(4바이트) 읽기
114
+ chunkType = stream.read(4)
115
+ if len(chunkType) != 4:
116
+ raise ValueError("Unexpected EOF while reading chunk type")
117
+ # 청크 데이터 읽기
118
+ data = stream.read(length)
119
+ if len(data) != length:
120
+ raise ValueError("Unexpected EOF while reading chunk data")
121
+ # CRC 읽기
122
+ crc = stream.read(4)
123
+ if len(crc) != 4:
124
+ raise ValueError("Unexpected EOF while reading chunk CRC")
125
+ # CRC 검증: chunkType + data에 대해 CRC32 계산
126
+ expectedCrc = zlib.crc32(chunkType)
127
+ expectedCrc = zlib.crc32(data, expectedCrc) & 0xFFFFFFFF
128
+ actualCrc = struct.unpack(">I", crc)[0]
129
+ if actualCrc != expectedCrc:
130
+ raise ValueError("Corrupted PNG chunk detected")
131
+ return chunkType, data
132
+
133
+
134
+ # PNG 스트림에서 이미지 정보를 로드한다.
135
+ # PNG 열기 단계 요약:
136
+ # 1) 시그니처 검사
137
+ # 2) IHDR에서 이미지 메타정보 읽기
138
+ # 3) 모든 IDAT를 모아 zlib로 압축 해제
139
+ # 4) 스캔라인별 필터 해제 후 RGB(A) 버퍼로 복원
140
+ def _loadPng(
141
+ stream,
142
+ ) -> Tuple[
143
+ int,
144
+ int,
145
+ bytearray,
146
+ int,
147
+ Optional[bytearray],
148
+ List[Tuple[bytes, Optional[bytes], int]],
149
+ Optional[dict],
150
+ Optional[bytearray],
151
+ ]:
152
+ # 파일 시그니처 확인(8바이트 고정)
153
+ signature = stream.read(8)
154
+ if signature != PNG_SIGNATURE:
155
+ raise ValueError("Unsupported PNG signature")
156
+
157
+ # IHDR에서 읽을 값들을 초기화
158
+ width = height = None
159
+ bitDepth = colorType = None
160
+ compression = filterMethod = interlace = None
161
+ # IDAT 데이터 목록
162
+ idatChunks: List[bytes] = []
163
+ # 원본 청크 구조 기록(재저장 시 유지용)
164
+ chunk_records: List[Tuple[bytes, Optional[bytes], int]] = []
165
+
166
+ # 청크를 순서대로 읽는다(IHDR/IDAT/IEND 등)
167
+ while True:
168
+ chunkType, data = _readChunk(stream)
169
+ if chunkType == b"":
170
+ break
171
+ if chunkType == b"IHDR":
172
+ # IHDR 포맷: width, height, bitDepth, colorType, compression, filter, interlace
173
+ (
174
+ width,
175
+ height,
176
+ bitDepth,
177
+ colorType,
178
+ compression,
179
+ filterMethod,
180
+ interlace,
181
+ ) = struct.unpack(">IIBBBBB", data)
182
+ if chunkType == b"IDAT":
183
+ # IDAT는 압축된 이미지 데이터
184
+ idatChunks.append(data)
185
+ chunk_records.append((chunkType, None, len(data)))
186
+ else:
187
+ # 다른 청크는 그대로 기록
188
+ chunk_records.append((chunkType, data, len(data)))
189
+
190
+ if chunkType == b"IEND":
191
+ break
192
+
193
+ # 필수 헤더 값이 모두 있는지 확인
194
+ if None in (
195
+ width,
196
+ height,
197
+ bitDepth,
198
+ colorType,
199
+ compression,
200
+ filterMethod,
201
+ interlace,
202
+ ):
203
+ raise ValueError("Incomplete PNG header information")
204
+ # 지원되는 포맷인지 확인
205
+ if bitDepth != 8:
206
+ raise ValueError("Only 8-bit PNG images are supported")
207
+ if colorType not in (2, 6):
208
+ raise ValueError("Only RGB/RGBA PNG images are supported")
209
+ if compression != 0 or filterMethod != 0 or interlace != 0:
210
+ raise ValueError("Unsupported PNG configuration (compression/filter/interlace)")
211
+
212
+ # IDAT를 모두 합쳐서 압축 해제
213
+ # PNG는 여러 IDAT로 분할될 수 있으므로 합쳐서 zlib로 해제한다.
214
+ rawImage = zlib.decompress(b"".join(idatChunks))
215
+ # 컬러 타입에 따른 바이트 수
216
+ bytesPerPixel = 3 if colorType == 2 else 4
217
+ # 한 줄(필터 제외) 바이트 수
218
+ rowLength = width * bytesPerPixel
219
+ # 예상되는 전체 길이(각 줄의 필터 1바이트 포함)
220
+ expected = height * (rowLength + 1)
221
+ if len(rawImage) != expected:
222
+ raise ValueError("Malformed PNG image data")
223
+
224
+ # RGB 픽셀 저장용 버퍼(내부 표현은 RGB 고정)
225
+ pixel_count = width * height
226
+ pixels = bytearray(pixel_count * 3)
227
+ # 알파 채널(있을 때만 별도 버퍼로 보관)
228
+ alpha = bytearray(pixel_count) if bytesPerPixel == 4 else None
229
+ filter_types = bytearray(height)
230
+ # 이전 줄 버퍼(필터 해제용)
231
+ prevRow = bytearray(rowLength)
232
+ # rawImage에서 현재 위치
233
+ offset = 0
234
+ for y in range(height):
235
+ # 각 줄의 첫 바이트는 필터 타입
236
+ # 각 줄의 첫 바이트는 필터 타입
237
+ filterType = rawImage[offset]
238
+ offset += 1
239
+ filter_types[y] = filterType
240
+ # 현재 줄 데이터 추출
241
+ rowBytes = bytearray(rawImage[offset : offset + rowLength])
242
+ offset += rowLength
243
+ # 필터 해제: 현재 줄 + 이전 줄 정보를 이용해 복원
244
+ recon = _applyPngFilter(filterType, rowBytes, prevRow, bytesPerPixel)
245
+ # 픽셀을 RGB/알파로 분리 저장
246
+ for x in range(width):
247
+ srcIndex = x * bytesPerPixel
248
+ destIndex = (y * width + x) * 3
249
+ pixel_index = y * width + x
250
+ pixels[destIndex] = recon[srcIndex]
251
+ pixels[destIndex + 1] = recon[srcIndex + 1]
252
+ pixels[destIndex + 2] = recon[srcIndex + 2]
253
+ if alpha is not None:
254
+ alpha[pixel_index] = recon[srcIndex + 3]
255
+ # 다음 줄을 위해 이전 줄 갱신(필터 해제에 필요)
256
+ prevRow = recon
257
+ return (
258
+ width,
259
+ height,
260
+ pixels,
261
+ bytesPerPixel,
262
+ alpha,
263
+ chunk_records,
264
+ None,
265
+ filter_types,
266
+ )
267
+
268
+
269
+ # PNG 청크를 만들기 위한 바이너리 데이터 생성
270
+ # length + type + data + crc 형태로 구성한다.
271
+ def _makeChunk(chunkType: bytes, data: bytes) -> bytes:
272
+ # 데이터 길이
273
+ length = struct.pack(">I", len(data))
274
+ # CRC 계산
275
+ crcValue = zlib.crc32(chunkType)
276
+ crcValue = zlib.crc32(data, crcValue) & 0xFFFFFFFF
277
+ crc = struct.pack(">I", crcValue)
278
+ # length + type + data + crc
279
+ return length + chunkType + data + crc
280
+
281
+
282
+ # PNG 스캔라인을 생성한다(필터 타입이 있으면 재사용).
283
+ # PNG 저장 시 각 줄 앞에 필터 타입을 붙이고,
284
+ # 픽셀을 RGB(또는 RGBA) 순서로 나열한다.
285
+ def _build_scanlines(
286
+ width: int,
287
+ height: int,
288
+ pixels: Sequence[int],
289
+ alpha: Optional[Sequence[int]],
290
+ filter_types: Optional[Sequence[int]] = None,
291
+ ) -> bytes:
292
+ # 한 줄의 RGB 바이트 수
293
+ rowStride = width * 3
294
+ bytes_per_pixel = 4 if alpha is not None else 3
295
+ row_length = width * bytes_per_pixel
296
+ raw = bytearray()
297
+ # 픽셀 버퍼를 bytearray로 통일
298
+ pix_buf = pixels if isinstance(pixels, bytearray) else bytearray(pixels)
299
+ alpha_buf = None
300
+ if alpha is not None:
301
+ alpha_buf = alpha if isinstance(alpha, bytearray) else bytearray(alpha)
302
+ if filter_types is not None and len(filter_types) != height:
303
+ raise ValueError("PNG filter count does not match image height")
304
+
305
+ prev_row = bytearray(row_length)
306
+ for y in range(height):
307
+ row_buf = bytearray(row_length)
308
+ row_start = y * rowStride
309
+ for x in range(width):
310
+ idx = row_start + x * 3
311
+ if alpha_buf is not None:
312
+ dest = x * 4
313
+ row_buf[dest] = pix_buf[idx]
314
+ row_buf[dest + 1] = pix_buf[idx + 1]
315
+ row_buf[dest + 2] = pix_buf[idx + 2]
316
+ row_buf[dest + 3] = alpha_buf[y * width + x]
317
+ else:
318
+ dest = x * 3
319
+ row_buf[dest] = pix_buf[idx]
320
+ row_buf[dest + 1] = pix_buf[idx + 1]
321
+ row_buf[dest + 2] = pix_buf[idx + 2]
322
+ filterType = filter_types[y] if filter_types is not None else 0
323
+ if filterType > 4:
324
+ raise ValueError(f"Unsupported PNG filter: {filterType}")
325
+ raw.append(filterType)
326
+ if filterType == 0:
327
+ raw.extend(row_buf)
328
+ else:
329
+ raw.extend(_encodePngFilter(filterType, row_buf, prev_row, bytes_per_pixel))
330
+ prev_row = row_buf
331
+ return bytes(raw)
332
+
333
+
334
+ # PNG 파일로 저장한다.
335
+ # PNG 저장 단계 요약:
336
+ # 1) IHDR 생성(폭/높이/색상정보)
337
+ # 2) 스캔라인 생성(필터 적용)
338
+ # 3) zlib 압축으로 IDAT 생성
339
+ # 4) 시그니처 + IHDR + IDAT + IEND 기록
340
+ def _writePng(
341
+ path: str,
342
+ width: int,
343
+ height: int,
344
+ pixels: Sequence[int],
345
+ alpha: Optional[Sequence[int]] = None,
346
+ filter_types: Optional[Sequence[int]] = None,
347
+ ) -> None:
348
+ # 입력 길이 검증
349
+ expected = width * height * 3
350
+ if len(pixels) != expected:
351
+ raise ValueError("Pixel data length does not match image dimensions")
352
+ if alpha is not None and len(alpha) != width * height:
353
+ raise ValueError("Alpha channel length does not match image dimensions")
354
+
355
+ # 알파 유무에 따라 컬러 타입 결정(2=RGB, 6=RGBA)
356
+ colorType = 6 if alpha is not None else 2
357
+ # IHDR: width, height, bitDepth(8), colorType, compression, filterMethod, interlace
358
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, colorType, 0, 0, 0)
359
+ filtered = _build_scanlines(width, height, pixels, alpha, filter_types)
360
+ # 스캔라인 전체를 zlib로 압축
361
+ compressed = zlib.compress(filtered)
362
+ with open(path, "wb") as output:
363
+ output.write(PNG_SIGNATURE)
364
+ output.write(_makeChunk(b"IHDR", ihdr))
365
+ output.write(_makeChunk(b"IDAT", compressed))
366
+ output.write(_makeChunk(b"IEND", b""))
367
+
368
+
369
+ # 압축된 IDAT 데이터를 기존 청크 길이에 맞춰 분할한다.
370
+ # 원본 PNG의 IDAT 청크 길이를 보존하면 재저장 시 구조가 유지된다.
371
+ def _split_idat_payload(data: bytes, target_lengths: Iterable[int]) -> List[bytes]:
372
+ parts: List[bytes] = []
373
+ offset = 0
374
+ total = len(data)
375
+ lengths = list(target_lengths)
376
+ count = len(lengths)
377
+ for idx, length in enumerate(lengths):
378
+ if offset >= total:
379
+ break
380
+ if length <= 0:
381
+ continue
382
+ if idx == count - 1:
383
+ # 마지막 청크는 남은 모든 데이터
384
+ take = total - offset
385
+ else:
386
+ # 길이에 맞춰 잘라서 사용
387
+ take = min(length, total - offset)
388
+ if take <= 0:
389
+ continue
390
+ parts.append(data[offset : offset + take])
391
+ offset += take
392
+ # 남은 데이터가 있으면 추가
393
+ if offset < total:
394
+ parts.append(data[offset:])
395
+ # target_lengths가 비어도 데이터가 있으면 그대로 추가
396
+ if not parts and total:
397
+ parts.append(data)
398
+ return parts
399
+
400
+
401
+ # 기존 청크 구조를 유지하면서 PNG를 저장한다.
402
+ # IDAT는 새로 압축한 데이터로 교체하되,
403
+ # 그 외 청크는 원래 순서/내용을 유지한다.
404
+ def _writePngWithChunks(
405
+ path: str,
406
+ width: int,
407
+ height: int,
408
+ pixels: Sequence[int],
409
+ alpha: Optional[Sequence[int]],
410
+ chunks: List[Tuple[bytes, Optional[bytes], int]],
411
+ filter_types: Optional[Sequence[int]] = None,
412
+ ) -> None:
413
+ if not chunks:
414
+ _writePng(path, width, height, pixels, alpha, filter_types)
415
+ return
416
+
417
+ # 입력 길이 검증
418
+ expected = width * height * 3
419
+ if len(pixels) != expected:
420
+ raise ValueError("Pixel data length does not match image dimensions")
421
+ if alpha is not None and len(alpha) != width * height:
422
+ raise ValueError("Alpha channel length does not match image dimensions")
423
+
424
+ # 새로운 IDAT 데이터 생성
425
+ filtered = _build_scanlines(width, height, pixels, alpha, filter_types)
426
+ compressed = zlib.compress(filtered)
427
+ idat_lengths = [length for chunkType, _, length in chunks if chunkType == b"IDAT"]
428
+ parts = _split_idat_payload(compressed, idat_lengths) or [compressed]
429
+
430
+ # 청크를 쓰는 헬퍼 함수
431
+ def write_chunk(output, chunkType: bytes, payload: bytes) -> None:
432
+ output.write(len(payload).to_bytes(4, "big"))
433
+ output.write(chunkType)
434
+ output.write(payload)
435
+ crc = zlib.crc32(chunkType)
436
+ crc = zlib.crc32(payload, crc) & 0xFFFFFFFF
437
+ output.write(struct.pack(">I", crc))
438
+
439
+ with open(path, "wb") as output:
440
+ output.write(PNG_SIGNATURE)
441
+ idat_written = False
442
+ for chunkType, data, _ in chunks:
443
+ if chunkType == b"IDAT":
444
+ if not idat_written:
445
+ # 분할된 IDAT를 모두 기록
446
+ for part in parts:
447
+ write_chunk(output, b"IDAT", part)
448
+ idat_written = True
449
+ continue
450
+ # 원본 청크 그대로 기록
451
+ payload = data if data is not None else b""
452
+ write_chunk(output, chunkType, payload)
453
+ if not idat_written:
454
+ # IDAT가 없던 경우 새로 기록
455
+ for part in parts:
456
+ write_chunk(output, b"IDAT", part)
457
+
458
+
459
+ # BMP 스트림에서 이미지 정보를 로드한다.
460
+ def _loadBmp(
461
+ stream,
462
+ ) -> Tuple[
463
+ int,
464
+ int,
465
+ bytearray,
466
+ int,
467
+ Optional[bytearray],
468
+ Optional[List[Tuple[bytes, Optional[bytes], int]]],
469
+ Optional[dict],
470
+ Optional[bytearray],
471
+ ]:
472
+ # BMP 파일 헤더(14바이트) 읽기
473
+ header = stream.read(14)
474
+ if len(header) != 14 or header[:2] != b"BM":
475
+ raise ValueError("Unsupported BMP header")
476
+ # 파일 크기와 픽셀 데이터 위치
477
+ fileSize, _, _, pixelOffset = struct.unpack("<IHHI", header[2:])
478
+ # DIB 헤더 크기 읽기
479
+ dibHeaderSizeBytes = stream.read(4)
480
+ if len(dibHeaderSizeBytes) != 4:
481
+ raise ValueError("Corrupted BMP DIB header")
482
+ dibHeaderSize = struct.unpack("<I", dibHeaderSizeBytes)[0]
483
+ if dibHeaderSize != 40:
484
+ raise ValueError("Only BITMAPINFOHEADER BMP files are supported")
485
+ # DIB 헤더 나머지(36바이트) 읽기
486
+ dibData = stream.read(36)
487
+ (
488
+ width,
489
+ height,
490
+ planes,
491
+ bitCount,
492
+ compression,
493
+ imageSize,
494
+ xPpm,
495
+ yPpm,
496
+ clrUsed,
497
+ clrImportant,
498
+ ) = struct.unpack("<iiHHIIiiII", dibData)
499
+ if planes != 1 or bitCount != 24 or compression != 0:
500
+ raise ValueError("Only uncompressed 24-bit BMP files are supported")
501
+ # 높이가 음수면 위에서 아래 방향
502
+ absHeight = abs(height)
503
+ # 각 줄은 4바이트 정렬
504
+ rowStride = ((width * 3 + 3) // 4) * 4
505
+ pixels = bytearray(width * absHeight * 3)
506
+ # 픽셀 데이터 시작 위치로 이동
507
+ stream.seek(pixelOffset)
508
+ for row in range(absHeight):
509
+ # 한 줄 데이터 읽기
510
+ rowData = stream.read(rowStride)
511
+ if len(rowData) != rowStride:
512
+ raise ValueError("Incomplete BMP pixel data")
513
+ # BMP는 기본적으로 아래에서 위로 저장
514
+ targetRow = absHeight - 1 - row if height > 0 else row
515
+ baseIndex = targetRow * width * 3
516
+ for x in range(width):
517
+ pixelOffsetInRow = x * 3
518
+ # BMP는 BGR 순서
519
+ b = rowData[pixelOffsetInRow]
520
+ g = rowData[pixelOffsetInRow + 1]
521
+ r = rowData[pixelOffsetInRow + 2]
522
+ dest = baseIndex + x * 3
523
+ pixels[dest] = r & 0xFF
524
+ pixels[dest + 1] = g & 0xFF
525
+ pixels[dest + 2] = b & 0xFF
526
+ # 일부 메타데이터 보관
527
+ metadata = {
528
+ "xppm": xPpm,
529
+ "yppm": yPpm,
530
+ "clrUsed": clrUsed,
531
+ "clrImportant": clrImportant,
532
+ }
533
+ return width, absHeight, pixels, 3, None, None, metadata, None
534
+
535
+
536
+ # BMP 파일로 저장한다.
537
+ def _writeBmp(
538
+ path: str,
539
+ width: int,
540
+ height: int,
541
+ pixels: Sequence[int],
542
+ meta: Optional[dict] = None,
543
+ ) -> None:
544
+ # 한 줄을 4바이트 정렬로 계산
545
+ rowStride = ((width * 3 + 3) // 4) * 4
546
+ pixelArraySize = rowStride * height
547
+ fileSize = 14 + 40 + pixelArraySize
548
+ # 메타데이터(없으면 기본값)
549
+ xppm = int(meta.get("xppm", 2835)) if meta else 2835
550
+ yppm = int(meta.get("yppm", 2835)) if meta else 2835
551
+ clrUsed = int(meta.get("clrUsed", 0)) if meta else 0
552
+ clrImportant = int(meta.get("clrImportant", 0)) if meta else 0
553
+ with open(path, "wb") as output:
554
+ # BMP 헤더 작성
555
+ output.write(b"BM")
556
+ output.write(struct.pack("<IHHI", fileSize, 0, 0, 54))
557
+ output.write(
558
+ struct.pack(
559
+ "<IIIHHIIIIII",
560
+ 40,
561
+ width,
562
+ height,
563
+ 1,
564
+ 24,
565
+ 0,
566
+ pixelArraySize,
567
+ xppm,
568
+ yppm,
569
+ clrUsed,
570
+ clrImportant,
571
+ )
572
+ )
573
+ # 한 줄 패딩 바이트 계산
574
+ rowPad = rowStride - width * 3
575
+ padBytes = b"\x00" * rowPad
576
+ # BMP는 아래에서 위로 저장
577
+ for y in range(height - 1, -1, -1):
578
+ start = y * width * 3
579
+ for x in range(width):
580
+ idx = start + x * 3
581
+ r = pixels[idx]
582
+ g = pixels[idx + 1]
583
+ b = pixels[idx + 2]
584
+ # BMP는 BGR 순서로 저장
585
+ output.write(bytes((b & 0xFF, g & 0xFF, r & 0xFF)))
586
+ if rowPad:
587
+ output.write(padBytes)
588
+
589
+
590
+ # 이미지 입력 타입 정의(파일 경로 또는 바이트)
591
+ ImageInput = Union[str, Path, bytes, bytearray, "SimpleImage"]
592
+
593
+
594
+ class SimpleImage:
595
+ # 인스턴스에서 사용하는 속성 이름을 제한
596
+ __slots__ = (
597
+ "width",
598
+ "height",
599
+ "_pixels",
600
+ "_alpha",
601
+ "_png_chunks",
602
+ "_png_filters",
603
+ "_bmp_header",
604
+ )
605
+
606
+ def __init__(
607
+ self,
608
+ width: int,
609
+ height: int,
610
+ pixels: Sequence[int],
611
+ alpha: Optional[Sequence[int]] = None,
612
+ png_chunks: Optional[List[Tuple[bytes, Optional[bytes], int]]] = None,
613
+ bmp_header: Optional[dict] = None,
614
+ png_filters: Optional[Sequence[int]] = None,
615
+ ):
616
+ # 이미지 크기 설정
617
+ self.width = width
618
+ self.height = height
619
+ # 픽셀 데이터 길이 검증
620
+ expected = width * height * 3
621
+ if len(pixels) != expected:
622
+ raise ValueError("Pixel data length does not match image dimensions")
623
+ self._pixels = bytearray(pixels)
624
+ # 알파 채널 처리
625
+ if alpha is not None:
626
+ if len(alpha) != width * height:
627
+ raise ValueError("Alpha channel length does not match image dimensions")
628
+ self._alpha = bytearray(alpha)
629
+ else:
630
+ self._alpha = None
631
+ # PNG 청크 정보(있으면 복사)
632
+ self._png_chunks = list(png_chunks) if png_chunks is not None else None
633
+ if png_filters is not None:
634
+ if len(png_filters) != height:
635
+ raise ValueError("PNG filter count does not match image height")
636
+ self._png_filters = bytearray(png_filters)
637
+ else:
638
+ self._png_filters = None
639
+ # BMP 헤더 정보(있으면 복사)
640
+ self._bmp_header = dict(bmp_header) if bmp_header is not None else None
641
+
642
+ @property
643
+ def size(self) -> Tuple[int, int]:
644
+ # (너비, 높이) 튜플 반환
645
+ return self.width, self.height
646
+
647
+ @staticmethod
648
+ def _streamToImage(
649
+ stream,
650
+ ) -> Tuple[
651
+ int,
652
+ int,
653
+ bytearray,
654
+ int,
655
+ Optional[bytearray],
656
+ Optional[List[Tuple[bytes, Optional[bytes], int]]],
657
+ Optional[dict],
658
+ Optional[bytearray],
659
+ ]:
660
+ # 시그니처로 포맷 판별
661
+ signature = stream.read(8)
662
+ stream.seek(0)
663
+ if signature.startswith(PNG_SIGNATURE):
664
+ return _loadPng(stream)
665
+ if signature[:2] == b"BM":
666
+ return _loadBmp(stream)
667
+ raise ValueError("Unsupported image format")
668
+
669
+ @classmethod
670
+ def open(cls, source: ImageInput) -> "SimpleImage":
671
+ # 파일 경로 또는 바이트 입력 처리
672
+ if isinstance(source, SimpleImage):
673
+ return source
674
+ if isinstance(source, (str, Path)):
675
+ with open(source, "rb") as stream:
676
+ (
677
+ width,
678
+ height,
679
+ pixels,
680
+ channels,
681
+ alpha,
682
+ chunks,
683
+ bmp_meta,
684
+ png_filters,
685
+ ) = cls._streamToImage(stream)
686
+ elif isinstance(source, (bytes, bytearray)):
687
+ stream = BytesIO(source)
688
+ (
689
+ width,
690
+ height,
691
+ pixels,
692
+ channels,
693
+ alpha,
694
+ chunks,
695
+ bmp_meta,
696
+ png_filters,
697
+ ) = cls._streamToImage(stream)
698
+ else:
699
+ raise TypeError("source must be a file path or raw bytes")
700
+ # SimpleImage 객체 생성
701
+ image = cls(width, height, pixels, alpha, chunks, bmp_meta, png_filters)
702
+ print(f"[SimpleImage] Opened image: {width}x{height}, channels={channels}")
703
+ return image
704
+
705
+ def getPixel(self, coords: Tuple[int, int]) -> Tuple[int, int, int]:
706
+ # 좌표에서 픽셀 값 가져오기
707
+ x, y = coords
708
+ if not (0 <= x < self.width and 0 <= y < self.height):
709
+ raise ValueError("Pixel coordinate out of bounds")
710
+ index = (y * self.width + x) * 3
711
+ return (
712
+ self._pixels[index],
713
+ self._pixels[index + 1],
714
+ self._pixels[index + 2],
715
+ )
716
+
717
+ def putPixel(self, coords: Tuple[int, int], value: Sequence[int]) -> None:
718
+ # 좌표에 픽셀 값 넣기
719
+ x, y = coords
720
+ if not (0 <= x < self.width and 0 <= y < self.height):
721
+ raise ValueError("Pixel coordinate out of bounds")
722
+ index = (y * self.width + x) * 3
723
+ r, g, b = value
724
+ self._pixels[index] = int(r) & 0xFF
725
+ self._pixels[index + 1] = int(g) & 0xFF
726
+ self._pixels[index + 2] = int(b) & 0xFF
727
+
728
+ def copy(self) -> "SimpleImage":
729
+ # 내부 버퍼를 복사해서 새 객체 생성
730
+ alpha_copy = self._alpha[:] if self._alpha is not None else None
731
+ png_chunks = self._png_chunks[:] if self._png_chunks is not None else None
732
+ png_filters = self._png_filters[:] if self._png_filters is not None else None
733
+ bmp_header = self._bmp_header.copy() if self._bmp_header is not None else None
734
+ return SimpleImage(
735
+ self.width,
736
+ self.height,
737
+ self._pixels[:],
738
+ alpha_copy,
739
+ png_chunks,
740
+ bmp_header,
741
+ png_filters,
742
+ )
743
+
744
+ def save(self, path: str) -> None:
745
+ # 원본 포맷 정보가 있으면 유지해서 저장
746
+ if self._png_chunks is not None:
747
+ _writePngWithChunks(
748
+ path,
749
+ self.width,
750
+ self.height,
751
+ self._pixels,
752
+ self._alpha,
753
+ self._png_chunks,
754
+ self._png_filters,
755
+ )
756
+ elif self._bmp_header is not None:
757
+ _writeBmp(path, self.width, self.height, self._pixels, self._bmp_header)
758
+ else:
759
+ _writePng(
760
+ path,
761
+ self.width,
762
+ self.height,
763
+ self._pixels,
764
+ self._alpha,
765
+ self._png_filters,
766
+ )
767
+
768
+ def saveBmp(self, path: str) -> None:
769
+ # 강제로 BMP로 저장
770
+ _writeBmp(path, self.width, self.height, self._pixels, self._bmp_header)