ipuz import

This commit is contained in:
2025-06-12 22:05:05 +03:00
parent 9b9ab7be27
commit 8b32d577e1
2 changed files with 219 additions and 0 deletions
+201
View File
@@ -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<Puzzle, String> {
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<Vec<(u32, String)>, 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
}
+18
View File
@@ -0,0 +1,18 @@
pub mod puz;
pub mod ipuz;
pub fn detect_and_parse(data: &[u8]) -> Result<crate::puzzle::Puzzle, String> {
// 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::<serde_json::Value>(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())
}