UNPKG

@xwordly/xword-parser

Version:

Fast, type-safe TypeScript library for parsing crossword puzzles (PUZ, iPUZ, JPZ, XD)

498 lines (494 loc) 15.9 kB
'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