Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "rsrcdump"
version = "0.1.0"
Expand All @@ -6,14 +10,25 @@ description = "Extract and convert Mac resource forks"
authors = ["Iliyas Jorio <github@jor.io>"]
license = "MIT"
readme = "README.md"
classifiers = [
"Typing :: Typed",
]

[tool.poetry.dependencies]
python = "^3.10"

[tool.poetry.scripts]
rsrcdump = "rsrcdump.__main__:main"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
files = ["rsrcdump/",]
show_column_numbers = true
show_error_codes = true
show_traceback = true
disallow_any_decorated = true
disallow_any_unimported = true
ignore_missing_imports = true
local_partial_types = true
no_implicit_optional = true
strict = true
warn_unreachable = true
6 changes: 4 additions & 2 deletions rsrcdump/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@
from rsrcdump.resconverters import standard_converters, StructConverter, Base16Converter


def load(data_or_path: bytes | PathLike) -> ResourceFork:
def load(data_or_path: bytes | PathLike[str]) -> ResourceFork:
data: bytes
if type(data_or_path) is not bytes:
path = data_or_path
with open(path, 'rb') as f:
data = f.read()
else:
data: bytes = data_or_path
data = data_or_path

try:
adf_entries = unpack_adf(data)
adf_resfork = adf_entries[ADF_ENTRYNUM_RESOURCEFORK]
assert isinstance(adf_resfork, bytes)
fork = ResourceFork.from_bytes(adf_resfork)
except NotADFError:
fork = ResourceFork.from_bytes(data)
Expand Down
20 changes: 12 additions & 8 deletions rsrcdump/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from rsrcdump.textio import set_global_encoding, parse_type_name
from rsrcdump.resconverters import standard_converters, StructConverter, Base16Converter

def main():
def main() -> None:
description = (
"Extract resources from a Macintosh resource fork. "
"https://github.com/jorio/rsrcdump"
Expand Down Expand Up @@ -99,24 +99,26 @@ def main():
for template_arg in struct_specs:
converter, restype = StructConverter.from_template_string_with_typename(template_arg)
if converter:
assert restype is not None
converters[restype] = converter


def load_resmap():
def load_resmap() -> tuple[ResourceFork, dict[int, bytes]]:
with open(inpath, 'rb') as file:
data = file.read()

try:
adf_entries = unpack_adf(data)
adf_resfork = adf_entries[ADF_ENTRYNUM_RESOURCEFORK]
assert isinstance(adf_resfork, bytes)
fork = ResourceFork.from_bytes(adf_resfork)
return fork, adf_entries
except NotADFError:
fork = ResourceFork.from_bytes(data)
return fork, []
return fork, {}


def do_list():
def do_list() -> int:
fork, adf_entries = load_resmap()
print(F"{'Type':4} {'ID':6} {'Size':8} {'Name'}")
print(F"{'-'*4} {'-'*6} {'-'*8} {'-'*32}")
Expand All @@ -128,7 +130,7 @@ def do_list():
return 0


def do_extract():
def do_extract() -> int:
outpath = args.o

# Generate an output path if we're not given one
Expand All @@ -142,12 +144,13 @@ def do_extract():

fork, adf_entries = load_resmap()

metadata = {}
metadata: dict[str, dict[int, str]] = {}

if adf_entries:
metadata["adf"] = {}
del adf_entries[ADF_ENTRYNUM_RESOURCEFORK]
for adf_entry_num, adf_entry in adf_entries.items():
assert isinstance(adf_entry, bytes)
metadata["adf"][adf_entry_num] = base64.b16encode(adf_entry).decode("ascii")

return resource_fork_to_json(
Expand All @@ -159,7 +162,7 @@ def do_extract():
metadata=metadata)


def do_pack():
def do_pack() -> int:
outpath = args.o

# Generate an output path if we're not given one
Expand All @@ -180,7 +183,8 @@ def do_pack():
converters=converters,
only_types=only_types,
skip_types=skip_types,
encoding=args.encoding)
#encoding=args.encoding
)

binary_fork = fork.pack()

Expand Down
6 changes: 3 additions & 3 deletions rsrcdump/adf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Final
from typing import Final, cast

from io import BytesIO
from struct import pack
Expand All @@ -17,7 +17,7 @@ class NotADFError(ValueError):
def unpack_adf(adf_data: bytes) -> dict[int, bytes]:
u = Unpacker(adf_data)

