Skip to content

Commit 170ea63

Browse files
committed
Add image preview data URLs
1 parent ef8cd94 commit 170ea63

File tree

8 files changed

+707
-6
lines changed

8 files changed

+707
-6
lines changed

scripts/im8c_to_bmp.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import struct
5+
from pathlib import Path
6+
7+
8+
def rgb565_to_rgb888(value):
9+
red5 = (value >> 11) & 0x1F
10+
green6 = (value >> 5) & 0x3F
11+
blue5 = value & 0x1F
12+
return (
13+
(red5 * 255 + 15) // 31,
14+
(green6 * 255 + 31) // 63,
15+
(blue5 * 255 + 15) // 31,
16+
)
17+
18+
19+
def parse_im8c_var(path):
20+
data = path.read_bytes()
21+
if data[:8] != b"**TI83F*":
22+
raise ValueError(f"{path} is not a TI var file")
23+
24+
if len(data) < 74:
25+
raise ValueError(f"{path} is too short to contain an IM8C payload")
26+
27+
data_length = data[57] | (data[58] << 8)
28+
payload = data[74:72 + data_length]
29+
if payload[:4] != b"IM8C":
30+
raise ValueError(f"{path} does not contain an IM8C payload")
31+
32+
pos = 4
33+
width = payload[pos] | (payload[pos + 1] << 8) | (payload[pos + 2] << 16)
34+
pos += 3
35+
height = payload[pos] | (payload[pos + 1] << 8) | (payload[pos + 2] << 16)
36+
pos += 3
37+
38+
palette_marker = payload[pos]
39+
pos += 1
40+
if palette_marker != 0x01:
41+
raise ValueError(f"Unsupported palette marker 0x{palette_marker:02X}")
42+
43+
has_alpha = payload[pos] != 0
44+
pos += 1
45+
transparent_index = payload[pos]
46+
pos += 1
47+
48+
palette_count = payload[pos]
49+
pos += 1
50+
if palette_count == 0:
51+
palette_count = 256
52+
53+
palette = []
54+
for _ in range(palette_count):
55+
palette.append(payload[pos] | (payload[pos + 1] << 8))
56+
pos += 2
57+
58+
return {
59+
"width": width,
60+
"height": height,
61+
"has_alpha": has_alpha,
62+
"transparent_index": transparent_index,
63+
"palette": palette,
64+
"compressed_image_data": payload[pos:],
65+
}
66+
67+
68+
def decode_im8c_rle(width, height, compressed_image_data):
69+
expected_pixels = width * height
70+
pixels = bytearray()
71+
pos = 0
72+
73+
while pos < len(compressed_image_data) and len(pixels) < expected_pixels:
74+
control = compressed_image_data[pos]
75+
pos += 1
76+
77+
if control & 0x80:
78+
if pos >= len(compressed_image_data):
79+
raise ValueError("Truncated IM8C RLE run")
80+
81+
run_length = (control & 0x7F) + 2
82+
palette_index = compressed_image_data[pos]
83+
pos += 1
84+
pixels.extend([palette_index] * min(run_length, expected_pixels - len(pixels)))
85+
else:
86+
literal_length = control + 1
87+
available = min(literal_length, len(compressed_image_data) - pos, expected_pixels - len(pixels))
88+
pixels.extend(compressed_image_data[pos:pos + available])
89+
pos += available
90+
91+
if len(pixels) < expected_pixels:
92+
pixels.extend([0] * (expected_pixels - len(pixels)))
93+
94+
return pixels
95+
96+
97+
def indices_to_rgba(width, height, indices, palette, has_alpha, transparent_index):
98+
rgba = bytearray()
99+
for y in range(height):
100+
for x in range(width):
101+
palette_index = indices[y * width + x]
102+
if has_alpha and palette_index == transparent_index:
103+
rgba.extend((0, 0, 0, 0))
104+
else:
105+
rgba.extend((*rgb565_to_rgb888(palette[palette_index]), 255))
106+
return rgba
107+
108+
109+
def make_bmp(width, height, rgba_pixels):
110+
row_stride = width * 4
111+
pixel_array_size = row_stride * height
112+
pixel_offset = 14 + 124
113+
file_size = pixel_offset + pixel_array_size
114+
115+
bmp = bytearray()
116+
bmp.extend(b"BM")
117+
bmp.extend(struct.pack("<IHHI", file_size, 0, 0, pixel_offset))
118+
bmp.extend(struct.pack("<IIIHHIIIIII", 124, width, height, 1, 32, 3, pixel_array_size, 2835, 2835, 0, 0))
119+
bmp.extend(struct.pack("<IIIII", 0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000, 0x73524742))
120+
bmp.extend(b"\x00" * 36)
121+
bmp.extend(struct.pack("<III", 0, 0, 0))
122+
bmp.extend(struct.pack("<IIII", 0, 0, 0, 0))
123+
124+
for row in range(height):
125+
src_y = height - 1 - row
126+
row_start = src_y * width * 4
127+
for x in range(width):
128+
src = row_start + x * 4
129+
bmp.extend((rgba_pixels[src + 2], rgba_pixels[src + 1], rgba_pixels[src], rgba_pixels[src + 3]))
130+
131+
return bmp
132+
133+
134+
def main():
135+
parser = argparse.ArgumentParser(description="Decode a TI Python IM8C AppVar into a BMP preview.")
136+
parser.add_argument("input", type=Path, help="Input .8xv file containing an IM8C payload")
137+
parser.add_argument("output", type=Path, nargs="?", help="Output BMP path (defaults next to the input)")
138+
args = parser.parse_args()
139+
140+
output = args.output if args.output is not None else args.input.with_suffix(".bmp")
141+
142+
parsed = parse_im8c_var(args.input)
143+
indices = decode_im8c_rle(parsed["width"], parsed["height"], parsed["compressed_image_data"])
144+
rgba_pixels = indices_to_rgba(
145+
parsed["width"],
146+
parsed["height"],
147+
indices,
148+
parsed["palette"],
149+
parsed["has_alpha"],
150+
parsed["transparent_index"],
151+
)
152+
bmp = make_bmp(parsed["width"], parsed["height"], rgba_pixels)
153+
output.write_bytes(bmp)
154+
print(f"Wrote {output} ({parsed['width']}x{parsed['height']})")
155+
156+
157+
if __name__ == "__main__":
158+
main()

