更新 HEICFile 类以支持新特性,添加图像尺寸获取功能,重构解析逻辑,增强代码可读性和健壮性

This commit is contained in:
Nymiro
2025-10-20 12:47:47 +13:00
parent 812318c50c
commit 7715acd18b
10 changed files with 204 additions and 69 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -13,3 +13,7 @@ class Box:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Box '{self.type}' size={self.size} offset={self.offset}>" return f"<Box '{self.type}' size={self.size} offset={self.offset}>"
def _post_parse_initialization(self):
"""Called by the parser after children have been assigned."""
pass
+53 -26
View File
@@ -1,10 +1,13 @@
# pyheic_struct/heic_file.py
import os import os
from base import Box from base import Box
from parser import parse_boxes from parser import parse_boxes
from heic_types import ItemLocationBox from heic_types import (
from heic_types import ItemLocationBox, PrimaryItemBox, ItemInfoBox ItemLocationBox, PrimaryItemBox, ItemInfoBox, ItemPropertiesBox,
ImageSpatialExtentsBox
)
# Import handlers
from handlers.base_handler import VendorHandler from handlers.base_handler import VendorHandler
from handlers.apple_handler import AppleHandler from handlers.apple_handler import AppleHandler
from handlers.samsung_handler import SamsungHandler from handlers.samsung_handler import SamsungHandler
@@ -12,27 +15,61 @@ from handlers.samsung_handler import SamsungHandler
class HEICFile: class HEICFile:
def __init__(self, filepath: str): def __init__(self, filepath: str):
self.filepath = filepath self.filepath = filepath
self._iloc_box: ItemLocationBox = None self._iloc_box: ItemLocationBox | None = None
self._ftyp_box: Box = None self._ftyp_box: Box | None = None
self._iinf_box: ItemInfoBox = None # <-- Add this line self._iinf_box: ItemInfoBox | None = None
self._pitm_box: PrimaryItemBox = None self._pitm_box: PrimaryItemBox | None = None
self.handler: VendorHandler = None # Will hold our strategy object self._iprp_box: ItemPropertiesBox | None = None
self.handler: VendorHandler | None = None
with open(self.filepath, 'rb') as f: with open(self.filepath, 'rb') as f:
file_size = os.fstat(f.fileno()).st_size file_size = os.fstat(f.fileno()).st_size
self.boxes = parse_boxes(f, file_size) self.boxes = parse_boxes(f, file_size)
self._find_essential_boxes(self.boxes) self._find_essential_boxes(self.boxes)
self._detect_vendor() self._detect_vendor()
# --- FIX: Changed 'elif' to separate 'if' statements for robustness ---
def _find_essential_boxes(self, boxes: list[Box]): def _find_essential_boxes(self, boxes: list[Box]):
"""Recursively search for essential boxes.""" """Recursively search for essential boxes."""
for box in boxes: for box in boxes:
if box.type == 'iloc': self._iloc_box = box if isinstance(box, ItemLocationBox):
if box.type == 'ftyp': self._ftyp_box = box self._iloc_box = box
if box.type == 'iinf': self._iinf_box = box # <-- Add this line if isinstance(box, ItemInfoBox):
if box.type == 'pitm': self._pitm_box = box self._iinf_box = box
if box.children: self._find_essential_boxes(box.children) if isinstance(box, PrimaryItemBox):
self._pitm_box = box
if isinstance(box, ItemPropertiesBox):
self._iprp_box = box
if box.type == 'ftyp':
self._ftyp_box = box
if box.children:
self._find_essential_boxes(box.children)
# All other methods remain unchanged
def get_image_size(self, item_id: int) -> tuple[int, int] | None:
"""
Gets the width and height of a given image item ID.
"""
if not self._iprp_box or not self._iprp_box.ipma or not self._iprp_box.ipco:
print("Warning: 'iprp' box or its sub-boxes ('ipma', 'ipco') not found.")
return None
if item_id not in self._iprp_box.ipma.entries:
print(f"Warning: No property association found for item ID {item_id}.")
return None
item_associations = self._iprp_box.ipma.entries[item_id].associations
for assoc in item_associations:
property_index = assoc - 1
if property_index < len(self._iprp_box.ipco.children):
prop = self._iprp_box.ipco.children[property_index]
if isinstance(prop, ImageSpatialExtentsBox):
return (prop.image_width, prop.image_height)
print(f"Warning: 'ispe' property not found for item ID {item_id}.")
return None
def get_primary_item_id(self) -> int | None: def get_primary_item_id(self) -> int | None:
if not self._pitm_box: if not self._pitm_box:
@@ -50,9 +87,6 @@ class HEICFile:
print(f" - {entry}") print(f" - {entry}")
def _detect_vendor(self): def _detect_vendor(self):
"""
Inspects the 'ftyp' box to determine the vendor and assign the correct handler.
"""
if self._ftyp_box: if self._ftyp_box:
compatible_brands = self._ftyp_box.raw_data[4:].decode('ascii', errors='ignore') compatible_brands = self._ftyp_box.raw_data[4:].decode('ascii', errors='ignore')
if 'samsung' in compatible_brands.lower() or 'mpvd' in [b.type for b in self.boxes]: if 'samsung' in compatible_brands.lower() or 'mpvd' in [b.type for b in self.boxes]:
@@ -62,13 +96,10 @@ class HEICFile:
self.handler = AppleHandler() self.handler = AppleHandler()
return return
# If no specific handler is found, use the base one.
print("Could not detect a specific vendor. Using default handler.") print("Could not detect a specific vendor. Using default handler.")
self.handler = VendorHandler() self.handler = VendorHandler()
def get_item_data(self, item_id: int) -> bytes | None: def get_item_data(self, item_id: int) -> bytes | None:
# ... (this method remains unchanged) ...
if not self._iloc_box: if not self._iloc_box:
print("Error: 'iloc' box not found.") print("Error: 'iloc' box not found.")
return None return None
@@ -77,20 +108,16 @@ class HEICFile:
print(f"Error: Item with ID {item_id} not found in 'iloc' box.") print(f"Error: Item with ID {item_id} not found in 'iloc' box.")
return None return None
with open(self.filepath, 'rb') as f: with open(self.filepath, 'rb') as f:
# IMPORTANT: iloc offsets are absolute from the start of the file.
# The mdat box's offset is not needed for this calculation.
f.seek(location.offset) f.seek(location.offset)
data = f.read(location.length) data = f.read(location.length)
return data return data
def get_motion_photo_data(self) -> bytes | None: def get_motion_photo_data(self) -> bytes | None:
""" if not self.handler:
Delegates the search for motion photo data to the assigned vendor handler. self._detect_vendor()
"""
offset = self.handler.find_motion_photo_offset(self) offset = self.handler.find_motion_photo_offset(self)
if offset is not None: if offset is not None:
with open(self.filepath, 'rb') as f: with open(self.filepath, 'rb') as f:
# We assume the video data goes to the end of the file from its offset
f.seek(offset) f.seek(offset)
return f.read() return f.read()
return None return None
+116 -25
View File
@@ -1,8 +1,11 @@
# pyheic_struct/heic_types.py
import struct import struct
from base import Box # <-- THIS IS THE KEY CHANGE from base import Box
# --- Existing classes (unchanged) ---
class ItemLocation: class ItemLocation:
# ... (rest of this class is unchanged)
def __init__(self, item_id, offset, length): def __init__(self, item_id, offset, length):
self.item_id = item_id self.item_id = item_id
self.offset = offset self.offset = offset
@@ -11,13 +14,11 @@ class ItemLocation:
return f"<ItemLocation ID={self.item_id} offset={self.offset} length={self.length}>" return f"<ItemLocation ID={self.item_id} offset={self.offset} length={self.length}>"
class ItemLocationBox(Box): class ItemLocationBox(Box):
# ... (rest of this class is unchanged)
def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes):
super().__init__(size, box_type, offset, raw_data) super().__init__(size, box_type, offset, raw_data)
self.locations = [] self.locations = []
self._parse_locations() self._parse_locations()
def _parse_locations(self): def _parse_locations(self):
# ... (all the parsing logic here is unchanged)
stream = self.raw_data stream = self.raw_data
version_flags = struct.unpack('>I', stream[:4])[0] version_flags = struct.unpack('>I', stream[:4])[0]
sizes = struct.unpack('>H', stream[4:6])[0] sizes = struct.unpack('>H', stream[4:6])[0]
@@ -28,8 +29,8 @@ class ItemLocationBox(Box):
current_pos = 8 current_pos = 8
for _ in range(item_count): for _ in range(item_count):
item_id = struct.unpack('>H', stream[current_pos : current_pos+2])[0] item_id = struct.unpack('>H', stream[current_pos : current_pos+2])[0]
current_pos += 2 # Skip construction_method current_pos += 2
current_pos += 2 # Skip data_reference_index current_pos += 2
current_pos += base_offset_size current_pos += base_offset_size
extent_count = struct.unpack('>H', stream[current_pos : current_pos+2])[0] extent_count = struct.unpack('>H', stream[current_pos : current_pos+2])[0]
current_pos += 2 current_pos += 2
@@ -47,35 +48,22 @@ class ItemLocationBox(Box):
return 0 return 0
class ItemInfoEntry: class ItemInfoEntry:
"""A data class for one item described in an 'iinf' box."""
def __init__(self, item_id, item_type, item_name): def __init__(self, item_id, item_type, item_name):
self.item_id = item_id self.item_id = item_id
self.type = item_type self.type = item_type
self.name = item_name self.name = item_name
def __repr__(self): def __repr__(self):
return f"<ItemInfoEntry ID={self.item_id} type='{self.type}' name='{self.name}'>" return f"<ItemInfoEntry ID={self.item_id} type='{self.type}' name='{self.name}'>"
class ItemInfoBox(Box): class ItemInfoBox(Box):
"""
A specialized class for the 'iinf' box.
Parses its own data to build a list of all available items.
"""
def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes):
super().__init__(size, box_type, offset, raw_data) super().__init__(size, box_type, offset, raw_data)
self.entries: list[ItemInfoEntry] = [] self.entries: list[ItemInfoEntry] = []
self._parse_entries() self._parse_entries()
def _parse_entries(self): def _parse_entries(self):
# 'iinf' is a FullBox, so the first 4 bytes are version/flags
item_count = struct.unpack('>H', self.raw_data[4:6])[0] item_count = struct.unpack('>H', self.raw_data[4:6])[0]
# The children are 'infe' boxes, which are parsed by the generic parser
for infe_box in self.children: for infe_box in self.children:
if infe_box.type == 'infe': if infe_box.type == 'infe':
# 'infe' is a FullBox (version, flags)
# version = infe_box.raw_data[0]
item_id = struct.unpack('>H', infe_box.raw_data[4:6])[0] item_id = struct.unpack('>H', infe_box.raw_data[4:6])[0]
item_type = infe_box.raw_data[8:12].decode('ascii').strip('\x00') item_type = infe_box.raw_data[8:12].decode('ascii').strip('\x00')
item_name_bytes = infe_box.raw_data[12:] item_name_bytes = infe_box.raw_data[12:]
@@ -83,15 +71,118 @@ class ItemInfoBox(Box):
self.entries.append(ItemInfoEntry(item_id, item_type, item_name)) self.entries.append(ItemInfoEntry(item_id, item_type, item_name))
class PrimaryItemBox(Box): class PrimaryItemBox(Box):
"""
A specialized class for the 'pitm' box.
"""
def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes): def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes):
super().__init__(size, box_type, offset, raw_data) super().__init__(size, box_type, offset, raw_data)
self.item_id: int = 0 self.item_id: int = 0
self._parse_item_id() self._parse_item_id()
def _parse_item_id(self): def _parse_item_id(self):
# 'pitm' is a FullBox (version, flags)
self.item_id = struct.unpack('>H', self.raw_data[4:6])[0] self.item_id = struct.unpack('>H', self.raw_data[4:6])[0]
# --- NEW classes for Task 3.1 & 3.2 ---
class ImageSpatialExtentsBox(Box):
"""'ispe' box, contains image dimensions."""
def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes):
super().__init__(size, box_type, offset, raw_data)
# 'ispe' is a FullBox
self.image_width = struct.unpack('>I', self.raw_data[4:8])[0]
self.image_height = struct.unpack('>I', self.raw_data[8:12])[0]
def __repr__(self):
return f"<ImageSpatialExtentsBox width={self.image_width} height={self.image_height}>"
class ItemPropertyAssociationEntry:
"""Helper class for an entry in the 'ipma' box."""
def __init__(self, item_id, association_count):
self.item_id = item_id
self.association_count = association_count
self.associations = [] # List of property indices
def __repr__(self):
return f"<ItemPropertyAssociationEntry item_id={self.item_id} associations={self.associations}>"
class ItemPropertyAssociationBox(Box):
"""'ipma' box, maps items to their properties."""
def __init__(self, size: int, box_type: str, offset: int, raw_data: bytes):
super().__init__(size, box_type, offset, raw_data)
self.entries: dict[int, ItemPropertyAssociationEntry] = {}
self._parse_associations()
def _parse_associations(self):
stream = self.raw_data
# A FullBox has a 4-byte header (version + flags)
version_flags = struct.unpack('>I', stream[:4])[0]
version = version_flags >> 24
flags = version_flags & 0xFFFFFF
entry_count = struct.unpack('>I', stream[4:8])[0]
pos = 8
# Determine field sizes based on the box version and flags
item_id_size = 4 if version >= 1 else 2
is_large_property_index = (flags & 1) == 1
for _ in range(entry_count):
# Boundary check for the item ID
if pos + item_id_size > len(stream):
break
if item_id_size == 4:
item_id = struct.unpack('>I', stream[pos:pos+4])[0]
pos += 4
else: # item_id_size == 2
item_id = struct.unpack('>H', stream[pos:pos+2])[0]
pos += 2
# Boundary check for association_count
if pos + 1 > len(stream):
break
association_count = stream[pos]
pos += 1
entry = ItemPropertyAssociationEntry(item_id, association_count)
for __ in range(association_count):
if is_large_property_index:
# 2-byte association entry
if pos + 2 > len(stream):
break # Not enough data
assoc_value = struct.unpack('>H', stream[pos:pos+2])[0]
property_index = assoc_value & 0x7FFF # Lower 15 bits
pos += 2
else:
# 1-byte association entry
if pos + 1 > len(stream):
break # Not enough data
assoc_value = stream[pos]
property_index = assoc_value & 0x7F # Lower 7 bits
pos += 1
# A property_index of 0 is not a valid index.
if property_index > 0:
entry.associations.append(property_index)
self.entries[item_id] = entry
class ItemPropertyContainerBox(Box):
"""'ipco' box. Just a container for property boxes like 'ispe'."""
pass
class ItemPropertiesBox(Box):
"""'iprp' box. Container for 'ipco' and 'ipma'."""
@property
def ipco(self) -> ItemPropertyContainerBox | None:
"""Finds and returns the 'ipco' child box if it exists."""
for child in self.children:
if isinstance(child, ItemPropertyContainerBox):
return child
return None
@property
def ipma(self) -> ItemPropertyAssociationBox | None:
"""Finds and returns the 'ipma' child box if it exists."""
for child in self.children:
if isinstance(child, ItemPropertyAssociationBox):
return child
return None
+9 -3
View File
@@ -1,4 +1,5 @@
# In main.py # pyheic_struct/main.py
from heic_file import HEICFile from heic_file import HEICFile
def analyze_file(filename: str): def analyze_file(filename: str):
@@ -11,12 +12,17 @@ def analyze_file(filename: str):
# Get the ID of the primary image # Get the ID of the primary image
primary_id = heic_file.get_primary_item_id() primary_id = heic_file.get_primary_item_id()
if primary_id: if primary_id:
print(f"\nThe primary item ID is: {primary_id}") print(f"The primary item ID is: {primary_id}")
# --- Use our new feature! ---
size = heic_file.get_image_size(primary_id)
if size:
print(f"Primary image dimensions (WxH): {size[0]} x {size[1]}")
print("\n" + "="*40 + "\n") print("\n" + "="*40 + "\n")
def main(): def main():
analyze_file('apple.heic') analyze_file('apple.HEIC')
analyze_file('samsung.heic') analyze_file('samsung.heic')
if __name__ == "__main__": if __name__ == "__main__":
+15 -8
View File
@@ -2,19 +2,29 @@ import struct
from typing import List, BinaryIO from typing import List, BinaryIO
from io import BytesIO from io import BytesIO
from base import Box # Import the base Box class from base import Box
from heic_types import ItemLocationBox, PrimaryItemBox, ItemInfoBox from heic_types import (
ItemLocationBox, PrimaryItemBox, ItemInfoBox, ItemPropertiesBox,
ItemPropertyContainerBox, ItemPropertyAssociationBox, ImageSpatialExtentsBox
)
# The factory map remains here # The factory map remains the same
BOX_TYPE_MAP = { BOX_TYPE_MAP = {
'iloc': ItemLocationBox, 'iloc': ItemLocationBox,
'pitm': PrimaryItemBox, 'pitm': PrimaryItemBox,
'iinf': ItemInfoBox, 'iinf': ItemInfoBox,
'iprp': ItemPropertiesBox,
'ipco': ItemPropertyContainerBox,
'ipma': ItemPropertyAssociationBox,
'ispe': ImageSpatialExtentsBox,
} }
CONTAINER_BOXES = {'meta', 'moov', 'trak', 'iprp', 'ipco', 'dinf', 'fiinf', 'ipro'} # --- FIX: Add 'iinf' to CONTAINER_BOXES ---
FULL_BOXES = {'meta', 'hdlr', 'pitm', 'iinf', 'iloc'} # 'iinf' is a container for 'infe' boxes, so it needs to be parsed recursively.
CONTAINER_BOXES = {'meta', 'moov', 'trak', 'iprp', 'ipco', 'dinf', 'fiinf', 'ipro', 'iinf'}
FULL_BOXES = {'meta', 'hdlr', 'pitm', 'iinf', 'iloc', 'ipma', 'ispe'}
# The parse_boxes function remains completely unchanged
def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]: def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]:
""" """
Parses boxes from a file stream up to a maximum size. Parses boxes from a file stream up to a maximum size.
@@ -49,11 +59,9 @@ def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]:
raw_data = stream.read(content_size) raw_data = stream.read(content_size)
if len(raw_data) < content_size: break if len(raw_data) < content_size: break
# --- Factory Pattern ---
box_class = BOX_TYPE_MAP.get(box_type, Box) box_class = BOX_TYPE_MAP.get(box_type, Box)
box = box_class(size, box_type, current_offset_in_stream, raw_data) box = box_class(size, box_type, current_offset_in_stream, raw_data)
# --- NEW: Recursive parsing logic moved here ---
if box.type in CONTAINER_BOXES: if box.type in CONTAINER_BOXES:
child_stream = BytesIO(box.raw_data) child_stream = BytesIO(box.raw_data)
parse_size = len(box.raw_data) parse_size = len(box.raw_data)
@@ -61,7 +69,6 @@ def parse_boxes(stream: BinaryIO, max_size: int) -> List[Box]:
child_stream.read(4) # Skip version/flags child_stream.read(4) # Skip version/flags
parse_size -= 4 parse_size -= 4
box.children = parse_boxes(child_stream, parse_size) box.children = parse_boxes(child_stream, parse_size)
# --- End of new logic ---
boxes.append(box) boxes.append(box)
stream.seek(current_offset_in_stream + size) stream.seek(current_offset_in_stream + size)