magic, version, filler, num_entries = u.unpack(">LL16sH")
magic, version, filler, num_entries = cast(tuple[int, int, bytes, int], u.unpack(">LL16sH"))

if ADF_MAGIC != magic:
raise NotADFError("AppleDouble magic number not found")
Expand All @@ -30,7 +30,7 @@ def unpack_adf(adf_data: bytes) -> dict[int, bytes]:
for _ in range(num_entries):
entry_offsets.append(u.unpack(">LLL"))

entries = {0: filler} # Entry #0 is invalid -- use it for the filler
entries: dict[int, bytes] = {0: filler} # Entry #0 is invalid -- use it for the filler

for entry_id, offset, length in entry_offsets:
u.seek(offset)
Expand Down
16 changes: 9 additions & 7 deletions rsrcdump/jsonio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
import base64
import os
import json
from collections.abc import Mapping

from rsrcdump.resconverters import ResourceConverter, Base16Converter
from rsrcdump.textio import get_global_encoding, sanitize_type_name, sanitize_resource_name, parse_type_name
from rsrcdump.resfork import Resource, ResourceFork


class JSONEncoderBase16Fallback(json.JSONEncoder):
def default(self, o: Any):
__slots__ = ()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think __slots__ are worth the maintenance overhead in a small project like this. Does mypy need them?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but mypy will give you warnings when accessing attributes that don't exist in slots, and Python will use memory more efficiently when objects have __slots__ defined


def default(self, o: bool | bytes) -> object:
if isinstance(o, bytes):
return base64.b16encode(o).decode('ascii')
else:
return JSONEncoderBase16Fallback(self, o)
return super().default(o)