src/TypeHandlers/TH_Picture.cpp

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ namespace tivars::TypeHandlers
1919
{
2020
namespace
2121
{
22+
constexpr std::array<std::array<uint8_t, 3>, 16> colorPicturePalette = {{
23+
{{255, 255, 255}},
24+
{{0, 0, 255}},
25+
{{255, 0, 0}},
26+
{{0, 0, 0}},
27+
{{255, 0, 255}},
28+
{{0, 160, 0}},
29+
{{255, 165, 0}},
30+
{{165, 96, 42}},
31+
{{0, 0, 128}},
32+
{{96, 192, 255}},
33+
{{255, 255, 0}},
34+
{{255, 255, 255}},
35+
{{216, 216, 216}},
36+
{{176, 176, 176}},
37+
{{128, 128, 128}},
38+
{{72, 72, 72}},
39+
}};
40+
2241
uint16_t read_le16(const data_t& data)
2342
{
2443
if (data.size() < 2)
@@ -51,6 +70,91 @@ namespace tivars::TypeHandlers
5170
}
5271
return data;
5372
}
73+
74+
data_t make_mono_picture_preview_rgb(const data_t& data)
75+
{
76+
data_t rgb;
77+
rgb.reserve(TH_Picture::monoPictureWidth * TH_Picture::monoPictureHeight * 3);
78+
79+
const size_t rowByteCount = TH_Picture::monoPictureWidth / 8;
80+
for (size_t y = 0; y < TH_Picture::monoPictureHeight; y++)
81+
{
82+
const size_t rowStart = TH_Picture::minimumDataByteCount + y * rowByteCount;
83+
for (size_t byteIdx = 0; byteIdx < rowByteCount; byteIdx++)
84+
{
85+
const uint8_t packed = rowStart + byteIdx < data.size() ? data[rowStart + byteIdx] : 0;
86+
for (int bit = 7; bit >= 0; bit--)
87+
{
88+
const bool isBlack = ((packed >> bit) & 1U) != 0;
89+
const uint8_t channel = isBlack ? 0x00 : 0xFF;
90+
rgb.push_back(channel);
91+
rgb.push_back(channel);
92+
rgb.push_back(channel);
93+
}
94+
}
95+
}
96+
97+
return rgb;
98+
}
99+
100+
data_t make_color_picture_preview_rgb(const data_t& data)
101+
{
102+
data_t rgb;
103+
rgb.reserve(TH_Picture::colorPictureWidth * TH_Picture::colorPictureHeight * 3);
104+
105+
const size_t rowByteCount = TH_Picture::colorPictureWidth / 2;
106+
for (size_t y = 0; y < TH_Picture::colorPictureHeight; y++)
107+
{
108+
const size_t rowStart = TH_Picture::minimumDataByteCount + y * rowByteCount;
109+
for (size_t byteIdx = 0; byteIdx < rowByteCount; byteIdx++)
110+
{
111+
const uint8_t packed = rowStart + byteIdx < data.size() ? data[rowStart + byteIdx] : 0;
112+
for (const uint8_t index : { static_cast<uint8_t>((packed >> 4) & 0x0F), static_cast<uint8_t>(packed & 0x0F) })
113+
{
114+
const auto& color = colorPicturePalette[index];
115+
rgb.push_back(color[0]);
116+
rgb.push_back(color[1]);
117+
rgb.push_back(color[2]);
118+
}
119+
}
120+
}
121+
122+
return rgb;
123+
}
124+
125+
data_t make_image_preview_rgb(const data_t& data)
126+
{
127+
data_t rgb(static_cast<size_t>(TH_Picture::imageWidth) * TH_Picture::imageHeight * 3, 0x00);
128+
const size_t rowStride = TH_Picture::imageWidth * 2 + TH_Picture::minimumDataByteCount;
129+
const size_t pixelStart = TH_Picture::minimumDataByteCount + 1;
130+
131+
for (size_t storedRow = 0; storedRow < TH_Picture::imageHeight; storedRow++)
132+
{
133+
const size_t rowStart = pixelStart + storedRow * rowStride;
134+
if (rowStart >= data.size())
135+
{
136+
break;
137+
}
138+
139+
const size_t y = TH_Picture::imageHeight - 1 - storedRow;
140+
for (size_t x = 0; x < TH_Picture::imageWidth; x++)
141+
{
142+
const size_t src = rowStart + x * 2;
143+
if (src + 1 >= data.size())
144+
{
145+
break;
146+
}
147+
148+
const auto color = rgb565_to_rgb888(static_cast<uint16_t>(data[src]) | (static_cast<uint16_t>(data[src + 1]) << 8));
149+
const size_t dst = (y * TH_Picture::imageWidth + x) * 3;
150+
rgb[dst] = color[0];
151+
rgb[dst + 1] = color[1];
152+
rgb[dst + 2] = color[2];
153+
}
154+
}
155+
156+
return rgb;
157+
}
54158
}
55159

