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.
- Pixseal/__init__.py +34 -0
- Pixseal/imageSigner.py +342 -0
- Pixseal/imageValidator.py +399 -0
- Pixseal/keyInput.py +91 -0
- Pixseal/simpleImage.py +35 -0
- Pixseal/simpleImage_ext.pypy39-pp73-x86_64-linux-gnu.so +0 -0
- Pixseal/simpleImage_py.py +770 -0
- pixseal-1.0.0.dist-info/METADATA +285 -0
- pixseal-1.0.0.dist-info/RECORD +11 -0
- pixseal-1.0.0.dist-info/WHEEL +6 -0
- pixseal-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|