def resource_fork_to_json(
Expand All @@ -23,11 +26,11 @@ def resource_fork_to_json(
include_types: list[bytes] = [],
exclude_types: list[bytes] = [],
converters: dict[bytes, ResourceConverter] = {},
metadata: Any = None,
metadata: Any | None = None,
quiet: bool = False,
) -> int:

json_blob: dict = {'_metadata': {
json_blob: dict[str, Any] = {'_metadata': {
'junk1': fork.junk_nextresmap,
'junk2': fork.junk_filerefnum,
'file_attributes': fork.file_attributes
Expand Down Expand Up @@ -75,7 +78,7 @@ def resource_fork_to_json(
obj = converter.unpack(res, fork)
separate_file = bool(converter.separate_file)
except BaseException as convert_exception:
errors.append(f"Failed to convert {res_type_key} #{res_id}: {convert_exception}")
errors.append(f"Failed to convert {res_type_key} #{res_id!r}: {convert_exception}")
if not quiet:
print("!!!", errors[-1])
wrapper['conversion_error'] = str(convert_exception)
Expand Down Expand Up @@ -118,7 +121,7 @@ def resource_fork_to_json(


def json_to_resource_fork(
json_blob: dict,
json_blob: dict[str, Any],
converters: dict[bytes, ResourceConverter],
only_types: list[bytes] = [],
skip_types: list[bytes] = [],
Expand Down Expand Up @@ -169,4 +172,3 @@ def json_to_resource_fork(
fork.tree[res_type][res_num] = res

return fork

11 changes: 8 additions & 3 deletions rsrcdump/packutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@
import struct

class Unpacker:
__slots__ = ("data", "offset")

def __init__(self, data: bytes, offset: int=0) -> None:
self.data = data
self.offset = offset

def unpack(self, fmt: str) -> tuple:
def unpack(self, fmt: str) -> tuple[Any, ...]:
record_length = struct.calcsize(fmt)
fields = struct.unpack_from(fmt, self.data, self.offset)
self.offset += record_length
return fields

def seek(self, offset: int):
def seek(self, offset: int) -> None:
self.offset = offset

def skip(self, n: int):
def skip(self, n: int) -> None:
self.offset += n

def read(self, size: int) -> bytes:
Expand All @@ -41,7 +43,10 @@ def eof(self) -> bool:
def remaining(self) -> int:
return len(self.data) - self.offset


class WritePlaceholder:
__slots__ = ("stream", "fmt", "position", "committed")

def __init__(self, stream: BytesIO, fmt: str) -> None:
self.stream = stream
self.fmt = fmt
Expand Down
39 changes: 23 additions & 16 deletions rsrcdump/pict.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
import struct
from ctypes import ArgumentError
from dataclasses import dataclass
from typing import TYPE_CHECKING

from rsrcdump.packutils import Unpacker
from rsrcdump.structtemplate import StructTemplate
from rsrcdump.textio import get_global_encoding

if TYPE_CHECKING:
from typing_extensions import Self


class PICTError(BaseException):
pass
__slots__ = ()


@dataclass(frozen=True)
Expand All @@ -23,26 +27,26 @@ class PICTRect:
right: int

@property
def width(self):
def width(self) -> int:
return self.right - self.left

@property
def height(self):
def height(self) -> int:
return self.bottom - self.top

def offset(self, dy: int = 0, dx: int = 0) -> PICTRect:
return PICTRect(self.top + dy, self.left + dx, self.bottom + dy, self.right + dx)

def intersect(self, r: PICTRect):
t = max(self.top, r.top)
l = max(self.left, r.left)
b = min(self.bottom, r.bottom)
r = min(self.right, r.right)
def intersect(self, rect: PICTRect) -> Self:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer -> PICTRect, which is more explicit at a glance.

t = max(self.top, rect.top)
l = max(self.left, rect.left)
b = min(self.bottom, rect.bottom)
r = min(self.right, rect.right)

b = max(t, b)
r = max(l, r)

return PICTRect(t, l, b, r)
return self.__class__(t, l, b, r)

def __repr__(self) -> str:
return f"({self.left},{self.top} {self.width}x{self.height})"
Expand Down Expand Up @@ -330,7 +334,7 @@ class Bitmap(Xmap):
frame_r: int

@property
def pixelsize(self) -> int:
def pixelsize(self) -> int: # type: ignore[override]
return 1


Expand Down Expand Up @@ -586,6 +590,7 @@ def read_pict_bits(u: Unpacker, opcode: int) -> tuple[PICTRect, bytes]:
if maskrgn_bits:
mask_8bit = unpack_maskrgn(maskrgn_rect, maskrgn_bits)

assert palette is not None
bgra = read_pixmap_image_data(u, raster, palette)

# Apply mask
Expand Down Expand Up @@ -642,7 +647,7 @@ def get_reserved_opcode_size(k: int) -> int:
return -1


def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect):
def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect) -> bytes:
intersection = src_rect.intersect(dst_rect)

src_io = io.BytesIO(src_data)
Expand All @@ -661,7 +666,7 @@ def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect):
return dst_io.getvalue()


def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data: bytes):
def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data: bytes) -> bytes:
intersection = src_rect.intersect(dst_rect)

src_dy, src_dx = intersection.top - src_rect.top, intersection.left - src_rect.left
Expand All @@ -685,7 +690,7 @@ def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data
return dst_io.getvalue()


def apply_8bit_mask_on_32bit_image(msk_rect: PICTRect, msk_data: bytes, dst_rect: PICTRect, dst_data: bytes):
def apply_8bit_mask_on_32bit_image(msk_rect: PICTRect, msk_data: bytes, dst_rect: PICTRect, dst_data: bytes) -> bytes:
intersection = dst_rect.intersect(msk_rect)

msk_dy, msk_dx = intersection.top - msk_rect.top, intersection.left - msk_rect.left
Expand Down Expand Up @@ -785,19 +790,21 @@ def convert_pict_to_image(data: bytes) -> tuple[int, int, bytes]:
if opcode not in (Op.LongComment, Op.LongText, Op.ShortComment, Op.DefHilite):
print(F"!!! skipping PICT opcode {opcode_name} at offset {u.offset}")

template = opcode_templates[opcode]
template = opcode_templates[Op(opcode)]
values = u.unpack(template.format)
annotated = template.tag_values(values)
assert isinstance(annotated, dict)

# Skip rest of variable-length records
if "len" in annotated:
# if opcode in (Op.LongText, Op.LongComment):
# text = u.read(annotated["len"]).decode(get_global_encoding(), "replace")
# print(F"{opcode_name} text contents: {text}")
# continue
u.skip(annotated["len"])
u.skip(int(annotated["len"]))
elif "datalen" in annotated:
u.skip(annotated["datalen"] - template.record_length)
datalen = int(annotated["datalen"])
u.skip(datalen - template.record_length)

else:
raise PICTError(F"unsupported PICT opcode {opcode_name}")
Expand Down
Empty file added rsrcdump/py.typed
Empty file.
Loading