├── README ├── __init__.py ├── converter.py ├── formats.py ├── macpaint.py └── requirements.txt /README: -------------------------------------------------------------------------------- 1 | A MacPaint file format translator. Convert MacPaint PNTG files to PNG, and vice-versa. 2 | Dithering not supported yet, PNGs are converted to black/white. 3 | 4 | If you use this, please submit your MacPaint files to www.macpaint.org! 5 | 6 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thejoelpatrol/macpaint_file/903a1bbe25499aeef20f3e2b3ed86f27e0f2cff7/__init__.py -------------------------------------------------------------------------------- /converter.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from macpaint import MacPaintFile 3 | from formats import PNGFile 4 | 5 | def main(args): 6 | if args.from_macpaint: 7 | macpaint_file = MacPaintFile.from_file(args.infile) 8 | PNGFile.write_image(args.outfile, macpaint_file) 9 | elif args.to_macpaint: 10 | png = PNGFile(args.infile) 11 | macpaint_file = png.convert() 12 | macpaint_file.write_file(args.outfile) 13 | else: 14 | raise RuntimeError("must specify either --from-macpaint or --to-macpaint") 15 | 16 | if __name__ == "__main__": 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument("--from-macpaint", "-m", action="store_true", help="Convert from MacPaint to PNG") 19 | parser.add_argument("--to-macpaint", "-p", action="store_true", help="Convert from PNG to MacPaint") 20 | parser.add_argument("--informat") 21 | parser.add_argument("infile", help="Input file path") 22 | parser.add_argument("outfile", help="Output file path") 23 | args = parser.parse_args() 24 | main(args) -------------------------------------------------------------------------------- /formats.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Sequence, List 3 | import png 4 | import os 5 | import struct 6 | from macpaint import MacPaintFile 7 | 8 | GAMMA = 2.2 9 | 10 | def chunks(lst: Sequence, n: int): 11 | """Yield successive n-sized chunks from lst.""" 12 | # https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks 13 | for i in range(0, len(lst), n): 14 | yield lst[i:i + n] 15 | 16 | def dither(grey_rows: List[bytes]) -> List[bytes]: 17 | """ 18 | TODO Atkinson dithering for greyscale image; for now only rounding grey to black/white 19 | :param grey_rows: 0-255 values, one byte per pixel 20 | :return: 0 or 255 values only, one byte per pixel 21 | """ 22 | dithered = list() 23 | for row in grey_rows: 24 | dithered_row = bytearray() 25 | for b in row: 26 | if b > 0x80: 27 | dithered_row.append(MacPaintFile.WHITE) 28 | else: 29 | dithered_row.append(MacPaintFile.BLACK) 30 | dithered.append(dithered_row) 31 | return dithered 32 | 33 | def to_greyscale(color_rows: List[List[int]], alpha: bool) -> List[bytes]: 34 | """ 35 | 36 | :param color_rows: 3 or 4 bytes per pixel, 8-bit RGB/A 37 | :param alpha: 4th alpha byte included? 38 | :return: 1 byte per pixel, 8-bit greyscale 39 | """ 40 | if alpha: 41 | bytes_per_pixel = 4 42 | print("discarding alpha channel, sorry! re-encode without alpha for better result") 43 | else: 44 | bytes_per_pixel = 3 45 | greyscale_rows = list() 46 | for i, row in enumerate(color_rows): 47 | grey_row = bytearray() 48 | if len(row) % bytes_per_pixel != 0: 49 | raise RuntimeError(f"row {i} does not contain a multiple of {bytes_per_pixel} values; must be 8-bit RGB{'A' if alpha else ''}") 50 | for pixel in chunks(row, bytes_per_pixel): 51 | if alpha: 52 | [r, g, b, a] = pixel 53 | else: 54 | [r, g, b] = pixel 55 | # https://stackoverflow.com/questions/687261/converting-rgb-to-grayscale-intensity 56 | r_lin = pow(r / 255, GAMMA) 57 | g_lin = pow(g / 255, GAMMA) 58 | b_lin = pow(b / 255, GAMMA) 59 | Y = 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin 60 | L = (116 * pow(Y, 1/3) - 16) / 100 61 | if L < -5: 62 | raise RuntimeError(f"unexpectedly negative Luminance: {L}") 63 | L = max(L, 0) 64 | byte = round(L * 255) 65 | grey_row.append(byte) 66 | greyscale_rows.append(grey_row) 67 | return greyscale_rows 68 | 69 | 70 | class ImageConverter(ABC): 71 | @abstractmethod 72 | def convert(self) -> MacPaintFile: 73 | raise NotImplementedError() 74 | 75 | @abstractmethod 76 | def write_image(self, path: str, macpaint_file: MacPaintFile): 77 | raise NotImplementedError() 78 | 79 | @classmethod 80 | def get(cls, path: str): 81 | filename = os.path.basename(path) 82 | if filename.lower().endswith(".png"): 83 | return PNGFile(path) 84 | else: 85 | raise NotImplementedError(f"filetype of {filename} not supported yet; only PNG so far :(") 86 | 87 | class PNGFile(ImageConverter): 88 | COLORMAP = 1 89 | GREYSCALE = 2 90 | ALPHA = 4 91 | def __init__(self, path: str): 92 | reader = png.Reader(filename=path) 93 | self.width, self.height, self.rows, self.info = reader.read() 94 | self.rows = [[b for b in row] for row in self.rows] 95 | if reader.bitdepth == 1: 96 | self.rows = [[255 if b else 0 for b in row] for row in self.rows] 97 | elif reader.bitdepth != 8: 98 | raise NotImplementedError("this PNG does not use 8-bit color/grey; only 1-bit or 8-bit supported") 99 | # https://gitlab.com/drj11/pypng/-/blob/612d2bde70805fc85979f176410fc7fb9f3c0754/code/png.py#L1665 100 | # https://www.w3.org/TR/png/#6Colour-values 101 | colormap = bool(reader.color_type & self.COLORMAP) 102 | if colormap: 103 | raise RuntimeError("PNG uses color map; not supported, must be greyscale or RGB/RGBA") 104 | greyscale = not (reader.color_type & self.GREYSCALE) 105 | alpha = bool(reader.color_type & self.ALPHA) 106 | if greyscale and alpha: 107 | raise RuntimeError("greyscale with alpha PNG not supported; please remove alpha channel") 108 | if not greyscale: 109 | self.rows = to_greyscale(self.rows, alpha) 110 | if self._need_dither(): 111 | self.rows = dither(self.rows) 112 | 113 | #if not isinstance(self.rows, list): 114 | # self.rows = list(self.rows) 115 | self.rows: List[List[int]] 116 | 117 | def _need_dither(self): 118 | need_dither = False 119 | for row in self.rows: 120 | for b in row: 121 | if b not in (MacPaintFile.WHITE, MacPaintFile.BLACK): 122 | need_dither = True 123 | break 124 | return need_dither 125 | 126 | def convert(self) -> MacPaintFile: 127 | rows = self.rows 128 | if self.height > MacPaintFile.HEIGHT: 129 | rows = rows[:MacPaintFile.HEIGHT] 130 | if self.height < MacPaintFile.HEIGHT: 131 | add_rows = MacPaintFile.HEIGHT - height 132 | for _ in range(add_rows): 133 | rows.append([MacPaintFile.WHITE] * MacPaintFile.WIDTH) 134 | if self.width > MacPaintFile.WIDTH: 135 | rows = [row[:MacPaintFile.WIDTH] for row in rows] 136 | if self.width < MacPaintFile.WIDTH: 137 | new_rows = list() 138 | add_pixels = MacPaintFile.WIDTH - self.width 139 | for row in rows: 140 | new_rows.append(row + [MacPaintFile.WHITE] * add_pixels) 141 | rows = new_rows 142 | return MacPaintFile.from_scanlines(rows) 143 | 144 | @classmethod 145 | def write_image(cls, path: str, macpaint_file: MacPaintFile): 146 | with open(path, 'wb') as f: 147 | w = png.Writer(macpaint_file.WIDTH, macpaint_file.HEIGHT, greyscale=True) 148 | w.write(f, macpaint_file.bitmap) 149 | 150 | 151 | -------------------------------------------------------------------------------- /macpaint.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from collections.abc import Sequence 3 | from typing import List 4 | import sys 5 | import png 6 | import subprocess 7 | 8 | # https://web.archive.org/web/20080705155158/http://developer.apple.com/technotes/tn/tn1023.html 9 | # https://web.archive.org/web/20150424145627/http://www.idea2ic.com/File_Formats/macpaint.pdf 10 | # http://www.weihenstephan.org/~michaste/pagetable/mac/Inside_Macintosh.pdf 11 | # https://en.wikipedia.org/wiki/PackBits 12 | 13 | def chunks(lst: Sequence, n: int): 14 | """Yield successive n-sized chunks from lst.""" 15 | # https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks 16 | for i in range(0, len(lst), n): 17 | yield lst[i:i + n] 18 | 19 | def _pack_bits(line: bytes) -> bytes: 20 | """ 21 | "PackBits compresses srcBytes bytes of data starting at srcPtr and stores the compressed 22 | data at dstPtr. The value of srcBytes should not be greater than 127. Bytes are compressed 23 | when there are three or more consecutive equal bytes." 24 | -- http://www.weihenstephan.org/~michaste/pagetable/mac/Inside_Macintosh.pdf 25 | """ 26 | if len(line) > 127: 27 | raise RuntimeError(f"scanline is too long: {len(line)}; can only compress 127 bytes at a time, MacPaint lines should be 72 bytes") 28 | packed = bytearray() 29 | i = 0 30 | while i < len(line): 31 | # precondition: i < len(line) - 2 32 | if line[i : i + 3] == bytearray([line[i]] * 3): 33 | j = i 34 | # 3+ bytes the same, compress these 35 | while j < len(line) and line[j] == line[i] and j - i < 127: 36 | j += 1 37 | count = j - i 38 | # the sign bit of the header is used to indicate whether the byte is repeated or a literal string 39 | # when repeated, the count is the two's complement negative value of the header 40 | # this must have been an efficient use of 68000 instructions or something 41 | header = 256 - count + 1 42 | byte = line[i] 43 | packed += struct.pack(">B", header) 44 | packed.append(byte) 45 | else: 46 | # literal bytes 47 | # postcondition: j <= len(line) - 3 or j == len(line} 48 | j = i + 1 49 | while j < len(line): 50 | end = min(j + 3, len(line)) 51 | if line[j : end] == bytearray([line[j]] * 3): 52 | break 53 | j += 1 54 | count = j - i 55 | header = count - 1 # literal bytes header is 1+n: https://en.wikipedia.org/wiki/PackBits 56 | literal_bytes = line[i : j] 57 | packed += struct.pack(">B", header) + literal_bytes 58 | i = j 59 | return packed 60 | 61 | 62 | class Header: 63 | SIZE = 512 64 | 65 | def __init__(self, version: int, patterns: List[bytes], reserved: bytes, raw: bytes): 66 | self.version = version 67 | self.patterns = patterns 68 | self.reserved = reserved 69 | self.raw_str = raw 70 | 71 | @classmethod 72 | def from_file(cls, path: str): 73 | with open(path, 'rb') as f: 74 | d = f.read() 75 | return cls.parse(d) 76 | 77 | @classmethod 78 | def parse(cls, raw: bytes): 79 | version = struct.unpack("=I", raw[:4])[0] 80 | _patterns = struct.unpack("=" + 38*"8s", raw[4:308]) 81 | patterns = list(_patterns) 82 | reserved = raw[308:] 83 | return cls(version, patterns, reserved, raw) 84 | 85 | @classmethod 86 | def gen_default(cls): 87 | version = 0 88 | patterns = [b'\0' for _ in range(304)] 89 | future = b'\0' * 204 90 | data = bytes() 91 | return cls(version, patterns, future, data) 92 | 93 | def pack(self) -> bytes: 94 | version = struct.pack(">I", self.version) 95 | return version + b''.join(self.patterns) + self.reserved 96 | 97 | class MacPaintFile: 98 | WIDTH = 576 99 | HEIGHT = 720 100 | WHITE = 255 101 | BLACK = 0 102 | 103 | def __init__(self, header: Header, data: bytes, bitmap: List[List[int]] = None): 104 | self.header = header 105 | self.data = data 106 | decompressed_data = self._unpack_bits(self.data) 107 | self.scanlines = list(chunks(decompressed_data, self.WIDTH // 8)) 108 | #self._generate_bitmap() 109 | if len(self.scanlines) > self.HEIGHT: 110 | print("found {} junk(?) scanlines at end of file, discarding".format(len(self.scanlines) - self.HEIGHT)) 111 | self.scanlines = self.scanlines[:self.HEIGHT] 112 | assert len(self.scanlines) == self.HEIGHT, "error: got {} scanlines, expected {}".format(len(self.scanlines), self.HEIGHT) 113 | self._bitmap: List[List[int]] = bitmap 114 | 115 | @classmethod 116 | def from_file(cls, path: str): 117 | header = Header.from_file(path) 118 | with open(path, 'rb') as f: 119 | filedata = f.read() 120 | data = filedata[Header.SIZE:] 121 | return cls(header, data) 122 | 123 | @classmethod 124 | def from_scanlines(cls, bitmap: List[List[int]]) -> "MacPaintFile": 125 | header = Header.gen_default() 126 | packed_bits = cls._gen_packed_data(bitmap) 127 | return cls(header, packed_bits, bitmap) 128 | 129 | def write_file(self, path: str): 130 | with open(path, 'wb') as f: 131 | f.write(self.header.pack() + self.data) 132 | 133 | if sys.platform == "darwin": 134 | PNTGMPNT = "50 4E 54 47 4D 50 4E 54 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" 135 | command = ["xattr", "-wx", "com.apple.FinderInfo", PNTGMPNT, path] 136 | try: 137 | subprocess.check_call(command) 138 | except: 139 | print(f"warning: could not set creator code/type code with xattr; MacPaint will not be able to open {path} unless you change the creator/type codes with ResEdit or xattr") 140 | 141 | def _unpack_bits(self, scanline_data: bytes) -> bytearray: 142 | result = [] 143 | i = 0 144 | while i < len(scanline_data): 145 | header = scanline_data[i] 146 | if header == 128: 147 | # ignored, next byte is another header 148 | i += 1 149 | elif header > 128: 150 | # twos complement -1 to -127 151 | # next byte repeated n times 152 | count = 256 - header + 1 153 | _byte = scanline_data[i + 1] 154 | decompressed = count * [_byte] 155 | result += decompressed 156 | i += 2 157 | else: 158 | # n bytes of literal uncompressed data 159 | count = header + 1 160 | _bytes = scanline_data[i+1 : i + 1 + count] 161 | result += list(_bytes) 162 | i += 1 + count 163 | return bytearray(result) 164 | 165 | @classmethod 166 | def _gen_packed_data(cls, bitmap: List[List[int]]) -> bytes: 167 | assert len(bitmap) == cls.HEIGHT, f"trying to pack {len(bitmap)} scanlines, expected {cls.HEIGHT}" 168 | bits = bytearray() 169 | for _, raw_line in enumerate(bitmap): 170 | i = 0 171 | bit_line = bytearray() 172 | while i < len(raw_line): 173 | byte = 0 174 | for j in range(8): 175 | value = raw_line[i + j] 176 | assert value in (cls.BLACK, cls.WHITE), f"got bad value for a pixel color: {value}" 177 | if value == cls.BLACK: 178 | byte |= 0x1 << (7 - j) # higher order bits come first left->right 179 | i += 8 180 | bit_line.append(byte) 181 | assert len(bit_line) == cls.WIDTH / 8, f"error in condensing bytes into bits; line is only {len(bit_line)} bytes" 182 | packed_line = _pack_bits(bit_line) 183 | bits += packed_line 184 | return bits 185 | 186 | def _generate_bitmap(self): 187 | bitmap = [] 188 | for i in range(len(self.scanlines)): 189 | scanline = self.scanlines[i] 190 | bitmap_line = [] 191 | for j in range(self.WIDTH // 8): 192 | byte = scanline[j] 193 | for k in range(8): 194 | mask = 0x1 << (7 - k) # higher order bits come first left->right 195 | # PNG: 0 == black; MacPaint: 0 == bit/pixel not set ie white 196 | bitmap_line.append(self.BLACK if (byte & mask) > 0 else self.WHITE) 197 | bitmap.append(bitmap_line) 198 | return bitmap 199 | 200 | @property 201 | def bitmap(self): 202 | if self._bitmap is None: 203 | self._bitmap = self._generate_bitmap() 204 | return self._bitmap 205 | 206 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pypng 2 | 3 | --------------------------------------------------------------------------------