diff --git a/cruciverb-core/src/import/ipuz.rs b/cruciverb-core/src/import/ipuz.rs new file mode 100644 index 0000000..892b216 --- /dev/null +++ b/cruciverb-core/src/import/ipuz.rs @@ -0,0 +1,201 @@ +use serde_json::Value; + +use crate::config::SymmetryType; +use crate::grid::{Cell, Grid}; +use crate::grid::slot::Direction; +use crate::puzzle::{Clue, PlacedWord, Puzzle}; + +pub fn from_ipuz(json: &Value) -> Result { + let dims = json.get("dimensions").ok_or("missing dimensions")?; + let width = dims.get("width").and_then(|v| v.as_u64()).ok_or("missing width")? as usize; + let height = dims.get("height").and_then(|v| v.as_u64()).ok_or("missing height")? as usize; + + if width == 0 || height == 0 || width > 50 || height > 50 { + return Err(format!("invalid grid size {}x{}", width, height)); + } + + let solution = json.get("solution").ok_or("missing solution grid")?; + let rows = solution.as_array().ok_or("solution is not an array")?; + if rows.len() != height { + return Err(format!("solution has {} rows, expected {}", rows.len(), height)); + } + + let mut grid = Grid::new(width, height); + for (r, row) in rows.iter().enumerate() { + let cells = row.as_array().ok_or("solution row is not an array")?; + if cells.len() != width { + return Err(format!("row {} has {} cells, expected {}", r, cells.len(), width)); + } + for (c, cell) in cells.iter().enumerate() { + if cell.is_null() { + grid.set(r, c, Cell::Empty); + } else if let Some(s) = cell.as_str() { + if s == "#" { + grid.set(r, c, Cell::Black); + } else if s.len() == 1 { + let ch = s.chars().next().unwrap().to_ascii_uppercase(); + if ch.is_ascii_alphabetic() { + grid.set(r, c, Cell::Letter(ch)); + } else { + grid.set(r, c, Cell::Empty); + } + } else { + grid.set(r, c, Cell::Empty); + } + } else if cell.as_u64().is_some() || cell.as_i64().is_some() { + // some ipuz files use 0 for empty cells in solution + grid.set(r, c, Cell::Empty); + } else { + grid.set(r, c, Cell::Empty); + } + } + } + + // parse clues + let clues_obj = json.get("clues").ok_or("missing clues")?; + let across_arr = clues_obj.get("Across").and_then(|v| v.as_array()).ok_or("missing Across clues")?; + let down_arr = clues_obj.get("Down").and_then(|v| v.as_array()).ok_or("missing Down clues")?; + + let parsed_across = parse_clue_array(across_arr)?; + let parsed_down = parse_clue_array(down_arr)?; + + // build word list and full clues from grid + parsed clue data + let mut clues_across = Vec::new(); + let mut clues_down = Vec::new(); + let mut words = Vec::new(); + + for (num, text) in &parsed_across { + if let Some((r, c)) = find_numbered_cell(&grid, &json, width, height, *num) { + 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: text.clone(), + answer, + definition: None, + wordplay_type: None, + }); + } + } + + for (num, text) in &parsed_down { + if let Some((r, c)) = find_numbered_cell(&grid, &json, width, height, *num) { + 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: text.clone(), + answer, + definition: None, + wordplay_type: None, + }); + } + } + + 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_clue_array(arr: &[Value]) -> Result, String> { + let mut result = Vec::new(); + for entry in arr { + if let Some(pair) = entry.as_array() { + if pair.len() >= 2 { + let num = pair[0].as_u64().ok_or("clue number not a number")? as u32; + let text = pair[1].as_str().unwrap_or("").to_string(); + result.push((num, text)); + } + } else if let Some(obj) = entry.as_object() { + // some ipuz files use object format: {"number": N, "clue": "text"} + let num = obj.get("number").and_then(|v| v.as_u64()).ok_or("clue missing number")? as u32; + let text = obj.get("clue").and_then(|v| v.as_str()).unwrap_or("").to_string(); + result.push((num, text)); + } + } + Ok(result) +} + +fn find_numbered_cell(grid: &Grid, json: &Value, width: usize, height: usize, target: u32) -> Option<(usize, usize)> { + // try the puzzle grid first (ipuz stores numbers there) + if let Some(puzzle_grid) = json.get("puzzle").and_then(|v| v.as_array()) { + for (r, row) in puzzle_grid.iter().enumerate() { + if let Some(cells) = row.as_array() { + for (c, cell) in cells.iter().enumerate() { + let num = if let Some(n) = cell.as_u64() { + n as u32 + } else if let Some(obj) = cell.as_object() { + obj.get("cell").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + } else { + continue + }; + if num == target { + return Some((r, c)); + } + } + } + } + } + + // fallback: compute numbering from grid + 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 { + if number == target { + return Some((r, c)); + } + number += 1; + } + } + } + None +} + +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 +} diff --git a/cruciverb-core/src/import/mod.rs b/cruciverb-core/src/import/mod.rs new file mode 100644 index 0000000..f8e12d8 --- /dev/null +++ b/cruciverb-core/src/import/mod.rs @@ -0,0 +1,18 @@ +pub mod puz; +pub mod ipuz; + +pub fn detect_and_parse(data: &[u8]) -> Result { + // try puz first (check for ACROSS&DOWN magic at offset 2) + if data.len() > 14 && &data[2..14] == b"ACROSS&DOWN\0" { + return puz::from_puz(data); + } + // try ipuz (valid JSON with crossword markers) + if let Ok(text) = std::str::from_utf8(data) { + if let Ok(json) = serde_json::from_str::(text) { + if json.get("kind").is_some() || json.get("puzzle").is_some() || json.get("dimensions").is_some() { + return ipuz::from_ipuz(&json); + } + } + } + Err("unrecognized file format".into()) +}