Skip to content

Commit 903e452

Browse files
committed
Fix DIB cursor height inference
1 parent 74cd71d commit 903e452

3 files changed

Lines changed: 86 additions & 7 deletions

File tree

ani2xcur/cursor_conversion/native_cursor/parsers.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,9 @@ def _decode_dib_payload(payload: bytes, entry_width: int, entry_height: int) ->
301301
if header_size < 40 or len(payload) < header_size:
302302
raise ValueError(f"Unsupported DIB header size {header_size}")
303303

304-
width, raw_height, planes, bit_count, compression, _, _, _, colors_used, _ = struct.unpack_from("<iiHHIIiiII", payload, 4)
304+
width, raw_height, planes, bit_count, compression, image_size, _, _, colors_used, _ = struct.unpack_from(
305+
"<iiHHIIiiII", payload, 4
306+
)
305307
if planes != 1:
306308
raise ValueError(f"Unsupported DIB plane count {planes}")
307309
if compression != 0:
@@ -311,8 +313,7 @@ def _decode_dib_payload(payload: bytes, entry_width: int, entry_height: int) ->
311313

312314
width = abs(width) or entry_width
313315
absolute_height = abs(raw_height)
314-
height = entry_height if absolute_height >= entry_height * 2 else absolute_height
315-
if width <= 0 or height <= 0:
316+
if width <= 0 or absolute_height <= 0:
316317
raise ValueError("Invalid DIB cursor dimensions")
317318

318319
palette: list[tuple[int, int, int]] = []
@@ -328,9 +329,19 @@ def _decode_dib_payload(payload: bytes, entry_width: int, entry_height: int) ->
328329
palette.append((r, g, b))
329330
pixel_offset = header_size + color_table_size
330331
row_stride = ((width * bit_count + 31) // 32) * 4
332+
mask_stride = ((width + 31) // 32) * 4
333+
available_data_size = len(payload) - pixel_offset
334+
declared_data_size = image_size or available_data_size
335+
height = _infer_dib_cursor_height(
336+
absolute_height=absolute_height,
337+
entry_height=entry_height,
338+
row_stride=row_stride,
339+
mask_stride=mask_stride,
340+
available_data_size=available_data_size,
341+
declared_data_size=declared_data_size,
342+
)
331343
xor_size = row_stride * height
332344
mask_offset = pixel_offset + xor_size
333-
mask_stride = ((width + 31) // 32) * 4
334345
if len(payload) < pixel_offset + xor_size:
335346
raise ValueError("DIB cursor pixel data is truncated")
336347

@@ -376,6 +387,52 @@ def _decode_dib_payload(payload: bytes, entry_width: int, entry_height: int) ->
376387
return Image.frombytes("RGBA", (width, height), bytes(rgba))
377388

378389

390+
def _infer_dib_cursor_height(
391+
*,
392+
absolute_height: int,
393+
entry_height: int,
394+
row_stride: int,
395+
mask_stride: int,
396+
available_data_size: int,
397+
declared_data_size: int,
398+
) -> int:
399+
"""Infer the real bitmap height for cursor DIB payloads.
400+
401+
CUR directory dimensions are limited to a byte and can disagree with the
402+
embedded DIB header. Cursor DIBs normally store XOR and AND bitmaps, so the
403+
header height is twice the visible image height.
404+
"""
405+
406+
def exact_with_mask(height: int) -> bool:
407+
return row_stride * height + mask_stride * height == declared_data_size
408+
409+
def exact_without_mask(height: int) -> bool:
410+
return row_stride * height == declared_data_size
411+
412+
def fits_with_mask(height: int) -> bool:
413+
return row_stride * height + mask_stride * height <= available_data_size
414+
415+
def fits_without_mask(height: int) -> bool:
416+
return row_stride * height <= available_data_size
417+
418+
candidate_heights: list[int] = []
419+
if absolute_height % 2 == 0:
420+
candidate_heights.append(absolute_height // 2)
421+
candidate_heights.append(absolute_height)
422+
if entry_height > 0:
423+
candidate_heights.append(entry_height)
424+
425+
for height in candidate_heights:
426+
if height > 0 and (exact_with_mask(height) or exact_without_mask(height)):
427+
return height
428+
429+
for height in candidate_heights:
430+
if height > 0 and (fits_with_mask(height) or fits_without_mask(height)):
431+
return height
432+
433+
raise ValueError("DIB cursor pixel data is truncated")
434+
435+
379436
def _decode_indexed_dib_pixel(payload: bytes, row_start: int, x: int, bit_count: int) -> int:
380437
if bit_count == 8:
381438
return payload[row_start + x]

ani2xcur/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Ani2xcur 版本"""
22

3-
VERSION = "0.1.4"
3+
VERSION = "0.1.5"

tests/test_native_cursor_converter.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ def test_parse_32bit_dib_cur_uses_alpha_channel():
128128
assert image.getpixel((1, 1)) == (255, 255, 255, 0)
129129

130130

131+
def test_parse_dib_cur_prefers_bitmap_height_when_directory_height_is_wrong():
132+
cur_blob = _make_cur_blob(_make_32bit_dib_payload_for_size(4, 4), width=2, height=2, hotspot=(3, 3))
133+
134+
frames = parse_blob(cur_blob)
135+
136+
image = frames[0].images[0].image
137+
assert [(cursor.image.size, cursor.hotspot, cursor.nominal) for cursor in frames[0].images] == [((4, 4), (3, 3), 4)]
138+
assert image.getpixel((3, 3)) == (3, 3, 100, 255)
139+
140+
131141
def test_parse_24bit_dib_cur_uses_and_mask():
132142
cur_blob = _make_cur_blob(_make_24bit_dib_payload())
133143

@@ -308,9 +318,9 @@ def test_shadow_uses_requested_opacity():
308318
assert alpha_histogram[64] > 0
309319

310320

311-
def _make_cur_blob(payload: bytes) -> bytes:
321+
def _make_cur_blob(payload: bytes, *, width: int = 2, height: int = 2, hotspot: tuple[int, int] = (1, 1)) -> bytes:
312322
header = struct.pack("<HHH", 0, 2, 1)
313-
entry = struct.pack("<BBBBHHII", 2, 2, 0, 0, 1, 1, len(payload), len(header) + 16)
323+
entry = struct.pack("<BBBBHHII", width, height, 0, 0, hotspot[0], hotspot[1], len(payload), len(header) + 16)
314324
return header + entry + payload
315325

316326

@@ -411,6 +421,18 @@ def _make_32bit_dib_payload() -> bytes:
411421
return header + pixels_bottom_up + mask
412422

413423

424+
def _make_32bit_dib_payload_for_size(width: int, height: int) -> bytes:
425+
row_stride = width * 4
426+
mask_stride = ((width + 31) // 32) * 4
427+
pixels_bottom_up = bytearray()
428+
for y in range(height - 1, -1, -1):
429+
for x in range(width):
430+
pixels_bottom_up.extend((100, y, x, 255))
431+
mask = b"\x00" * (mask_stride * height)
432+
header = struct.pack("<IiiHHIIiiII", 40, width, height * 2, 1, 32, 0, row_stride * height + len(mask), 0, 0, 0, 0)
433+
return header + bytes(pixels_bottom_up) + mask
434+
435+
414436
def _make_24bit_dib_payload() -> bytes:
415437
width = 2
416438
height = 2

0 commit comments

Comments
 (0)