1use core::fmt;
10
11#[derive(Debug)]
13pub enum TilesetError {
14 PngDecode(String),
16 InvalidDimensions(u32, u32, u16, u16),
18 EmptyCodepage,
20 UnsupportedPixelFormat(String),
22 ZeroTileSize,
24 ZeroSpacing,
26}
27
28impl fmt::Display for TilesetError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::PngDecode(e) => write!(f, "png decode failed: {e}"),
32 Self::InvalidDimensions(iw, ih, tw, th) => {
33 write!(f, "image {iw}x{ih} is not divisible by tile size {tw}x{th}")
34 }
35 Self::EmptyCodepage => write!(f, "codepage mapping has no entries"),
36 Self::UnsupportedPixelFormat(fmt_name) => {
37 write!(
38 f,
39 "unsupported pixel format: {fmt_name}; expected RGBA8 or RGB8"
40 )
41 }
42 Self::ZeroTileSize => {
43 write!(f, "tile_width and tile_height must be non-zero")
44 }
45 Self::ZeroSpacing => {
46 write!(f, "spacing_cells_x and spacing_cells_y must be non-zero")
47 }
48 }
49 }
50}
51
52impl std::error::Error for TilesetError {}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum Codepage {
61 Cp437,
65 Unicode {
69 start: char,
71 },
72 Identity,
82 Custom(Vec<char>),
86}
87
88impl Codepage {
89 #[must_use]
92 #[allow(clippy::cast_possible_truncation)]
93 pub fn codepoint(&self, i: usize) -> Option<char> {
94 match self {
95 Self::Cp437 => CP437_TO_UNICODE.get(i).copied(),
96 Self::Unicode { start } => {
97 let scalar = (*start as u32).checked_add(i as u32)?;
98 char::from_u32(scalar)
99 }
100 Self::Identity => char::from_u32(i as u32),
101 Self::Custom(table) => table.get(i).copied(),
102 }
103 }
104
105 #[must_use]
107 pub const fn len(&self) -> Option<usize> {
108 match self {
109 Self::Cp437 => Some(256),
110 Self::Unicode { .. } | Self::Identity => None,
111 Self::Custom(t) => Some(t.len()),
112 }
113 }
114
115 #[must_use]
117 pub fn is_empty(&self) -> bool {
118 self.len() == Some(0)
119 }
120}
121
122pub const CP437_TO_UNICODE: [char; 256] = [
124 '\u{0000}', '\u{263A}', '\u{263B}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
125 '\u{25D8}', '\u{25CB}', '\u{25D9}', '\u{2642}', '\u{2640}', '\u{266A}', '\u{266B}', '\u{263C}',
126 '\u{25BA}', '\u{25C4}', '\u{2195}', '\u{203C}', '\u{00B6}', '\u{00A7}', '\u{25AC}', '\u{21A8}',
127 '\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221F}', '\u{2194}', '\u{25B2}', '\u{25BC}',
128 ' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2',
129 '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E',
130 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
131 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
132 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~',
133 '\u{2302}', '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}',
134 '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', '\u{00EE}', '\u{00EC}', '\u{00C4}',
135 '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}',
136 '\u{00F9}', '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}',
137 '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', '\u{00F1}', '\u{00D1}', '\u{00AA}',
138 '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}',
139 '\u{00BB}', '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}',
140 '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255D}', '\u{255C}', '\u{255B}',
141 '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}',
142 '\u{255F}', '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}',
143 '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}',
144 '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}',
145 '\u{2580}', '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}',
146 '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', '\u{221E}', '\u{03C6}', '\u{03B5}',
147 '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}',
148 '\u{2248}', '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}',
149 '\u{00A0}',
150];
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct TilesetOptions {
155 pub bytes: Vec<u8>,
157 pub tile_width: u16,
159 pub tile_height: u16,
161 pub columns: Option<u16>,
165 pub codepage: Codepage,
167 pub spacing_cells_x: u16,
169 pub spacing_cells_y: u16,
171 pub transparent_color: Option<(u8, u8, u8)>,
178}
179
180impl TilesetOptions {
181 #[must_use]
186 pub const fn from_bytes(bytes: Vec<u8>) -> TilesetBuilder {
187 TilesetBuilder {
188 bytes,
189 tile_width: 0,
190 tile_height: 0,
191 columns: None,
192 codepage: Codepage::Cp437,
193 spacing_cells_x: 1,
194 spacing_cells_y: 1,
195 transparent_color: None,
196 }
197 }
198}
199
200pub struct TilesetBuilder {
249 bytes: Vec<u8>,
250 tile_width: u16,
251 tile_height: u16,
252 columns: Option<u16>,
253 codepage: Codepage,
254 spacing_cells_x: u16,
255 spacing_cells_y: u16,
256 transparent_color: Option<(u8, u8, u8)>,
257}
258
259impl TilesetBuilder {
260 #[must_use]
262 pub const fn tile_size(mut self, width: u16, height: u16) -> Self {
263 self.tile_width = width;
264 self.tile_height = height;
265 self
266 }
267
268 #[must_use]
272 pub const fn columns(mut self, cols: u16) -> Self {
273 self.columns = Some(cols);
274 self
275 }
276
277 #[must_use]
279 pub fn codepage(mut self, codepage: Codepage) -> Self {
280 self.codepage = codepage;
281 self
282 }
283
284 #[must_use]
288 pub fn start_codepoint(mut self, start: char) -> Self {
289 self.codepage = Codepage::Unicode { start };
290 self
291 }
292
293 #[must_use]
297 pub const fn spacing(mut self, x: u16, y: u16) -> Self {
298 self.spacing_cells_x = x;
299 self.spacing_cells_y = y;
300 self
301 }
302
303 #[must_use]
308 pub const fn transparent_color(mut self, r: u8, g: u8, b: u8) -> Self {
309 self.transparent_color = Some((r, g, b));
310 self
311 }
312
313 pub fn build(self) -> Result<TilesetOptions, TilesetError> {
321 if self.tile_width == 0 || self.tile_height == 0 {
322 return Err(TilesetError::ZeroTileSize);
323 }
324 if self.spacing_cells_x == 0 || self.spacing_cells_y == 0 {
325 return Err(TilesetError::ZeroSpacing);
326 }
327 if let Codepage::Custom(ref t) = self.codepage
328 && t.is_empty()
329 {
330 return Err(TilesetError::EmptyCodepage);
331 }
332 Ok(TilesetOptions {
333 bytes: self.bytes,
334 tile_width: self.tile_width,
335 tile_height: self.tile_height,
336 columns: self.columns,
337 codepage: self.codepage,
338 spacing_cells_x: self.spacing_cells_x,
339 spacing_cells_y: self.spacing_cells_y,
340 transparent_color: self.transparent_color,
341 })
342 }
343}
344
345#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn tileset_builder_rejects_zero_tile_size() {
353 let opts = TilesetOptions::from_bytes(vec![]).tile_size(0, 16).build();
354 assert!(matches!(opts, Err(TilesetError::ZeroTileSize)));
355 }
356
357 #[test]
358 fn tileset_builder_rejects_zero_spacing() {
359 let opts = TilesetOptions::from_bytes(vec![])
360 .tile_size(16, 16)
361 .spacing(0, 1)
362 .build();
363 assert!(matches!(opts, Err(TilesetError::ZeroSpacing)));
364 }
365
366 #[test]
367 fn tileset_builder_rejects_empty_custom_codepage() {
368 let opts = TilesetOptions::from_bytes(vec![])
369 .tile_size(16, 16)
370 .codepage(Codepage::Custom(vec![]))
371 .build();
372 assert!(matches!(opts, Err(TilesetError::EmptyCodepage)));
373 }
374
375 #[test]
376 fn tileset_builder_valid() {
377 let opts = TilesetOptions::from_bytes(vec![0u8; 64])
378 .tile_size(16, 16)
379 .start_codepoint('\u{E000}')
380 .spacing(2, 2)
381 .build()
382 .unwrap();
383 assert_eq!(opts.tile_width, 16);
384 assert_eq!(opts.spacing_cells_x, 2);
385 assert!(matches!(
386 opts.codepage,
387 Codepage::Unicode { start: '\u{E000}' }
388 ));
389 }
390
391 #[test]
392 fn cp437_codepage_spot_checks() {
393 assert_eq!(Codepage::Cp437.codepoint(32), Some(' '));
394 assert_eq!(Codepage::Cp437.codepoint(64), Some('@'));
395 assert_eq!(Codepage::Cp437.codepoint(176), Some('\u{2591}'));
396 assert_eq!(Codepage::Cp437.codepoint(256), None);
397 }
398
399 #[test]
400 fn identity_codepage_positional() {
401 assert_eq!(Codepage::Identity.codepoint(0), Some('\0'));
402 assert_eq!(Codepage::Identity.codepoint(65), Some('A'));
403 assert_eq!(Codepage::Identity.codepoint(0xD800), None);
405 assert_eq!(Codepage::Identity.codepoint(0xDFFF), None);
406 assert_eq!(Codepage::Identity.codepoint(0xE000), Some('\u{E000}'));
408 }
409
410 #[test]
411 fn unicode_codepage_offset() {
412 let cp = Codepage::Unicode { start: '\u{E000}' };
413 assert_eq!(cp.codepoint(0), Some('\u{E000}'));
414 assert_eq!(cp.codepoint(5), Some('\u{E005}'));
415 }
416
417 #[test]
418 fn custom_codepage_bounds() {
419 let cp = Codepage::Custom(vec!['A', 'B', 'C']);
420 assert_eq!(cp.codepoint(0), Some('A'));
421 assert_eq!(cp.codepoint(2), Some('C'));
422 assert_eq!(cp.codepoint(3), None);
423 }
424}