@xwordly/xword-parser
Version:
Fast, type-safe TypeScript library for parsing crossword puzzles (PUZ, iPUZ, JPZ, XD)
498 lines (494 loc) • 15.9 kB
JavaScript
'use strict';
var chunk3QROV6K6_js = require('./chunk-3QROV6K6.js');
var chunkKVCCVFYY_js = require('./chunk-KVCCVFYY.js');
// src/binary-reader.ts
var BinaryReader = class {
_buffer;
_offset;
constructor(data) {
if (data instanceof ArrayBuffer) {
this._buffer = Buffer.from(data);
} else if (data instanceof Uint8Array) {
this._buffer = Buffer.from(data);
} else {
this._buffer = data;
}
this._offset = 0;
}
/**
* Read an unsigned 8-bit integer and advance the offset
*/
readUInt8() {
if (this._offset + 1 > this._buffer.length) {
throw new chunkKVCCVFYY_js.BinaryParseError(
`Cannot read byte at offset ${this._offset}: buffer too short (length: ${this._buffer.length})`
);
}
const value = this._buffer.readUInt8(this._offset);
this._offset += 1;
return value;
}
/**
* Read an unsigned 16-bit integer (little-endian) and advance the offset
*/
readUInt16LE() {
if (this._offset + 2 > this._buffer.length) {
throw new chunkKVCCVFYY_js.BinaryParseError(
`Cannot read 16-bit value at offset ${this._offset}: buffer too short (length: ${this._buffer.length})`
);
}
const value = this._buffer.readUInt16LE(this._offset);
this._offset += 2;
return value;
}
/**
* Read a specified number of bytes and advance the offset
*/
readBytes(length) {
if (this._offset + length > this._buffer.length) {
throw new chunkKVCCVFYY_js.BinaryParseError(
`Cannot read ${length} bytes at offset ${this._offset}: buffer too short (length: ${this._buffer.length})`
);
}
const value = this._buffer.slice(this._offset, this._offset + length);
this._offset += length;
return value;
}
/**
* Read a string of specified length with optional null trimming
* @param length Number of bytes to read
* @param trimNull Whether to trim at first null byte (default: true)
* @param encoding Character encoding (default: 'latin1')
*/
readString(length, trimNull = true, encoding = "latin1") {
const bytes = this.readBytes(length);
if (trimNull) {
let end = bytes.indexOf(0);
if (end === -1) end = length;
return bytes.toString(encoding, 0, end);
}
return bytes.toString(encoding);
}
/**
* Read a null-terminated string and advance past the null terminator
* @param encoding Character encoding (default: 'latin1')
*/
readNullTerminatedString(encoding = "latin1") {
const start = this._offset;
while (this._offset < this._buffer.length && this._buffer[this._offset] !== 0) {
this._offset++;
}
if (this._offset >= this._buffer.length) {
throw new chunkKVCCVFYY_js.BinaryParseError(
`Cannot read null-terminated string at offset ${start}: buffer ended without null terminator`
);
}
const str = this._buffer.toString(encoding, start, this._offset);
this._offset++;
return str;
}
/**
* Move the read position to a specific offset
*/
seek(position) {
if (position < 0 || position > this._buffer.length) {
throw new chunkKVCCVFYY_js.BinaryParseError(
`Cannot seek to position ${position}: out of bounds (buffer length: ${this._buffer.length})`
);
}
this._offset = position;
}
/**
* Get the current read position
*/
get position() {
return this._offset;
}
/**
* Get the total length of the buffer
*/
get length() {
return this._buffer.length;
}
/**
* Get the underlying buffer
*/
get buffer() {
return this._buffer;
}
/**
* Check if there's more data to read
*/
hasMore() {
return this._offset < this._buffer.length;
}
/**
* Get the number of bytes remaining to read
*/
get remaining() {
return this._buffer.length - this._offset;
}
};
// src/puz.ts
function readHeader(reader) {
const magicBytes = Buffer.from(chunk3QROV6K6_js.PUZ_MAGIC_STRING, "latin1");
let magicOffset = -1;
for (let i = 0; i <= reader.length - magicBytes.length; i++) {
let found = true;
for (let j = 0; j < magicBytes.length; j++) {
if (reader.buffer[i + j] !== magicBytes[j]) {
found = false;
break;
}
}
if (found) {
magicOffset = i;
break;
}
}
if (magicOffset === -1) {
throw new chunkKVCCVFYY_js.PuzParseError(
`Invalid PUZ file: magic string "${chunk3QROV6K6_js.PUZ_MAGIC_STRING}" not found`,
"PUZ_INVALID_HEADER" /* PUZ_INVALID_HEADER */
);
}
const headerStart = magicOffset - 2;
if (headerStart < 0) {
throw new chunkKVCCVFYY_js.PuzParseError(
"Invalid PUZ file: magic string found too early in file",
"PUZ_INVALID_HEADER" /* PUZ_INVALID_HEADER */
);
}
if (headerStart + chunk3QROV6K6_js.PUZ_HEADER_SIZE > reader.length) {
throw new chunkKVCCVFYY_js.PuzParseError(
"Invalid PUZ file: insufficient data for header",
"PUZ_INVALID_HEADER" /* PUZ_INVALID_HEADER */
);
}
reader.seek(headerStart);
const checksum = reader.readUInt16LE();
const magic = reader.readString(12, true, "latin1");
const cibChecksum = reader.readUInt16LE();
const maskedLowChecksum = reader.readUInt16LE();
const maskedHighChecksum = reader.readUInt16LE();
const version = reader.readString(4, true, "latin1");
const reserved1 = reader.readUInt16LE();
const scrambledChecksum = reader.readUInt16LE();
const reserved2 = reader.readBytes(12);
reader.readBytes(4);
const width = reader.readUInt8();
const height = reader.readUInt8();
const numClues = reader.readUInt16LE();
const puzzleType = reader.readUInt16LE();
const scrambledTag = reader.readUInt16LE();
const header = {
checksum,
magic,
cibChecksum,
maskedLowChecksum,
maskedHighChecksum,
version,
reserved1,
scrambledChecksum,
reserved2,
width,
height,
numClues,
puzzleType,
scrambledTag
};
if (header.magic !== chunk3QROV6K6_js.PUZ_MAGIC_STRING) {
throw new chunkKVCCVFYY_js.InvalidFileError(
"PUZ",
`magic string mismatch after positioning. Expected "${chunk3QROV6K6_js.PUZ_MAGIC_STRING}", got "${header.magic}"`
);
}
return header;
}
function parseGrid(solution, playerState, width, height) {
const grid = [];
for (let row = 0; row < height; row++) {
const cells = [];
for (let col = 0; col < width; col++) {
const index = row * width + col;
const solutionChar = solution[index];
const playerChar = playerState[index];
cells.push({
solution: solutionChar === "." ? void 0 : solutionChar,
playerState: playerChar === "-" || playerChar === "." ? void 0 : playerChar,
isBlack: solutionChar === "."
});
}
grid.push(cells);
}
return grid;
}
function assignClueNumbers(grid) {
const cluePositions = /* @__PURE__ */ new Map();
let clueNumber = 1;
const height = grid.length;
const width = grid[0]?.length || 0;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
if (grid[row]?.[col]?.isBlack) continue;
const hasAcross = (col === 0 || grid[row]?.[col - 1]?.isBlack) && col < width - 1 && !grid[row]?.[col + 1]?.isBlack;
const hasDown = (row === 0 || grid[row - 1]?.[col]?.isBlack) && row < height - 1 && !grid[row + 1]?.[col]?.isBlack;
if (hasAcross || hasDown) {
if (hasAcross) {
cluePositions.set(`A${row},${col}`, clueNumber);
}
if (hasDown) {
cluePositions.set(`D${row},${col}`, clueNumber);
}
clueNumber++;
}
}
}
return cluePositions;
}
function parseClues(clueStrings, cluePositions) {
const across = [];
const down = [];
let clueIndex = 0;
const sortedPositions = Array.from(cluePositions.entries()).sort((a, b) => {
const [aType, aPos] = a[0].split(",");
const [bType, bPos] = b[0].split(",");
const aRow = parseInt(aType?.substring(1) || "0");
const bRow = parseInt(bType?.substring(1) || "0");
const aCol = parseInt(aPos || "0");
const bCol = parseInt(bPos || "0");
if (aRow !== bRow) return aRow - bRow;
return aCol - bCol;
});
for (const [key, number] of sortedPositions) {
if (key.startsWith("A") && clueIndex < clueStrings.length) {
across.push({
number,
text: clueStrings[clueIndex++] || ""
});
}
}
for (const [key, number] of sortedPositions) {
if (key.startsWith("D") && clueIndex < clueStrings.length) {
down.push({
number,
text: clueStrings[clueIndex++] || ""
});
}
}
return { across, down };
}
function parseExtraSections(reader, grid) {
const result = {};
while (reader.hasMore()) {
if (reader.position + 8 > reader.length) break;
while (reader.hasMore() && reader.buffer[reader.position] === 0) {
reader.readUInt8();
}
if (reader.position + 8 > reader.length) break;
const sectionTitle = reader.readString(4, true, "latin1");
const dataLength = reader.readUInt16LE();
reader.readUInt16LE();
if (dataLength > reader.length - reader.position) break;
const sectionData = reader.readBytes(dataLength);
switch (sectionTitle) {
case chunk3QROV6K6_js.PUZ_SECTION_GRBS: {
const height = grid.length;
const width = grid[0]?.length || 0;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const index = row * width + col;
if (index < sectionData.length) {
const rebusKey = sectionData[index];
if (rebusKey && rebusKey > 0 && grid[row]?.[col]) {
grid[row][col].hasRebus = true;
grid[row][col].rebusKey = rebusKey - 1;
}
}
}
}
break;
}
case chunk3QROV6K6_js.PUZ_SECTION_RTBL: {
const tableStr = sectionData.toString("latin1");
const entries = tableStr.split(";");
result.rebusTable = /* @__PURE__ */ new Map();
for (const entry of entries) {
if (entry.includes(":")) {
const [key, value] = entry.split(":");
const keyNum = Number.parseInt(key || "0", 10);
if (!Number.isNaN(keyNum)) {
result.rebusTable.set(keyNum, value || "");
}
}
}
break;
}
case chunk3QROV6K6_js.PUZ_SECTION_GEXT: {
const height = grid.length;
const width = grid[0]?.length || 0;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const index = row * width + col;
if (index < sectionData.length && grid[row]?.[col]) {
const flags = sectionData[index];
if (flags && flags & chunk3QROV6K6_js.PUZ_CIRCLED_CELL_FLAG) {
grid[row][col].isCircled = true;
}
}
}
}
break;
}
case chunk3QROV6K6_js.PUZ_SECTION_LTIM: {
const timerStr = sectionData.toString("latin1");
const [elapsed, running] = timerStr.split(",");
result.timer = {
elapsed: parseInt(elapsed || "0") || 0,
running: running === "0" ? false : true
};
break;
}
}
}
return result;
}
function parsePuz(data, options) {
let buffer;
if (typeof data === "string") {
buffer = Buffer.from(data, "base64");
} else if (data instanceof Buffer) {
buffer = data;
} else {
buffer = Buffer.from(data);
}
try {
const reader = new BinaryReader(buffer);
const header = readHeader(reader);
if (header.width <= 0 || header.height <= 0) {
throw new chunkKVCCVFYY_js.PuzParseError(
`Invalid puzzle dimensions: width=${header.width}, height=${header.height}`,
"PUZ_INVALID_GRID" /* PUZ_INVALID_GRID */
);
}
const maxWidth = options?.maxGridSize?.width ?? chunk3QROV6K6_js.MAX_GRID_WIDTH;
const maxHeight = options?.maxGridSize?.height ?? chunk3QROV6K6_js.MAX_GRID_HEIGHT;
if (header.width > maxWidth || header.height > maxHeight) {
throw new chunkKVCCVFYY_js.PuzParseError(
`Grid dimensions too large: ${header.width}x${header.height}. Maximum supported size is ${maxWidth}x${maxHeight}`,
"PUZ_INVALID_GRID" /* PUZ_INVALID_GRID */
);
}
const gridSize = header.width * header.height;
const solution = reader.readString(gridSize, true, "latin1");
const playerState = reader.readString(gridSize, true, "latin1");
const title = reader.readNullTerminatedString("latin1");
const author = reader.readNullTerminatedString("latin1");
const copyright = reader.readNullTerminatedString("latin1");
const clueStrings = [];
for (let i = 0; i < header.numClues; i++) {
clueStrings.push(reader.readNullTerminatedString("latin1"));
}
const notes = reader.readNullTerminatedString("latin1");
const grid = parseGrid(solution, playerState, header.width, header.height);
const cluePositions = assignClueNumbers(grid);
const { across, down } = parseClues(clueStrings, cluePositions);
const extras = parseExtraSections(reader, grid);
return {
width: header.width,
height: header.height,
metadata: {
title: title || void 0,
author: author || void 0,
copyright: copyright || void 0,
notes: notes || void 0
},
grid,
across,
down,
rebusTable: extras.rebusTable,
isScrambled: header.scrambledTag !== 0,
timer: extras.timer
};
} catch (error) {
if (error instanceof chunkKVCCVFYY_js.BinaryParseError) {
throw new chunkKVCCVFYY_js.PuzParseError(error.message, "PUZ_PARSE_ERROR" /* PUZ_PARSE_ERROR */);
}
throw error;
}
}
function convertPuzToUnified(puzzle) {
const grid = {
width: puzzle.width,
height: puzzle.height,
cells: []
};
let cellNumber = 1;
for (let y = 0; y < puzzle.height; y++) {
const row = [];
for (let x = 0; x < puzzle.width; x++) {
const puzCell = puzzle.grid[y]?.[x];
let number;
if (puzCell && !puzCell.isBlack) {
const needsNumber = (
// Start of across word
(x === 0 || puzzle.grid[y]?.[x - 1]?.isBlack) && x < puzzle.width - 1 && !puzzle.grid[y]?.[x + 1]?.isBlack || // Start of down word
(y === 0 || puzzle.grid[y - 1]?.[x]?.isBlack) && y < puzzle.height - 1 && !puzzle.grid[y + 1]?.[x]?.isBlack
);
if (needsNumber) {
number = cellNumber++;
}
}
const cell = {
solution: puzCell?.solution,
number,
isBlack: puzCell?.isBlack || false
};
if (puzCell?.isCircled) {
cell.isCircled = true;
}
if (puzCell?.hasRebus) {
cell.hasRebus = true;
if (puzCell.rebusKey !== void 0) {
cell.rebusKey = puzCell.rebusKey;
}
}
row.push(cell);
}
grid.cells.push(row);
}
const clues = {
across: puzzle.across.map((c) => ({
number: c.number,
text: c.text
})),
down: puzzle.down.map((c) => ({
number: c.number,
text: c.text
}))
};
const result = {
title: puzzle.metadata.title,
author: puzzle.metadata.author,
copyright: puzzle.metadata.copyright,
notes: puzzle.metadata.notes,
grid,
clues,
rebusTable: puzzle.rebusTable
};
const additionalProps = {};
if (puzzle.isScrambled) {
additionalProps.isScrambled = puzzle.isScrambled;
}
if (puzzle.timer) {
additionalProps.timer = puzzle.timer;
}
if (Object.keys(additionalProps).length > 0) {
result.additionalProperties = additionalProps;
}
return result;
}
exports.convertPuzToUnified = convertPuzToUnified;
exports.parsePuz = parsePuz;
//# sourceMappingURL=chunk-5YBHNJTI.js.map
//# sourceMappingURL=chunk-5YBHNJTI.js.map