diff --git a/cruciverb-core/src/import/puz.rs b/cruciverb-core/src/import/puz.rs new file mode 100644 index 0000000..2580b9b --- /dev/null +++ b/cruciverb-core/src/import/puz.rs @@ -0,0 +1,192 @@ +use crate::config::SymmetryType; +use crate::grid::{Cell, Grid}; +use crate::grid::slot::Direction; +use crate::puzzle::{Clue, PlacedWord, Puzzle}; + +pub fn from_puz(data: &[u8]) -> Result { + if data.len() < 52 { + return Err("file too short for .puz format".into()); + } + if &data[2..14] != b"ACROSS&DOWN\0" { + return Err("missing ACROSS&DOWN magic".into()); + } + + let width = data[44] as usize; + let height = data[45] as usize; + let num_clues = u16::from_le_bytes([data[46], data[47]]) as usize; + + if width == 0 || height == 0 || width > 50 || height > 50 { + return Err(format!("invalid grid size {}x{}", width, height)); + } + + let board_size = width * height; + let solution_start = 52; + let state_start = solution_start + board_size; + let strings_start = state_start + board_size; + + if data.len() < strings_start { + return Err("file truncated before grid data".into()); + } + + let solution = &data[solution_start..state_start]; + + // build grid + let mut grid = Grid::new(width, height); + for i in 0..board_size { + let r = i / width; + let c = i % width; + let b = solution[i]; + if b == b'.' { + grid.set(r, c, Cell::Black); + } else if b.is_ascii_alphabetic() { + grid.set(r, c, Cell::Letter((b as char).to_ascii_uppercase())); + } else { + grid.set(r, c, Cell::Empty); + } + } + + // parse null-terminated strings after the state grid + let string_data = &data[strings_start..]; + let strings = parse_null_strings(string_data); + + // strings[0] = title, strings[1] = author, strings[2] = copyright + // strings[3..3+num_clues] = clue texts, rest = notes + if strings.len() < 3 + num_clues { + return Err(format!("expected {} clue strings, found {}", num_clues, strings.len().saturating_sub(3))); + } + + let clue_strings: Vec<&str> = strings[3..3 + num_clues].iter().map(|s| s.as_str()).collect(); + + // number the cells and assign clues + let (clues_across, clues_down, words) = number_and_assign( + &grid, width, height, &clue_strings, + )?; + + Ok(Puzzle { + grid, + words, + clues_across, + clues_down, + width, + height, + symmetry: SymmetryType::None, + difficulty_score: 50, + generation_time_ms: 0, + theme: None, + theme_entries: vec![], + is_cryptic: false, + }) +} + +fn parse_null_strings(data: &[u8]) -> Vec { + let mut result = Vec::new(); + let mut start = 0; + for (i, &b) in data.iter().enumerate() { + if b == 0 { + let s = String::from_utf8_lossy(&data[start..i]).to_string(); + result.push(s); + start = i + 1; + } + } + result +} + +fn number_and_assign( + grid: &Grid, + width: usize, + height: usize, + clue_strings: &[&str], +) -> Result<(Vec, Vec, Vec), String> { + let mut clues_across = Vec::new(); + let mut clues_down = Vec::new(); + let mut words = Vec::new(); + let mut clue_idx = 0; + let mut number = 1u32; + + for r in 0..height { + for c in 0..width { + if grid.get(r, c).is_black() { + continue; + } + + let starts_across = (c == 0 || grid.get(r, c.wrapping_sub(1)).is_black() || c.wrapping_sub(1) >= width) + && c + 1 < width && !grid.get(r, c + 1).is_black(); + + let starts_down = (r == 0 || grid.get(r.wrapping_sub(1), c).is_black() || r.wrapping_sub(1) >= height) + && r + 1 < height && !grid.get(r + 1, c).is_black(); + + if !starts_across && !starts_down { + continue; + } + + let num = number; + number += 1; + + if starts_across { + if clue_idx >= clue_strings.len() { + return Err("ran out of clue strings".into()); + } + let answer = read_word(grid, r, c, width, height, Direction::Across); + words.push(PlacedWord { + word: answer.clone(), + row: r, + col: c, + direction: Direction::Across, + clue_number: num, + }); + clues_across.push(Clue { + number: num, + direction: Direction::Across, + text: clue_strings[clue_idx].to_string(), + answer, + definition: None, + wordplay_type: None, + }); + clue_idx += 1; + } + + if starts_down { + if clue_idx >= clue_strings.len() { + return Err("ran out of clue strings".into()); + } + let answer = read_word(grid, r, c, width, height, Direction::Down); + words.push(PlacedWord { + word: answer.clone(), + row: r, + col: c, + direction: Direction::Down, + clue_number: num, + }); + clues_down.push(Clue { + number: num, + direction: Direction::Down, + text: clue_strings[clue_idx].to_string(), + answer, + definition: None, + wordplay_type: None, + }); + clue_idx += 1; + } + } + } + + Ok((clues_across, clues_down, words)) +} + +fn read_word(grid: &Grid, row: usize, col: usize, width: usize, height: usize, dir: Direction) -> String { + let mut word = String::new(); + let (mut r, mut c) = (row, col); + loop { + if r >= height || c >= width { break; } + match grid.get(r, c) { + Cell::Letter(ch) => word.push(ch), + Cell::Empty => word.push('_'), + Cell::Black => break, + } + match dir { + Direction::Across => c += 1, + Direction::Down => r += 1, + } + } + word +}