56160
data_t TH_Picture::makeDataFromString(const std::string& str, const options_t& options, const TIVarFile* _ctx)
@@ -105,6 +209,7 @@ namespace tivars::TypeHandlers
105209
{"dataWidth", monoPictureWidth / 8},
106210
{"dataHeight", monoPictureHeight},
107211
};
212+
j["previewImageDataUrl"] = make_bmp_data_url(monoPictureWidth, monoPictureHeight, make_mono_picture_preview_rgb(data));
108213
break;
109214

110215
case colorPictureDataByteCount:
@@ -121,6 +226,7 @@ namespace tivars::TypeHandlers
121226
{"dataWidth", colorPictureWidth / 2},
122227
{"dataHeight", colorPictureHeight},
123228
};
229+
j["previewImageDataUrl"] = make_bmp_data_url(colorPictureWidth, colorPictureHeight, make_color_picture_preview_rgb(data));
124230
break;
125231

126232
case imageDataByteCount:
@@ -141,6 +247,7 @@ namespace tivars::TypeHandlers
141247
{"dataWidth", 2 * imageWidth + minimumDataByteCount},
142248
{"dataHeight", imageHeight},
143249
};
250+
j["previewImageDataUrl"] = make_bmp_data_url(imageWidth, imageHeight, make_image_preview_rgb(data));
144251
break;
145252

146253
default:

0 commit comments

Comments
 (0)