ipuz import
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user